Compare commits
237 Commits
cf63408023
...
0acd13f0f0
Author | SHA1 | Date | |
---|---|---|---|
0acd13f0f0 | |||
cfdb948372 | |||
9b3130f363 | |||
d10efc274c | |||
2e1b7fab53 | |||
91b92b2cc4 | |||
6e3ab111f3 | |||
7de0b1c00e | |||
8107743af2 | |||
aeff82f625 | |||
935cb1c38b | |||
acd5b7706b | |||
0b111d1012 | |||
211d8f37d8 | |||
e2517a7786 | |||
aaf5b4fecc | |||
173e251e9f | |||
3c0891e069 | |||
2dc3de43d1 | |||
1b332b5b3b | |||
6ef34afc6d | |||
1cd6b63f1e | |||
6fd17ee70e | |||
050d151c67 | |||
25ec569cd8 | |||
2dd9847566 | |||
ef108f2e4a | |||
4894c2d627 | |||
f0861a9a62 | |||
221cf89d10 | |||
c54ef9b84a | |||
7aad272316 | |||
1f43353360 | |||
5b54566c89 | |||
a9e3d331fc | |||
192086546a | |||
a30b8f888d | |||
1e3fc9be3d | |||
2ec08c7f68 | |||
3adf9b0d00 | |||
6fba0f28db | |||
9bdc2c09e5 | |||
1eb95cde92 | |||
719e793860 | |||
f0e68f7b48 | |||
72501bd0b3 | |||
bd6aaa07c8 | |||
77a9f81a1d | |||
3eb88f66cf | |||
3fb76d59b2 | |||
72db2863d0 | |||
81ad0cf4db | |||
b6f2a89e04 | |||
e2c735b804 | |||
3f576ce3e5 | |||
1e51e8bb8b | |||
ad48191b53 | |||
d851f302cc | |||
09ba2122e7 | |||
9b16bf6e6f | |||
ab6b5eefc0 | |||
993cd5ed1c | |||
b733bb4154 | |||
2edbbc3ede | |||
af3013ad67 | |||
d02fe73952 | |||
32444d5a7e | |||
b003b5e04b | |||
5d217a264a | |||
8d8c4d2da3 | |||
|
943a03bd2c | ||
|
680303cfa2 | ||
|
e16bbf8acf | ||
42ebbdba6d | |||
a2b477a3dc | |||
3c7d5ad5ad | |||
4261e26f19 | |||
a0ee4e1312 | |||
93630650fc | |||
36b20fa2dd | |||
ae1c4dd3e6 | |||
69438f44b3 | |||
aceaa01cdb | |||
7fe220a630 | |||
f07599adf2 | |||
1b89b16705 | |||
0fb1148508 | |||
208145d288 | |||
a6bd60077d | |||
387c20a708 | |||
1a9d34d347 | |||
ed4ee53fdb | |||
4848a13fa0 | |||
b9ebd506c8 | |||
249f49f7b3 | |||
db3f5eb066 | |||
c2f43e0096 | |||
d81586d026 | |||
18a9419cef | |||
8a69083c19 | |||
ba1b79f657 | |||
1a2415925f | |||
16a597183a | |||
2461430869 | |||
605201dbc8 | |||
214d3250fe | |||
ea9c634a25 | |||
62095cb170 | |||
aba85c70c1 | |||
98569cff69 | |||
952dfdc521 | |||
93724802d8 | |||
e5f19e3b8b | |||
4479506ee0 | |||
359d4508b1 | |||
ef9ba68790 | |||
53ce0d9e54 | |||
505921045e | |||
2c71e01e5a | |||
c3cf84ee7d | |||
5787a8943d | |||
313e276ad6 | |||
310891bf16 | |||
8852356966 | |||
6243766ecc | |||
2d5e987fcc | |||
bf851c2bd6 | |||
0327f254a2 | |||
a0c7078593 | |||
0cbea9607e | |||
e799d516ea | |||
c630d8f091 | |||
2494fbb837 | |||
cb2560d46f | |||
240ed5f859 | |||
0365730e0e | |||
b9d5eab3ea | |||
16c84d59dc | |||
621c396407 | |||
3f2cc3d97a | |||
ce927308c4 | |||
df99eb0aab | |||
e8473d4f5b | |||
1175d77c55 | |||
570f4ca7d9 | |||
e4b9c8f1bc | |||
ea6e7c5d8c | |||
a462945c98 | |||
003d4d65e5 | |||
38fa5ab991 | |||
e22e7b9c90 | |||
fc6a8eae9d | |||
4dab811388 | |||
012dc5ec69 | |||
f72f67342d | |||
283ac315d8 | |||
1a5b0f372d | |||
de40e859d7 | |||
6140de8eea | |||
a9fcbd7909 | |||
a963153c2a | |||
8efb743b84 | |||
f251d6b97b | |||
8a5a96d02c | |||
fc0dd14b4d | |||
4d67c157f0 | |||
a678ef70e7 | |||
87d320b6da | |||
6b7d3c4b7c | |||
8a99f8f6b1 | |||
4aa24cc0a1 | |||
e309f3bbd0 | |||
a757c45b84 | |||
ed397e352f | |||
cf6cec4d32 | |||
c4422355e3 | |||
dd947ecd39 | |||
968b59aaee | |||
b9cb023306 | |||
031ef140f3 | |||
7376607475 | |||
1aea6ee588 | |||
12717ba25e | |||
a1fa666cd3 | |||
30cfd67e28 | |||
068d156da3 | |||
ce4ed9b0a9 | |||
b8acbe7359 | |||
c61485638b | |||
fd20d5177d | |||
2a603e1e41 | |||
e4d71c5a39 | |||
842a6ebe16 | |||
14ecc63944 | |||
18fb728973 | |||
a11b75f1cb | |||
86abadd6bb | |||
ab47b06fd6 | |||
640ffcb77e | |||
26b6abe66b | |||
c00df84f2a | |||
2b7b7a10bc | |||
5b18b3d50d | |||
be24afc8bf | |||
5332572b2e | |||
6551fda493 | |||
e3d33f201c | |||
ea3d550f64 | |||
21c1632233 | |||
1a66cadb53 | |||
c8b1330244 | |||
4c5204598e | |||
b8aedc842e | |||
c1579cb106 | |||
c1ff949346 | |||
3eea6c2ff9 | |||
d1f826bdb5 | |||
5586fcff7a | |||
f98b18affc | |||
28135244c3 | |||
47533c7512 | |||
f79f35e2be | |||
c43c4a9b24 | |||
81a47a12ec | |||
9e3a0a0f1d | |||
2d0426c0a3 | |||
7f366d3f3c | |||
3dd1e0461c | |||
8036a3a5be | |||
29a692de5f | |||
4f515d4733 | |||
2c28e95bd9 | |||
e7d354d4c7 | |||
5b64506612 | |||
f2135081ef | |||
ae9b1a8215 | |||
0f5b3d62b1 |
4
.gitignore
vendored
4
.gitignore
vendored
@ -52,7 +52,11 @@ app.*.map.json
|
||||
**/*.g.dart
|
||||
**/*.freezed.dart
|
||||
**/*.moxxy.dart
|
||||
lib/i18n/*.dart
|
||||
|
||||
# Direnv
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
# Android artifacts
|
||||
.android
|
||||
|
2
.gitlint
2
.gitlint
@ -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]
|
||||
|
@ -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
|
||||
|
@ -13,3 +13,6 @@ analyzer:
|
||||
- "**/*.freezed.dart"
|
||||
- "**/*.moxxy.dart"
|
||||
- "test/"
|
||||
- "integration_test/"
|
||||
- "lib/service/database/migrations/*.dart"
|
||||
- "lib/i18n/*.dart"
|
||||
|
BIN
assets/fonts/RobotoMono-Regular.ttf
Normal file
BIN
assets/fonts/RobotoMono-Regular.ttf
Normal file
Binary file not shown.
224
assets/i18n/strings.i18n.json
Normal file
224
assets/i18n/strings.i18n.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
224
assets/i18n/strings_de.i18n.json
Normal file
224
assets/i18n/strings_de.i18n.json
Normal 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
7
build.yaml
Normal file
@ -0,0 +1,7 @@
|
||||
targets:
|
||||
$default:
|
||||
builders:
|
||||
slang_build_runner:
|
||||
options:
|
||||
input_directory: assets/i18n
|
||||
output_directory: lib/i18n
|
@ -44,9 +44,7 @@
|
||||
ripgrep # General utilities
|
||||
];
|
||||
|
||||
ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk";
|
||||
JAVA_HOME = pinnedJDK;
|
||||
ANDROID_AVD_HOME = (toString ./.) + "/.android/avd";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
67
integration_test/backoff_test.dart
Normal file
67
integration_test/backoff_test.dart
Normal 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));
|
||||
}
|
@ -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"
|
||||
|
@ -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
|
||||
@ -103,7 +110,7 @@ void main() async {
|
||||
setupBlocs(navKey);
|
||||
|
||||
await initializeServiceIfNeeded();
|
||||
|
||||
|
||||
runApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
@ -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: MyApp(navKey),
|
||||
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;
|
||||
|
@ -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:');
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
@ -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;
|
||||
@ -55,25 +55,30 @@ class ConversationService {
|
||||
|
||||
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache.
|
||||
Future<Conversation> updateConversation(int id, {
|
||||
String? lastMessageBody,
|
||||
int? lastChangeTimestamp,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
}
|
||||
) async {
|
||||
String? lastMessageBody,
|
||||
int? lastChangeTimestamp,
|
||||
bool? lastMessageRetracted,
|
||||
int? lastMessageId,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
138
lib/service/cryptography/cryptography.dart
Normal file
138
lib/service/cryptography/cryptography.dart
Normal 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!);
|
||||
}
|
||||
}
|
150
lib/service/cryptography/implementations.dart
Normal file
150
lib/service/cryptography/implementations.dart
Normal 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,
|
||||
);
|
||||
}
|
64
lib/service/cryptography/types.dart
Normal file
64
lib/service/cryptography/types.dart
Normal 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;
|
||||
}
|
@ -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;
|
||||
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
@ -121,15 +146,17 @@ class DatabaseService {
|
||||
|
||||
/// Updates the conversation with id [id] inside the database.
|
||||
Future<Conversation> updateConversation(int id, {
|
||||
String? lastMessageBody,
|
||||
int? lastChangeTimestamp,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
}
|
||||
) async {
|
||||
String? lastMessageBody,
|
||||
int? lastChangeTimestamp,
|
||||
bool? lastMessageRetracted,
|
||||
int? lastMessageId,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
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()),
|
||||
);
|
||||
}
|
||||
|
||||
@ -315,19 +378,46 @@ class DatabaseService {
|
||||
final msg = messagesRaw.first;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
15
lib/service/database/migrations/0000_language.dart
Normal file
15
lib/service/database/migrations/0000_language.dart
Normal 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(),
|
||||
);
|
||||
}
|
11
lib/service/database/migrations/0000_retraction.dart
Normal file
11
lib/service/database/migrations/0000_retraction.dart
Normal 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)};',
|
||||
);
|
||||
}
|
@ -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)};',
|
||||
);
|
||||
}
|
13
lib/service/database/migrations/0000_xmpp_state.dart
Normal file
13
lib/service/database/migrations/0000_xmpp_state.dart
Normal 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
|
||||
)''',
|
||||
);
|
||||
}
|
@ -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,68 +85,83 @@ 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<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences) async {
|
||||
final xmpp = GetIt.I.get<XmppService>();
|
||||
final state = await xmpp.getXmppState();
|
||||
|
||||
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
|
||||
|
||||
// Check some permissions
|
||||
final storagePerm = await Permission.storage.status;
|
||||
final permissions = List<int>.empty(growable: true);
|
||||
if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
|
||||
permissions.add(Permission.storage.value);
|
||||
|
||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
||||
askedStoragePermission: true,
|
||||
),);
|
||||
}
|
||||
|
||||
return PreStartDoneEvent(
|
||||
state: 'logged_in',
|
||||
jid: state.jid,
|
||||
displayName: state.displayName ?? state.jid!.split('@').first,
|
||||
avatarUrl: state.avatarUrl,
|
||||
avatarHash: state.avatarHash,
|
||||
permissionsToRequest: permissions,
|
||||
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 xmpp = GetIt.I.get<XmppService>();
|
||||
final settings = await xmpp.getConnectionSettings();
|
||||
final state = await xmpp.getXmppState();
|
||||
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) {
|
||||
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
|
||||
|
||||
// Check some permissions
|
||||
final storagePerm = await Permission.storage.status;
|
||||
final permissions = List<int>.empty(growable: true);
|
||||
if (storagePerm.isDenied /*&& !state.askedStoragePermission*/) {
|
||||
permissions.add(Permission.storage.value);
|
||||
|
||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
||||
askedStoragePermission: true,
|
||||
),);
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
PreStartDoneEvent(
|
||||
state: 'logged_in',
|
||||
jid: state.jid,
|
||||
displayName: state.displayName ?? state.jid!.split('@').first,
|
||||
avatarUrl: state.avatarUrl,
|
||||
avatarHash: state.avatarHash,
|
||||
permissionsToRequest: permissions,
|
||||
preferences: preferences,
|
||||
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
|
||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||
),
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 {
|
||||
@ -136,11 +139,52 @@ 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) {
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
errorType: noError,
|
||||
);
|
||||
}
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: msg.copyWith(isUploading: false),
|
||||
),
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
encryption.key,
|
||||
encryption.iv,
|
||||
encryption.ciphertextHashes,
|
||||
StatelessFileSharingUrlSource(slot.getUrl),
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
@ -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 {
|
||||
@ -269,18 +333,39 @@ 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,73 +376,132 @@ class HttpFileTransferService {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) {
|
||||
// TODO(PapaTutuWawa): Error handling
|
||||
// TODO(PapaTutuWawa): Trigger event
|
||||
_log.warning('HTTP GET of ${job.url} returned ${response.statusCode}');
|
||||
} else {
|
||||
// Check the MIME type
|
||||
final notification = GetIt.I.get<NotificationsService>();
|
||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
// TODO(Unknown): Restrict to the library's supported file types
|
||||
final size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
|
||||
mediaWidth = size.width;
|
||||
mediaHeight = size.height;
|
||||
} else if (mime.startsWith('video/')) {
|
||||
// TODO(Unknown): Also figure out the thumbnail size here
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
isFileUploadNotification: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg.copyWith(isDownloading: false)));
|
||||
|
||||
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,
|
||||
conv.id,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
sharedMedia: List<SharedMedium>.from(conv.sharedMedia)..add(sharedMedium),
|
||||
);
|
||||
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');
|
||||
_log.finest('Failed to download: $err');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRequestOkay(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);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
// TODO(Unknown): Restrict to the library's supported file types
|
||||
Size? size;
|
||||
try {
|
||||
size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to get image size for $downloadedPath: $ex');
|
||||
}
|
||||
|
||||
mediaWidth = size?.width;
|
||||
mediaHeight = size?.height;
|
||||
} else if (mime.startsWith('video/')) {
|
||||
// TODO(Unknown): Also figure out the thumbnail size here
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(msg, '');
|
||||
}
|
||||
|
||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.id,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
sharedMedia: List<SharedMedium>.from(conv.sharedMedia)..add(sharedMedium),
|
||||
);
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
@ -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;
|
||||
}
|
||||
|
49
lib/service/httpfiletransfer/location.dart
Normal file
49
lib/service/httpfiletransfer/location.dart
Normal 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;
|
||||
}
|
||||
}
|
5
lib/service/language.dart
Normal file
5
lib/service/language.dart
Normal file
@ -0,0 +1,5 @@
|
||||
/// A simple wrapper around storing the system's default language.
|
||||
class LanguageService {
|
||||
LanguageService() : defaultLocale = 'en';
|
||||
String defaultLocale;
|
||||
}
|
@ -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
|
||||
@ -86,39 +107,74 @@ class MessageService {
|
||||
(message) => message.sid == stanzaId,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
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)) {
|
||||
_messageCache[newMessage.conversationJid] = _messageCache[newMessage.conversationJid]!.map((m) {
|
||||
if (m.id == newMessage.id) return newMessage;
|
||||
if (m.id == newMessage.id) return newMessage;
|
||||
|
||||
return m;
|
||||
return m;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
|
@ -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
|
42
lib/service/moxxmpp/omemo.dart
Normal file
42
lib/service/moxxmpp/omemo.dart
Normal 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());
|
||||
}
|
||||
}
|
@ -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,10 +13,10 @@ 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'),
|
||||
_log = Logger('MoxxyReconnectionPolicy'),
|
||||
super();
|
||||
final Logger _log;
|
||||
|
||||
@ -27,6 +27,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 {
|
||||
@ -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...');
|
||||
|
@ -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
|
19
lib/service/moxxmpp/socket.dart
Normal file
19
lib/service/moxxmpp/socket.dart
Normal 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();
|
||||
}
|
||||
}
|
@ -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!);
|
||||
}
|
||||
}
|
||||
}
|
4
lib/service/not_specified.dart
Normal file
4
lib/service/not_specified.dart
Normal file
@ -0,0 +1,4 @@
|
||||
class _NotSpecifiedValue { const _NotSpecifiedValue(); }
|
||||
|
||||
/// A value used for indicating that a value is not specified.
|
||||
const notSpecified = _NotSpecifiedValue();
|
@ -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;
|
||||
|
13
lib/service/omemo/implementations.dart
Normal file
13
lib/service/omemo/implementations.dart
Normal 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>>{},
|
||||
),
|
||||
);
|
||||
}
|
305
lib/service/omemo/omemo.dart
Normal file
305
lib/service/omemo/omemo.dart
Normal 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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
|
@ -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,10 +136,11 @@ Future<void> entrypoint() async {
|
||||
// Register singletons
|
||||
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
|
||||
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
|
||||
GetIt.I.registerSingleton<LanguageService>(LanguageService());
|
||||
|
||||
setupLogging();
|
||||
setupBackgroundEventHandler();
|
||||
|
||||
|
||||
// Initialize the database
|
||||
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
|
||||
await GetIt.I.get<DatabaseService>().initialize();
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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/')) {
|
||||
final imageSize = image_size.ImageSizeGetter.getSize(FileInput(File(path)));
|
||||
dimensions[path] = Size(
|
||||
imageSize.width.toDouble(),
|
||||
imageSize.height.toDouble(),
|
||||
);
|
||||
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] ?? [],
|
||||
),
|
||||
@ -497,34 +500,52 @@ 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) {
|
||||
@ -694,9 +719,8 @@ class XmppService {
|
||||
/// Extract the mime guess from a message, if existent.
|
||||
String? _getMimeGuess(MessageEvent event) {
|
||||
return firstNotNull([
|
||||
event.sfs?.metadata.mediaType,
|
||||
event.sims?.mediaType,
|
||||
event.fun?.mediaType,
|
||||
event.sfs?.metadata.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.
|
||||
@ -751,9 +827,14 @@ class XmppService {
|
||||
await _handleFileUploadNotificationReplacement(event, conversationJid);
|
||||
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,13 +1008,13 @@ class XmppService {
|
||||
}
|
||||
|
||||
// Notify the UI of the message
|
||||
sendEvent(
|
||||
MessageAddedEvent(
|
||||
message: message.copyWith(
|
||||
isDownloading: event.fun != null,
|
||||
),
|
||||
),
|
||||
);
|
||||
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 {
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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,31 +221,16 @@ String filenameFromUrl(String url) {
|
||||
return Uri.parse(url).pathSegments.last;
|
||||
}
|
||||
|
||||
ChatState chatStateFromString(String raw) {
|
||||
switch(raw) {
|
||||
case 'active': {
|
||||
return ChatState.active;
|
||||
}
|
||||
case 'composing': {
|
||||
return ChatState.composing;
|
||||
}
|
||||
case 'paused': {
|
||||
return ChatState.paused;
|
||||
}
|
||||
case 'inactive': {
|
||||
return ChatState.inactive;
|
||||
}
|
||||
case 'gone': {
|
||||
return ChatState.gone;
|
||||
}
|
||||
default: {
|
||||
return ChatState.gone;
|
||||
}
|
||||
}
|
||||
/// 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');
|
||||
}
|
||||
|
||||
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.
|
||||
String filenameWithSuffix(String filename, String suffix) {
|
||||
@ -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';
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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,17 +47,24 @@ 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;
|
||||
|
||||
const Message._();
|
||||
|
||||
|
||||
/// JSON
|
||||
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(json);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
22
lib/shared/models/omemo_device.dart
Normal file
22
lib/shared/models/omemo_device.dart
Normal 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);
|
||||
}
|
@ -3,29 +3,31 @@ 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({
|
||||
@Default(true) bool sendChatMarkers,
|
||||
@Default(true) bool sendChatStates,
|
||||
@Default(true) bool showSubscriptionRequests,
|
||||
@Default(true) bool autoDownloadWifi,
|
||||
@Default(false) bool autoDownloadMobile,
|
||||
@Default(15) int maximumAutoDownloadSize,
|
||||
@Default('') String backgroundPath,
|
||||
@Default(true) bool isAvatarPublic,
|
||||
@Default(false) bool autoAcceptSubscriptionRequests,
|
||||
@Default(false) bool debugEnabled,
|
||||
@Default('') String debugPassphrase,
|
||||
@Default('') String debugIp,
|
||||
@Default(-1) int debugPort,
|
||||
@Default('') String twitterRedirect,
|
||||
@Default('') String youtubeRedirect,
|
||||
@Default(false) bool enableTwitterRedirect,
|
||||
@Default(false) bool enableYoutubeRedirect,
|
||||
@Default(false) bool defaultMuteState,
|
||||
@Default(true) bool sendChatMarkers,
|
||||
@Default(true) bool sendChatStates,
|
||||
@Default(true) bool showSubscriptionRequests,
|
||||
@Default(true) bool autoDownloadWifi,
|
||||
@Default(false) bool autoDownloadMobile,
|
||||
@Default(15) int maximumAutoDownloadSize,
|
||||
@Default('') String backgroundPath,
|
||||
@Default(true) bool isAvatarPublic,
|
||||
@Default(false) bool autoAcceptSubscriptionRequests,
|
||||
@Default(false) bool debugEnabled,
|
||||
@Default('') String debugPassphrase,
|
||||
@Default('') String debugIp,
|
||||
@Default(-1) int debugPort,
|
||||
@Default('') String twitterRedirect,
|
||||
@Default('') String youtubeRedirect,
|
||||
@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
|
||||
|
15
lib/shared/warning_types.dart
Normal file
15
lib/shared/warning_types.dart
Normal 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 '';
|
||||
}
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -49,9 +49,9 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversations: List.from(state.conversations.map((c) {
|
||||
if (c.jid == event.conversation.jid) return event.conversation;
|
||||
if (c.jid == event.conversation.jid) return event.conversation;
|
||||
|
||||
return c;
|
||||
return c;
|
||||
}).toList()..sort(compareConversation),),
|
||||
),
|
||||
);
|
||||
|
69
lib/ui/bloc/devices_bloc.dart
Normal file
69
lib/ui/bloc/devices_bloc.dart
Normal 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());
|
||||
}
|
||||
}
|
21
lib/ui/bloc/devices_event.dart
Normal file
21
lib/ui/bloc/devices_event.dart
Normal 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 {}
|
10
lib/ui/bloc/devices_state.dart
Normal file
10
lib/ui/bloc/devices_state.dart
Normal 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;
|
||||
}
|
@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
|
127
lib/ui/bloc/own_devices_bloc.dart
Normal file
127
lib/ui/bloc/own_devices_bloc.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
27
lib/ui/bloc/own_devices_event.dart
Normal file
27
lib/ui/bloc/own_devices_event.dart
Normal 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;
|
||||
}
|
11
lib/ui/bloc/own_devices_state.dart
Normal file
11
lib/ui/bloc/own_devices_state.dart
Normal 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;
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
@ -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 '';
|
||||
}
|
||||
|
@ -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),
|
||||
),
|
||||
)
|
||||
],
|
||||
|
@ -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.',
|
||||
context,
|
||||
() {
|
||||
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,
|
||||
() {
|
||||
context.read<BlocklistBloc>().add(UnblockedAllEvent());
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
);
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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,
|
||||
() {
|
||||
// TODO(Unknown): Maybe show a progress indicator
|
||||
// TODO(Unknown): Have the page update its state once the addition is done
|
||||
context.read<ConversationBloc>().add(
|
||||
JidAddedEvent(jid),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
);
|
||||
|
||||
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,
|
||||
|
@ -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,
|
||||
() {
|
||||
context.read<ConversationBloc>().add(JidBlockedEvent(jid));
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
);
|
||||
|
||||
if (result) {
|
||||
// ignore: use_build_context_synchronously
|
||||
context.read<ConversationBloc>().add(JidBlockedEvent(jid));
|
||||
|
||||
// ignore: use_build_context_synchronously
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
@ -67,83 +73,138 @@ class ConversationTopbarWidget extends StatelessWidget {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
children: [
|
||||
TopbarTitleText(state.conversation!.title),
|
||||
_buildChatState(state.conversation!.chatState)
|
||||
],
|
||||
),
|
||||
),
|
||||
Hero(
|
||||
tag: 'conversation_profile_picture',
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
child: AvatarWrapper(
|
||||
radius: 25,
|
||||
avatarUrl: state.conversation!.avatarUrl,
|
||||
altText: state.conversation!.title,
|
||||
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: [
|
||||
const BackButton(),
|
||||
Hero(
|
||||
tag: 'conversation_profile_picture',
|
||||
child: Material(
|
||||
color: const Color.fromRGBO(0, 0, 0, 0),
|
||||
child: AvatarWrapper(
|
||||
radius: 25,
|
||||
avatarUrl: state.conversation!.avatarUrl,
|
||||
altText: state.conversation!.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => GetIt.I.get<profile.ProfileBloc>().add(
|
||||
profile.ProfilePageRequestedEvent(
|
||||
false,
|
||||
conversation: context.read<ConversationBloc>().state.conversation,
|
||||
),
|
||||
),
|
||||
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: {
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case ConversationOption.block: {
|
||||
await blockJid(state.conversation!.jid, context);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.more_vert),
|
||||
itemBuilder: (BuildContext c) => [
|
||||
popupItemWithIcon(ConversationOption.close, t.pages.conversation.closeChat, Icons.close),
|
||||
popupItemWithIcon(ConversationOption.block, t.pages.conversation.blockUser, Icons.block)
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
() => 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),
|
||||
],
|
||||
),
|
||||
// ignore: implicit_dynamic_type
|
||||
PopupMenuButton(
|
||||
onSelected: (result) {
|
||||
switch (result) {
|
||||
case ConversationOption.close: {
|
||||
showConfirmationDialog(
|
||||
'Close Chat',
|
||||
'Are you sure you want to close this chat?',
|
||||
context,
|
||||
() {
|
||||
context.read<ConversationsBloc>().add(
|
||||
ConversationClosedEvent(state.conversation!.jid),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
);
|
||||
}
|
||||
break;
|
||||
case ConversationOption.block: {
|
||||
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)
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -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,
|
||||
)
|
||||
],
|
||||
),
|
||||
|
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -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);
|
||||
|
@ -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'),
|
||||
),
|
||||
)
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
),
|
||||
|
104
lib/ui/pages/profile/devices.dart
Normal file
104
lib/ui/pages/profile/devices.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
222
lib/ui/pages/profile/own_devices.dart
Normal file
222
lib/ui/pages/profile/own_devices.dart
Normal 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
108
lib/ui/pages/profile/widgets.dart
Normal file
108
lib/ui/pages/profile/widgets.dart
Normal 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,
|
||||
),
|
||||
] :
|
||||
[],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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(),
|
||||
|
@ -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'),
|
||||
),
|
||||
)
|
||||
|
105
lib/ui/pages/settings/appearance/appearance.dart
Normal file
105
lib/ui/pages/settings/appearance/appearance.dart
Normal 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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'),
|
||||
),
|
||||
],
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
@ -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(
|
||||
|
@ -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]),
|
||||
|
@ -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,
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user