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
|
**/*.g.dart
|
||||||
**/*.freezed.dart
|
**/*.freezed.dart
|
||||||
**/*.moxxy.dart
|
**/*.moxxy.dart
|
||||||
|
lib/i18n/*.dart
|
||||||
|
|
||||||
# Direnv
|
# Direnv
|
||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|
||||||
|
# Android artifacts
|
||||||
|
.android
|
||||||
|
2
.gitlint
2
.gitlint
@ -7,7 +7,7 @@ line-length=72
|
|||||||
[title-trailing-punctuation]
|
[title-trailing-punctuation]
|
||||||
[title-hard-tab]
|
[title-hard-tab]
|
||||||
[title-match-regex]
|
[title-match-regex]
|
||||||
regex=^(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]
|
[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
|
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
|
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
|
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
|
`flutter pub get` to install all dependencies. Then run `flutter pub run build_runner build` to generate
|
||||||
|
@ -13,3 +13,6 @@ analyzer:
|
|||||||
- "**/*.freezed.dart"
|
- "**/*.freezed.dart"
|
||||||
- "**/*.moxxy.dart"
|
- "**/*.moxxy.dart"
|
||||||
- "test/"
|
- "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
|
ripgrep # General utilities
|
||||||
];
|
];
|
||||||
|
|
||||||
ANDROID_HOME = "${android.androidsdk}/libexec/android-sdk";
|
|
||||||
JAVA_HOME = pinnedJDK;
|
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
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
jid: String
|
jid: String
|
||||||
displayName: String
|
preStart:
|
||||||
|
type: PreStartDoneEvent
|
||||||
|
deserialise: true
|
||||||
- name: LoginFailureEvent
|
- name: LoginFailureEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
reason: String
|
reason: String?
|
||||||
- name: PreStartDoneEvent
|
- name: PreStartDoneEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
@ -69,8 +71,7 @@ files:
|
|||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
# Send by the service if a message has been received or returned by
|
# Send by the service if a message has been received or returned by # [SendMessageCommand].
|
||||||
# [SendMessageCommand].
|
|
||||||
- name: MessageAddedEvent
|
- name: MessageAddedEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
@ -109,7 +110,7 @@ files:
|
|||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
id: int
|
id: int
|
||||||
progress: double
|
progress: double?
|
||||||
# Triggered by [RosterService] if we receive a roster push.
|
# Triggered by [RosterService] if we receive a roster push.
|
||||||
- name: RosterDiffEvent
|
- name: RosterDiffEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
@ -172,6 +173,32 @@ files:
|
|||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- 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
|
generate_builder: true
|
||||||
builder_name: "Event"
|
builder_name: "Event"
|
||||||
builder_baseclass: "BackgroundEvent"
|
builder_baseclass: "BackgroundEvent"
|
||||||
@ -190,6 +217,8 @@ files:
|
|||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
systemLocaleCode: String
|
||||||
- name: AddConversationCommand
|
- name: AddConversationCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@ -315,6 +344,56 @@ files:
|
|||||||
attributes:
|
attributes:
|
||||||
jid: String
|
jid: String
|
||||||
muted: bool
|
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
|
generate_builder: true
|
||||||
# get${builder_Name}FromJson
|
# get${builder_Name}FromJson
|
||||||
builder_name: "Command"
|
builder_name: "Command"
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/addcontact_bloc.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/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/cropbackground_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/login_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/newconversation_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/preferences_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sendfiles_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/intro.dart';
|
||||||
import 'package:moxxyv2/ui/pages/login.dart';
|
import 'package:moxxyv2/ui/pages/login.dart';
|
||||||
import 'package:moxxyv2/ui/pages/newconversation.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/profile/profile.dart';
|
||||||
import 'package:moxxyv2/ui/pages/sendfiles.dart';
|
import 'package:moxxyv2/ui/pages/sendfiles.dart';
|
||||||
import 'package:moxxyv2/ui/pages/server_info.dart';
|
import 'package:moxxyv2/ui/pages/server_info.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/about.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/appearance/cropbackground.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/conversation.dart';
|
import 'package:moxxyv2/ui/pages/settings/conversation.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/debugging.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/pages/splashscreen/splashscreen.dart';
|
||||||
import 'package:moxxyv2/ui/service/data.dart';
|
import 'package:moxxyv2/ui/service/data.dart';
|
||||||
import 'package:moxxyv2/ui/service/progress.dart';
|
import 'package:moxxyv2/ui/service/progress.dart';
|
||||||
import 'package:moxxyv2/ui/service/thumbnail.dart';
|
import 'package:moxxyv2/ui/theme.dart';
|
||||||
import 'package:page_transition/page_transition.dart';
|
import 'package:page_transition/page_transition.dart';
|
||||||
import 'package:share_handler/share_handler.dart';
|
import 'package:share_handler/share_handler.dart';
|
||||||
|
|
||||||
void setupLogging() {
|
void setupLogging() {
|
||||||
Logger.root.level = Level.ALL;
|
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||||
Logger.root.onRecord.listen((record) {
|
Logger.root.onRecord.listen((record) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}');
|
print('[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}');
|
||||||
@ -68,7 +75,6 @@ void setupLogging() {
|
|||||||
Future<void> setupUIServices() async {
|
Future<void> setupUIServices() async {
|
||||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||||
GetIt.I.registerSingleton<ThumbnailCacheService>(ThumbnailCacheService());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||||
@ -76,8 +82,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
|||||||
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
||||||
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
||||||
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
||||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc()); GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
|
||||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||||
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
||||||
@ -86,6 +91,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
|||||||
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
||||||
GetIt.I.registerSingleton<ShareSelectionBloc>(ShareSelectionBloc());
|
GetIt.I.registerSingleton<ShareSelectionBloc>(ShareSelectionBloc());
|
||||||
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
|
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
|
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
|
||||||
@ -152,15 +159,23 @@ void main() async {
|
|||||||
BlocProvider<ServerInfoBloc>(
|
BlocProvider<ServerInfoBloc>(
|
||||||
create: (_) => GetIt.I.get<ServerInfoBloc>(),
|
create: (_) => GetIt.I.get<ServerInfoBloc>(),
|
||||||
),
|
),
|
||||||
|
BlocProvider<DevicesBloc>(
|
||||||
|
create: (_) => GetIt.I.get<DevicesBloc>(),
|
||||||
|
),
|
||||||
|
BlocProvider<OwnDevicesBloc>(
|
||||||
|
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
child: TranslationProvider(
|
||||||
child: MyApp(navKey),
|
child: MyApp(navKey),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends StatefulWidget {
|
||||||
|
|
||||||
const MyApp(this.navigationKey, { Key? key }) : super(key: key);
|
const MyApp(this.navigationKey, { super.key });
|
||||||
final GlobalKey<NavigatorState> navigationKey;
|
final GlobalKey<NavigatorState> navigationKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -248,44 +263,12 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
|
locale: TranslationProvider.of(context).flutterLocale,
|
||||||
|
supportedLocales: LocaleSettings.supportedLocales,
|
||||||
|
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||||
title: 'Moxxy',
|
title: 'Moxxy',
|
||||||
theme: ThemeData(
|
theme: getThemeData(context, Brightness.light),
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
darkTheme: getThemeData(context, Brightness.dark),
|
||||||
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),
|
|
||||||
),
|
|
||||||
navigatorKey: widget.navigationKey,
|
navigatorKey: widget.navigationKey,
|
||||||
onGenerateRoute: (settings) {
|
onGenerateRoute: (settings) {
|
||||||
switch (settings.name) {
|
switch (settings.name) {
|
||||||
@ -314,6 +297,9 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
case shareSelectionRoute: return ShareSelectionPage.route;
|
case shareSelectionRoute: return ShareSelectionPage.route;
|
||||||
case serverInfoRoute: return ServerInfoPage.route;
|
case serverInfoRoute: return ServerInfoPage.route;
|
||||||
case conversationSettingsRoute: return ConversationSettingsPage.route;
|
case conversationSettingsRoute: return ConversationSettingsPage.route;
|
||||||
|
case devicesRoute: return DevicesPage.route;
|
||||||
|
case ownDevicesRoute: return OwnDevicesPage.route;
|
||||||
|
case appearanceRoute: return AppearanceSettingsPage.route;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
@ -5,6 +5,8 @@ import 'package:get_it/get_it.dart';
|
|||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
import 'package:image_size_getter/image_size_getter.dart';
|
import 'package:image_size_getter/image_size_getter.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
@ -12,14 +14,6 @@ import 'package:moxxyv2/service/service.dart';
|
|||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/shared/avatar.dart';
|
import 'package:moxxyv2/shared/avatar.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
|
||||||
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
|
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
||||||
/// avatar data. Returns the cleaned version.
|
/// avatar data. Returns the cleaned version.
|
||||||
@ -93,7 +87,10 @@ class AvatarService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
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);
|
final itemNodes = items.map((i) => i.node);
|
||||||
|
|
||||||
_log.finest('Disco items for $jid:');
|
_log.finest('Disco items for $jid:');
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/xmpp/connection.dart';
|
|
||||||
import 'package:moxxyv2/xmpp/managers/namespaces.dart';
|
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0191.dart';
|
|
||||||
|
|
||||||
enum BlockPushType {
|
enum BlockPushType {
|
||||||
block,
|
block,
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
|
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
|
||||||
import 'package:moxxyv2/xmpp/connection.dart';
|
|
||||||
|
|
||||||
class ConnectivityService {
|
class ConnectivityService {
|
||||||
|
|
||||||
ConnectivityService() : _log = Logger('ConnectivityService');
|
ConnectivityService() : _log = Logger('ConnectivityService');
|
||||||
final Logger _log;
|
final Logger _log;
|
||||||
|
|
||||||
|
@ -2,9 +2,10 @@ import 'dart:async';
|
|||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.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/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/notifications.dart';
|
import 'package:moxxyv2/service/notifications.dart';
|
||||||
import 'package:moxxyv2/xmpp/connection.dart';
|
|
||||||
|
|
||||||
class ConnectivityWatcherService {
|
class ConnectivityWatcherService {
|
||||||
|
|
||||||
@ -17,7 +18,7 @@ class ConnectivityWatcherService {
|
|||||||
Future<void> _onTimerElapsed() async {
|
Future<void> _onTimerElapsed() async {
|
||||||
await GetIt.I.get<NotificationsService>().showWarningNotification(
|
await GetIt.I.get<NotificationsService>().showWarningNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
'Could not connect to server',
|
t.errors.connection.connectionTimeout,
|
||||||
);
|
);
|
||||||
_stopTimer();
|
_stopTimer();
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/shared/cache.dart';
|
import 'package:moxxyv2/shared/cache.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
|
||||||
|
|
||||||
class ConversationService {
|
class ConversationService {
|
||||||
|
|
||||||
ConversationService()
|
ConversationService()
|
||||||
: _conversationCache = LRUCache(100),
|
: _conversationCache = LRUCache(100),
|
||||||
_loadedConversations = false;
|
_loadedConversations = false;
|
||||||
@ -57,23 +57,28 @@ class ConversationService {
|
|||||||
Future<Conversation> updateConversation(int id, {
|
Future<Conversation> updateConversation(int id, {
|
||||||
String? lastMessageBody,
|
String? lastMessageBody,
|
||||||
int? lastChangeTimestamp,
|
int? lastChangeTimestamp,
|
||||||
|
bool? lastMessageRetracted,
|
||||||
|
int? lastMessageId,
|
||||||
bool? open,
|
bool? open,
|
||||||
int? unreadCounter,
|
int? unreadCounter,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
bool? muted,
|
bool? muted,
|
||||||
}
|
bool? encrypted,
|
||||||
) async {
|
}) async {
|
||||||
final conversation = await _getConversationById(id);
|
final conversation = await _getConversationById(id);
|
||||||
final newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
final newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
||||||
id,
|
id,
|
||||||
lastMessageBody: lastMessageBody,
|
lastMessageBody: lastMessageBody,
|
||||||
|
lastMessageRetracted: lastMessageRetracted,
|
||||||
|
lastMessageId: lastMessageId,
|
||||||
lastChangeTimestamp: lastChangeTimestamp,
|
lastChangeTimestamp: lastChangeTimestamp,
|
||||||
open: open,
|
open: open,
|
||||||
unreadCounter: unreadCounter,
|
unreadCounter: unreadCounter,
|
||||||
avatarUrl: avatarUrl,
|
avatarUrl: avatarUrl,
|
||||||
chatState: conversation?.chatState ?? ChatState.gone,
|
chatState: conversation?.chatState ?? ChatState.gone,
|
||||||
muted: muted,
|
muted: muted,
|
||||||
|
encrypted: encrypted,
|
||||||
);
|
);
|
||||||
|
|
||||||
_conversationCache.cache(id, newConversation);
|
_conversationCache.cache(id, newConversation);
|
||||||
@ -83,6 +88,8 @@ class ConversationService {
|
|||||||
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the cache.
|
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the cache.
|
||||||
Future<Conversation> addConversationFromData(
|
Future<Conversation> addConversationFromData(
|
||||||
String title,
|
String title,
|
||||||
|
int lastMessageId,
|
||||||
|
bool lastMessageRetracted,
|
||||||
String lastMessageBody,
|
String lastMessageBody,
|
||||||
String avatarUrl,
|
String avatarUrl,
|
||||||
String jid,
|
String jid,
|
||||||
@ -90,9 +97,12 @@ class ConversationService {
|
|||||||
int lastChangeTimestamp,
|
int lastChangeTimestamp,
|
||||||
bool open,
|
bool open,
|
||||||
bool muted,
|
bool muted,
|
||||||
|
bool encrypted,
|
||||||
) async {
|
) async {
|
||||||
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
||||||
title,
|
title,
|
||||||
|
lastMessageId,
|
||||||
|
lastMessageRetracted,
|
||||||
lastMessageBody,
|
lastMessageBody,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
jid,
|
jid,
|
||||||
@ -100,9 +110,21 @@ class ConversationService {
|
|||||||
lastChangeTimestamp,
|
lastChangeTimestamp,
|
||||||
open,
|
open,
|
||||||
muted,
|
muted,
|
||||||
|
encrypted,
|
||||||
);
|
);
|
||||||
|
|
||||||
_conversationCache.cache(newConversation.id, newConversation);
|
_conversationCache.cache(newConversation.id, newConversation);
|
||||||
return 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 rosterTable = 'RosterItems';
|
||||||
const mediaTable = 'SharedMedia';
|
const mediaTable = 'SharedMedia';
|
||||||
const preferenceTable = 'Preferences';
|
const preferenceTable = 'Preferences';
|
||||||
|
const omemoDeviceTable = 'OmemoDevices';
|
||||||
|
const omemoDeviceListTable = 'OmemoDeviceList';
|
||||||
|
const omemoRatchetsTable = 'OmemoSessions';
|
||||||
|
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
||||||
|
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
||||||
|
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
||||||
|
const xmppStateTable = 'XmppState';
|
||||||
|
|
||||||
const typeString = 0;
|
const typeString = 0;
|
||||||
const typeInt = 1;
|
const typeInt = 1;
|
||||||
|
@ -7,6 +7,15 @@ Future<void> configureDatabase(Database db) async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createDatabase(Database db, int version) async {
|
Future<void> createDatabase(Database db, int version) async {
|
||||||
|
// XMPP state
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $xmppStateTable (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
@ -19,19 +28,30 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
conversationJid TEXT NOT NULL,
|
conversationJid TEXT NOT NULL,
|
||||||
isMedia INTEGER NOT NULL,
|
isMedia INTEGER NOT NULL,
|
||||||
isFileUploadNotification INTEGER NOT NULL,
|
isFileUploadNotification INTEGER NOT NULL,
|
||||||
|
encrypted INTEGER NOT NULL,
|
||||||
errorType INTEGER,
|
errorType INTEGER,
|
||||||
|
warningType INTEGER,
|
||||||
mediaUrl TEXT,
|
mediaUrl TEXT,
|
||||||
mediaType TEXT,
|
mediaType TEXT,
|
||||||
thumbnailData TEXT,
|
thumbnailData TEXT,
|
||||||
mediaWidth INTEGER,
|
mediaWidth INTEGER,
|
||||||
mediaHeight INTEGER,
|
mediaHeight INTEGER,
|
||||||
srcUrl TEXT,
|
srcUrl TEXT,
|
||||||
|
key TEXT,
|
||||||
|
iv TEXT,
|
||||||
|
encryptionScheme TEXT,
|
||||||
received INTEGER,
|
received INTEGER,
|
||||||
displayed INTEGER,
|
displayed INTEGER,
|
||||||
acked INTEGER,
|
acked INTEGER,
|
||||||
originId TEXT,
|
originId TEXT,
|
||||||
quote_id INTEGER,
|
quote_id INTEGER,
|
||||||
filename TEXT,
|
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)
|
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,
|
unreadCounter INTEGER NOT NULL,
|
||||||
lastMessageBody TEXT NOT NULL,
|
lastMessageBody TEXT NOT NULL,
|
||||||
open INTEGER 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
|
// Settings
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
@ -86,8 +172,7 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
key TEXT NOT NULL PRIMARY KEY,
|
key TEXT NOT NULL PRIMARY KEY,
|
||||||
type INTEGER NOT NULL,
|
type INTEGER NOT NULL,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
);
|
)''',
|
||||||
''',
|
|
||||||
);
|
);
|
||||||
await db.insert(
|
await db.insert(
|
||||||
preferenceTable,
|
preferenceTable,
|
||||||
@ -233,4 +318,20 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
'false',
|
'false',
|
||||||
).toDatabaseJson(),
|
).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:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/database/constants.dart';
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/creation.dart';
|
import 'package:moxxyv2/service/database/creation.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_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/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/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.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:path/path.dart' as path;
|
||||||
import 'package:random_string/random_string.dart';
|
import 'package:random_string/random_string.dart';
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
@ -21,7 +29,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
|
|||||||
const databasePasswordKey = 'database_encryption_password';
|
const databasePasswordKey = 'database_encryption_password';
|
||||||
|
|
||||||
class DatabaseService {
|
class DatabaseService {
|
||||||
|
|
||||||
DatabaseService() : _log = Logger('DatabaseService');
|
DatabaseService() : _log = Logger('DatabaseService');
|
||||||
late Database _db;
|
late Database _db;
|
||||||
final FlutterSecureStorage _storage = const FlutterSecureStorage(
|
final FlutterSecureStorage _storage = const FlutterSecureStorage(
|
||||||
@ -50,9 +57,27 @@ class DatabaseService {
|
|||||||
_db = await openDatabase(
|
_db = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
password: key,
|
password: key,
|
||||||
version: 1,
|
version: 5,
|
||||||
onCreate: createDatabase,
|
onCreate: createDatabase,
|
||||||
onConfigure: configureDatabase,
|
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');
|
_log.finest('Database setup done');
|
||||||
@ -108,7 +133,7 @@ class DatabaseService {
|
|||||||
final rawQuote = (await _db.query(
|
final rawQuote = (await _db.query(
|
||||||
'Messages',
|
'Messages',
|
||||||
where: 'conversationJid = ? AND id = ?',
|
where: 'conversationJid = ? AND id = ?',
|
||||||
whereArgs: [jid, m['id']! as int],
|
whereArgs: [jid, m['quote_id']! as int],
|
||||||
)).first;
|
)).first;
|
||||||
quotes = Message.fromDatabaseJson(rawQuote, null);
|
quotes = Message.fromDatabaseJson(rawQuote, null);
|
||||||
}
|
}
|
||||||
@ -123,13 +148,15 @@ class DatabaseService {
|
|||||||
Future<Conversation> updateConversation(int id, {
|
Future<Conversation> updateConversation(int id, {
|
||||||
String? lastMessageBody,
|
String? lastMessageBody,
|
||||||
int? lastChangeTimestamp,
|
int? lastChangeTimestamp,
|
||||||
|
bool? lastMessageRetracted,
|
||||||
|
int? lastMessageId,
|
||||||
bool? open,
|
bool? open,
|
||||||
int? unreadCounter,
|
int? unreadCounter,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
bool? muted,
|
bool? muted,
|
||||||
}
|
bool? encrypted,
|
||||||
) async {
|
}) async {
|
||||||
final cd = (await _db.query(
|
final cd = (await _db.query(
|
||||||
'Conversations',
|
'Conversations',
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
@ -148,6 +175,12 @@ class DatabaseService {
|
|||||||
if (lastMessageBody != null) {
|
if (lastMessageBody != null) {
|
||||||
c['lastMessageBody'] = lastMessageBody;
|
c['lastMessageBody'] = lastMessageBody;
|
||||||
}
|
}
|
||||||
|
if (lastMessageRetracted != null) {
|
||||||
|
c['lastMessageRetracted'] = boolToInt(lastMessageRetracted);
|
||||||
|
}
|
||||||
|
if (lastMessageId != null) {
|
||||||
|
c['lastMessageId'] = lastMessageId;
|
||||||
|
}
|
||||||
if (lastChangeTimestamp != null) {
|
if (lastChangeTimestamp != null) {
|
||||||
c['lastChangeTimestamp'] = lastChangeTimestamp;
|
c['lastChangeTimestamp'] = lastChangeTimestamp;
|
||||||
}
|
}
|
||||||
@ -163,6 +196,9 @@ class DatabaseService {
|
|||||||
if (muted != null) {
|
if (muted != null) {
|
||||||
c['muted'] = boolToInt(muted);
|
c['muted'] = boolToInt(muted);
|
||||||
}
|
}
|
||||||
|
if (encrypted != null) {
|
||||||
|
c['encrypted'] = boolToInt(encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
await _db.update(
|
await _db.update(
|
||||||
'Conversations',
|
'Conversations',
|
||||||
@ -184,6 +220,8 @@ class DatabaseService {
|
|||||||
/// [Conversation] object can carry its database id.
|
/// [Conversation] object can carry its database id.
|
||||||
Future<Conversation> addConversationFromData(
|
Future<Conversation> addConversationFromData(
|
||||||
String title,
|
String title,
|
||||||
|
int lastMessageId,
|
||||||
|
bool lastMessageRetracted,
|
||||||
String lastMessageBody,
|
String lastMessageBody,
|
||||||
String avatarUrl,
|
String avatarUrl,
|
||||||
String jid,
|
String jid,
|
||||||
@ -191,10 +229,13 @@ class DatabaseService {
|
|||||||
int lastChangeTimestamp,
|
int lastChangeTimestamp,
|
||||||
bool open,
|
bool open,
|
||||||
bool muted,
|
bool muted,
|
||||||
|
bool encrypted,
|
||||||
) async {
|
) async {
|
||||||
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||||
final conversation = Conversation(
|
final conversation = Conversation(
|
||||||
title,
|
title,
|
||||||
|
lastMessageId,
|
||||||
|
lastMessageRetracted,
|
||||||
lastMessageBody,
|
lastMessageBody,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
jid,
|
jid,
|
||||||
@ -206,6 +247,7 @@ class DatabaseService {
|
|||||||
rosterItem != null,
|
rosterItem != null,
|
||||||
rosterItem?.subscription ?? 'none',
|
rosterItem?.subscription ?? 'none',
|
||||||
muted,
|
muted,
|
||||||
|
encrypted,
|
||||||
ChatState.gone,
|
ChatState.gone,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -237,8 +279,12 @@ class DatabaseService {
|
|||||||
bool isMedia,
|
bool isMedia,
|
||||||
String sid,
|
String sid,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
|
bool encrypted,
|
||||||
{
|
{
|
||||||
String? srcUrl,
|
String? srcUrl,
|
||||||
|
String? key,
|
||||||
|
String? iv,
|
||||||
|
String? encryptionScheme,
|
||||||
String? mediaUrl,
|
String? mediaUrl,
|
||||||
String? mediaType,
|
String? mediaType,
|
||||||
String? thumbnailData,
|
String? thumbnailData,
|
||||||
@ -247,9 +293,16 @@ class DatabaseService {
|
|||||||
String? originId,
|
String? originId,
|
||||||
String? quoteId,
|
String? quoteId,
|
||||||
String? filename,
|
String? filename,
|
||||||
|
int? errorType,
|
||||||
|
int? warningType,
|
||||||
|
Map<String, String>? plaintextHashes,
|
||||||
|
Map<String, String>? ciphertextHashes,
|
||||||
|
bool isDownloading = false,
|
||||||
|
bool isUploading = false,
|
||||||
|
int? mediaSize,
|
||||||
}
|
}
|
||||||
) async {
|
) async {
|
||||||
final m = Message(
|
var m = Message(
|
||||||
sender,
|
sender,
|
||||||
body,
|
body,
|
||||||
timestamp,
|
timestamp,
|
||||||
@ -258,8 +311,13 @@ class DatabaseService {
|
|||||||
conversationJid,
|
conversationJid,
|
||||||
isMedia,
|
isMedia,
|
||||||
isFileUploadNotification,
|
isFileUploadNotification,
|
||||||
errorType: noError,
|
encrypted,
|
||||||
|
errorType: errorType,
|
||||||
|
warningType: warningType,
|
||||||
mediaUrl: mediaUrl,
|
mediaUrl: mediaUrl,
|
||||||
|
key: key,
|
||||||
|
iv: iv,
|
||||||
|
encryptionScheme: encryptionScheme,
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
thumbnailData: thumbnailData,
|
thumbnailData: thumbnailData,
|
||||||
mediaWidth: mediaWidth,
|
mediaWidth: mediaWidth,
|
||||||
@ -270,19 +328,24 @@ class DatabaseService {
|
|||||||
acked: false,
|
acked: false,
|
||||||
originId: originId,
|
originId: originId,
|
||||||
filename: filename,
|
filename: filename,
|
||||||
|
plaintextHashes: plaintextHashes,
|
||||||
|
ciphertextHashes: ciphertextHashes,
|
||||||
|
isUploading: isUploading,
|
||||||
|
isDownloading: isDownloading,
|
||||||
|
mediaSize: mediaSize,
|
||||||
);
|
);
|
||||||
|
|
||||||
Message? quotes;
|
|
||||||
if (quoteId != null) {
|
if (quoteId != null) {
|
||||||
quotes = await getMessageByXmppId(quoteId, conversationJid);
|
final quotes = await getMessageByXmppId(quoteId, conversationJid);
|
||||||
if (quotes == null) {
|
if (quotes == null) {
|
||||||
_log.warning('Failed to add quote for message with id $quoteId');
|
_log.warning('Failed to add quote for message with id $quoteId');
|
||||||
|
} else {
|
||||||
|
m = m.copyWith(quotes: quotes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return m.copyWith(
|
return m.copyWith(
|
||||||
id: await _db.insert('Messages', m.toDatabaseJson(quotes?.id)),
|
id: await _db.insert('Messages', m.toDatabaseJson()),
|
||||||
quotes: quotes,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,18 +379,45 @@ class DatabaseService {
|
|||||||
return Message.fromDatabaseJson(msg, null);
|
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.
|
/// Updates the message item with id [id] inside the database.
|
||||||
Future<Message> updateMessage(int id, {
|
Future<Message> updateMessage(int id, {
|
||||||
String? mediaUrl,
|
Object? body = notSpecified,
|
||||||
String? mediaType,
|
Object? mediaUrl = notSpecified,
|
||||||
|
Object? mediaType = notSpecified,
|
||||||
|
bool? isMedia,
|
||||||
bool? received,
|
bool? received,
|
||||||
bool? displayed,
|
bool? displayed,
|
||||||
bool? acked,
|
bool? acked,
|
||||||
int? errorType,
|
Object? errorType = notSpecified,
|
||||||
|
Object? warningType = notSpecified,
|
||||||
bool? isFileUploadNotification,
|
bool? isFileUploadNotification,
|
||||||
String? srcUrl,
|
Object? srcUrl = notSpecified,
|
||||||
int? mediaWidth,
|
Object? key = notSpecified,
|
||||||
int? mediaHeight,
|
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 {
|
}) async {
|
||||||
final md = (await _db.query(
|
final md = (await _db.query(
|
||||||
'Messages',
|
'Messages',
|
||||||
@ -337,11 +427,14 @@ class DatabaseService {
|
|||||||
)).first;
|
)).first;
|
||||||
final m = Map<String, dynamic>.from(md);
|
final m = Map<String, dynamic>.from(md);
|
||||||
|
|
||||||
if (mediaUrl != null) {
|
if (mediaUrl != notSpecified) {
|
||||||
m['mediaUrl'] = mediaUrl;
|
m['mediaUrl'] = mediaUrl as String?;
|
||||||
}
|
}
|
||||||
if (mediaType != null) {
|
if (mediaType != notSpecified) {
|
||||||
m['mediaType'] = mediaType;
|
m['mediaType'] = mediaType as String?;
|
||||||
|
}
|
||||||
|
if (isMedia != null) {
|
||||||
|
m['isMedia'] = boolToInt(isMedia);
|
||||||
}
|
}
|
||||||
if (received != null) {
|
if (received != null) {
|
||||||
m['received'] = boolToInt(received);
|
m['received'] = boolToInt(received);
|
||||||
@ -352,20 +445,50 @@ class DatabaseService {
|
|||||||
if (acked != null) {
|
if (acked != null) {
|
||||||
m['acked'] = boolToInt(acked);
|
m['acked'] = boolToInt(acked);
|
||||||
}
|
}
|
||||||
if (errorType != null) {
|
if (errorType != notSpecified) {
|
||||||
m['errorType'] = errorType;
|
m['errorType'] = errorType as int?;
|
||||||
|
}
|
||||||
|
if (warningType != notSpecified) {
|
||||||
|
m['warningType'] = warningType as int?;
|
||||||
}
|
}
|
||||||
if (isFileUploadNotification != null) {
|
if (isFileUploadNotification != null) {
|
||||||
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
|
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
|
||||||
}
|
}
|
||||||
if (srcUrl != null) {
|
if (srcUrl != notSpecified) {
|
||||||
m['srcUrl'] = srcUrl;
|
m['srcUrl'] = srcUrl as String?;
|
||||||
}
|
}
|
||||||
if (mediaWidth != null) {
|
if (mediaWidth != notSpecified) {
|
||||||
m['mediaWidth'] = mediaWidth;
|
m['mediaWidth'] = mediaWidth as int?;
|
||||||
}
|
}
|
||||||
if (mediaHeight != null) {
|
if (mediaHeight != notSpecified) {
|
||||||
m['mediaHeight'] = mediaHeight;
|
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(
|
await _db.update(
|
||||||
@ -538,4 +661,275 @@ class DatabaseService {
|
|||||||
|
|
||||||
await batch.commit();
|
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:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/service/avatars.dart';
|
import 'package:moxxyv2/service/avatars.dart';
|
||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||||
|
import 'package:moxxyv2/service/language.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/reconnect.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/preferences.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
@ -20,16 +27,7 @@ import 'package:moxxyv2/shared/commands.dart';
|
|||||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/xmpp/connection.dart';
|
import 'package:moxxyv2/shared/models/preferences.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:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
void setupBackgroundEventHandler() {
|
void setupBackgroundEventHandler() {
|
||||||
@ -56,6 +54,14 @@ void setupBackgroundEventHandler() {
|
|||||||
EventTypeMatcher<SignOutCommand>(performSignOut),
|
EventTypeMatcher<SignOutCommand>(performSignOut),
|
||||||
EventTypeMatcher<SendFilesCommand>(performSendFiles),
|
EventTypeMatcher<SendFilesCommand>(performSendFiles),
|
||||||
EventTypeMatcher<SetConversationMuteStatusCommand>(performSetMuteState),
|
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);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@ -79,43 +85,31 @@ Future<void> performLogin(LoginCommand command, { dynamic extra }) async {
|
|||||||
|
|
||||||
// ignore: avoid_dynamic_calls
|
// ignore: avoid_dynamic_calls
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
GetIt.I.get<MoxxyReconnectionPolicy>().setShouldReconnect(true);
|
GetIt.I.get<MoxxyReconnectionPolicy>().setShouldReconnect(true);
|
||||||
final settings = GetIt.I.get<XmppConnection>().getConnectionSettings();
|
final settings = GetIt.I.get<XmppConnection>().getConnectionSettings();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
LoginSuccessfulEvent(
|
LoginSuccessfulEvent(
|
||||||
jid: settings.jid.toString(),
|
jid: settings.jid.toString(),
|
||||||
displayName: settings.jid.local,
|
preStart: await _buildPreStartDoneEvent(preferences),
|
||||||
),
|
),
|
||||||
id:id,
|
id:id,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO(Unknown): Send the data of the [PreStartDoneEvent]
|
|
||||||
} else {
|
} else {
|
||||||
GetIt.I.get<MoxxyReconnectionPolicy>().setShouldReconnect(false);
|
GetIt.I.get<MoxxyReconnectionPolicy>().setShouldReconnect(false);
|
||||||
sendEvent(
|
sendEvent(
|
||||||
LoginFailureEvent(
|
LoginFailureEvent(
|
||||||
reason: result.reason!,
|
reason: xmppErrorToTranslatableString(result.error!),
|
||||||
),
|
),
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra }) async {
|
Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences) 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 xmpp = GetIt.I.get<XmppService>();
|
||||||
final settings = await xmpp.getConnectionSettings();
|
|
||||||
final state = await xmpp.getXmppState();
|
final state = await xmpp.getXmppState();
|
||||||
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
|
|
||||||
|
|
||||||
if (settings != null) {
|
|
||||||
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
|
await GetIt.I.get<RosterService>().loadRosterFromDatabase();
|
||||||
|
|
||||||
// Check some permissions
|
// Check some permissions
|
||||||
@ -129,8 +123,7 @@ Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra })
|
|||||||
),);
|
),);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendEvent(
|
return PreStartDoneEvent(
|
||||||
PreStartDoneEvent(
|
|
||||||
state: 'logged_in',
|
state: 'logged_in',
|
||||||
jid: state.jid,
|
jid: state.jid,
|
||||||
displayName: state.displayName ?? state.jid!.split('@').first,
|
displayName: state.displayName ?? state.jid!.split('@').first,
|
||||||
@ -140,7 +133,35 @@ Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra })
|
|||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
|
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
|
||||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra }) async {
|
||||||
|
final id = extra as String;
|
||||||
|
|
||||||
|
// Prevent a race condition where the UI sends the prestart command before the service
|
||||||
|
// has finished setting everything up
|
||||||
|
GetIt.I.get<Logger>().finest('Waiting for preStart future to complete..');
|
||||||
|
await GetIt.I.get<Completer<void>>().future;
|
||||||
|
GetIt.I.get<Logger>().finest('PreStart future done');
|
||||||
|
|
||||||
|
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
|
|
||||||
|
// Set the locale very early
|
||||||
|
GetIt.I.get<LanguageService>().defaultLocale = command.systemLocaleCode;
|
||||||
|
if (preferences.languageLocaleCode == 'default') {
|
||||||
|
LocaleSettings.setLocaleRaw(command.systemLocaleCode);
|
||||||
|
} else {
|
||||||
|
LocaleSettings.setLocaleRaw(preferences.languageLocaleCode);
|
||||||
|
}
|
||||||
|
GetIt.I.get<XmppService>().setNotificationText(
|
||||||
|
await GetIt.I.get<XmppConnection>().getConnectionState(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final settings = await GetIt.I.get<XmppService>().getConnectionSettings();
|
||||||
|
if (settings != null) {
|
||||||
|
sendEvent(
|
||||||
|
await _buildPreStartDoneEvent(preferences),
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -185,6 +206,8 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
|||||||
} else {
|
} else {
|
||||||
final conversation = await cs.addConversationFromData(
|
final conversation = await cs.addConversationFromData(
|
||||||
command.title,
|
command.title,
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
command.lastMessageBody,
|
command.lastMessageBody,
|
||||||
command.avatarUrl,
|
command.avatarUrl,
|
||||||
command.jid,
|
command.jid,
|
||||||
@ -193,6 +216,7 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
|||||||
true,
|
true,
|
||||||
// TODO(PapaTutuWawa): Take as an argument
|
// TODO(PapaTutuWawa): Take as an argument
|
||||||
false,
|
false,
|
||||||
|
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
@ -261,6 +285,21 @@ Future<void> performSetCSIState(SetCSIStateCommand command, { dynamic extra }) a
|
|||||||
|
|
||||||
Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async {
|
Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async {
|
||||||
await GetIt.I.get<PreferencesService>().modifyPreferences((_) => command.preferences);
|
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 {
|
Future<void> performAddContact(AddContactCommand command, { dynamic extra }) async {
|
||||||
@ -288,6 +327,8 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
|||||||
} else {
|
} else {
|
||||||
final c = await cs.addConversationFromData(
|
final c = await cs.addConversationFromData(
|
||||||
jid.split('@')[0],
|
jid.split('@')[0],
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
jid,
|
jid,
|
||||||
@ -296,6 +337,7 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
|||||||
true,
|
true,
|
||||||
// TODO(PapaTutuWawa): Take as an argument
|
// TODO(PapaTutuWawa): Take as an argument
|
||||||
false,
|
false,
|
||||||
|
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
|
||||||
);
|
);
|
||||||
sendEvent(
|
sendEvent(
|
||||||
AddContactResultEvent(conversation: c, added: true),
|
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 {
|
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 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!);
|
final metadata = await peekFile(command.message.srcUrl!);
|
||||||
|
|
||||||
// TODO(Unknown): Maybe deduplicate with the code in the xmpp service
|
// TODO(Unknown): Maybe deduplicate with the code in the xmpp service
|
||||||
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
|
// NOTE: This either works by returing "jpg" for ".../hallo.jpg" or fails
|
||||||
// for ".../aaaaaaaaa", in which case we would've failed anyways.
|
// 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);
|
final mimeGuess = metadata.mime ?? guessMimeTypeFromExtension(ext);
|
||||||
|
|
||||||
await srv.downloadFile(
|
await srv.downloadFile(
|
||||||
FileDownloadJob(
|
FileDownloadJob(
|
||||||
command.message.srcUrl!,
|
MediaFileLocation(
|
||||||
command.message.id,
|
message.srcUrl!,
|
||||||
command.message.conversationJid,
|
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,
|
mimeGuess,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -441,3 +497,139 @@ Future<void> performSetMuteState(SetConversationMuteStatusCommand command, { dyn
|
|||||||
|
|
||||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
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:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'dart:ui';
|
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;
|
import 'package:native_imaging/native_imaging.dart' as native;
|
||||||
|
|
||||||
/// Generate a blurhash thumbnail using native_imaging.
|
Future<String?> _generateBlurhashThumbnailImpl(String path) async {
|
||||||
Future<String?> generateBlurhashThumbnail(String path) async {
|
|
||||||
await native.init();
|
await native.init();
|
||||||
|
|
||||||
final bytes = await File(path).readAsBytes();
|
final bytes = await File(path).readAsBytes();
|
||||||
|
|
||||||
native.Image image;
|
native.Image image;
|
||||||
|
int width;
|
||||||
|
int height;
|
||||||
try {
|
try {
|
||||||
final dartCodec = await instantiateImageCodec(bytes);
|
final dartCodec = await instantiateImageCodec(bytes);
|
||||||
final dartFrame = await dartCodec.getNextFrame();
|
final dartFrame = await dartCodec.getNextFrame();
|
||||||
final rgbaData = await dartFrame.image.toByteData();
|
final rgbaData = await dartFrame.image.toByteData();
|
||||||
if (rgbaData == null) return null;
|
if (rgbaData == null) return null;
|
||||||
|
|
||||||
final width = dartFrame.image.width;
|
width = dartFrame.image.width;
|
||||||
final height = dartFrame.image.height;
|
height = dartFrame.image.height;
|
||||||
|
|
||||||
dartFrame.image.dispose();
|
dartFrame.image.dispose();
|
||||||
dartCodec.dispose();
|
dartCodec.dispose();
|
||||||
@ -36,7 +40,37 @@ Future<String?> generateBlurhashThumbnail(String path) async {
|
|||||||
return null;
|
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();
|
image.free();
|
||||||
|
scaled.free();
|
||||||
return blurhash;
|
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:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:math';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:dio/dio.dart' as dio;
|
import 'package:dio/dio.dart' as dio;
|
||||||
import 'package:get_it/get_it.dart';
|
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:logging/logging.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
|
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||||
@ -20,14 +25,12 @@ import 'package:moxxyv2/service/service.dart';
|
|||||||
import 'package:moxxyv2/shared/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/xmpp/connection.dart';
|
import 'package:moxxyv2/shared/warning_types.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:path/path.dart' as pathlib;
|
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:synchronized/synchronized.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
/// This service is responsible for managing the up- and download of files using Http.
|
/// This service is responsible for managing the up- and download of files using Http.
|
||||||
class HttpFileTransferService {
|
class HttpFileTransferService {
|
||||||
@ -137,10 +140,51 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
|
||||||
|
// Notify UI of upload failure
|
||||||
|
for (final recipient in job.recipients) {
|
||||||
|
final msg = await ms.updateMessage(
|
||||||
|
job.messageMap[recipient]!.id,
|
||||||
|
errorType: error,
|
||||||
|
isUploading: false,
|
||||||
|
);
|
||||||
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
await _pickNextUploadTask();
|
||||||
|
}
|
||||||
|
|
||||||
/// Actually attempt to upload the file described by the job [job].
|
/// Actually attempt to upload the file described by the job [job].
|
||||||
Future<void> _performFileUpload(FileUploadJob job) async {
|
Future<void> _performFileUpload(FileUploadJob job) async {
|
||||||
_log.finest('Beginning upload of ${job.path}');
|
_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 data = await file.readAsBytes();
|
||||||
final stat = file.statSync();
|
final stat = file.statSync();
|
||||||
|
|
||||||
@ -148,17 +192,16 @@ class HttpFileTransferService {
|
|||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
final httpManager = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
final httpManager = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
||||||
final slotResult = await httpManager.requestUploadSlot(
|
final slotResult = await httpManager.requestUploadSlot(
|
||||||
pathlib.basename(job.path),
|
pathlib.basename(path),
|
||||||
stat.size,
|
stat.size,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (slotResult.isError()) {
|
if (slotResult.isType<HttpFileUploadError>()) {
|
||||||
_log.severe('Failed to request upload slot for ${job.path}!');
|
_log.severe('Failed to request upload slot for ${job.path}!');
|
||||||
await _nextUploadJob();
|
await _fileUploadFailed(job, fileUploadFailedError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||||
final slot = slotResult.getValue();
|
|
||||||
try {
|
try {
|
||||||
final response = await dio.Dio().putUri<dynamic>(
|
final response = await dio.Dio().putUri<dynamic>(
|
||||||
Uri.parse(slot.putUrl),
|
Uri.parse(slot.putUrl),
|
||||||
@ -187,38 +230,56 @@ class HttpFileTransferService {
|
|||||||
if (response.statusCode != 201) {
|
if (response.statusCode != 201) {
|
||||||
// TODO(PapaTutuWawa): Trigger event
|
// TODO(PapaTutuWawa): Trigger event
|
||||||
_log.severe('Upload failed');
|
_log.severe('Upload failed');
|
||||||
|
await _fileUploadFailed(job, fileUploadFailedError);
|
||||||
// Notify UI of upload failure
|
return;
|
||||||
for (final recipient in job.recipients) {
|
|
||||||
final msg = await ms.updateMessage(
|
|
||||||
job.messageMap[recipient]!.id,
|
|
||||||
errorType: fileUploadFailedError,
|
|
||||||
);
|
|
||||||
sendEvent(
|
|
||||||
MessageUpdatedEvent(
|
|
||||||
message: msg.copyWith(isUploading: false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
_log.fine('Upload was successful');
|
_log.fine('Upload was successful');
|
||||||
|
|
||||||
|
const uuid = Uuid();
|
||||||
for (final recipient in job.recipients) {
|
for (final recipient in job.recipients) {
|
||||||
// Notify UI of upload completion
|
// Notify UI of upload completion
|
||||||
var msg = job.messageMap[recipient]!;
|
var msg = await ms.updateMessage(
|
||||||
|
job.messageMap[recipient]!.id,
|
||||||
// Reset a stored error, if there was one
|
mediaSize: stat.size,
|
||||||
if (msg.errorType != null) {
|
errorType: noError,
|
||||||
|
encryptionScheme: encryption != null ?
|
||||||
|
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||||
|
null,
|
||||||
|
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||||
|
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||||
|
isUploading: false,
|
||||||
|
srcUrl: slot.getUrl,
|
||||||
|
);
|
||||||
|
// TODO(Unknown): Maybe batch those two together?
|
||||||
|
final oldSid = msg.sid;
|
||||||
msg = await ms.updateMessage(
|
msg = await ms.updateMessage(
|
||||||
msg.id,
|
msg.id,
|
||||||
errorType: noError,
|
sid: uuid.v4(),
|
||||||
|
originId: uuid.v4(),
|
||||||
);
|
);
|
||||||
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
|
StatelessFileSharingSource source;
|
||||||
|
final plaintextHashes = <String, String>{};
|
||||||
|
if (encryption != null) {
|
||||||
|
source = StatelessFileSharingEncryptedSource(
|
||||||
|
SFSEncryptionType.aes256GcmNoPadding,
|
||||||
|
encryption.key,
|
||||||
|
encryption.iv,
|
||||||
|
encryption.ciphertextHashes,
|
||||||
|
StatelessFileSharingUrlSource(slot.getUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||||
|
} else {
|
||||||
|
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||||
|
try {
|
||||||
|
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||||
|
.hashFile(job.path, HashFunction.sha256);
|
||||||
|
} catch (ex) {
|
||||||
|
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sendEvent(
|
|
||||||
MessageUpdatedEvent(
|
|
||||||
message: msg.copyWith(isUploading: false),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send the message to the recipient
|
// Send the message to the recipient
|
||||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||||
@ -226,6 +287,7 @@ class HttpFileTransferService {
|
|||||||
to: recipient,
|
to: recipient,
|
||||||
body: slot.getUrl,
|
body: slot.getUrl,
|
||||||
requestDeliveryReceipt: true,
|
requestDeliveryReceipt: true,
|
||||||
|
id: msg.sid,
|
||||||
originId: msg.originId,
|
originId: msg.originId,
|
||||||
sfs: StatelessFileSharingData(
|
sfs: StatelessFileSharingData(
|
||||||
FileMetadataData(
|
FileMetadataData(
|
||||||
@ -233,10 +295,12 @@ class HttpFileTransferService {
|
|||||||
size: stat.size,
|
size: stat.size,
|
||||||
name: pathlib.basename(job.path),
|
name: pathlib.basename(job.path),
|
||||||
thumbnails: job.thumbnails,
|
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');
|
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||||
@ -249,15 +313,15 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on dio.DioError {
|
} on dio.DioError {
|
||||||
// TODO(PapaTutuWawa): Check if this is a timeout
|
|
||||||
_log.finest('Upload failed due to connection error');
|
_log.finest('Upload failed due to connection error');
|
||||||
|
await _fileUploadFailed(job, fileUploadFailedError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _nextUploadJob();
|
await _pickNextUploadTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _nextUploadJob() async {
|
Future<void> _pickNextUploadTask() async {
|
||||||
// Free the upload resources for the next one
|
// Free the upload resources for the next one
|
||||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
||||||
await _uploadLock.synchronized(() async {
|
await _uploadLock.synchronized(() async {
|
||||||
@ -270,17 +334,38 @@ class HttpFileTransferService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fileDownloadFailed(FileDownloadJob job, int error) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
|
||||||
|
// Notify UI of download failure
|
||||||
|
final msg = await ms.updateMessage(
|
||||||
|
job.mId,
|
||||||
|
errorType: error,
|
||||||
|
isDownloading: false,
|
||||||
|
);
|
||||||
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
|
await _pickNextDownloadTask();
|
||||||
|
}
|
||||||
|
|
||||||
/// Actually attempt to download the file described by the job [job].
|
/// Actually attempt to download the file described by the job [job].
|
||||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||||
_log.finest('Downloading ${job.url}');
|
final filename = job.location.filename;
|
||||||
final uri = Uri.parse(job.url);
|
_log.finest('Downloading ${job.location.url} as $filename');
|
||||||
final filename = uri.pathSegments.last;
|
|
||||||
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
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 {
|
try {
|
||||||
final response = await dio.Dio().downloadUri(
|
response = await dio.Dio().downloadUri(
|
||||||
uri,
|
Uri.parse(job.location.url),
|
||||||
downloadedPath,
|
downloadPath,
|
||||||
onReceiveProgress: (count, total) {
|
onReceiveProgress: (count, total) {
|
||||||
final progress = count.toDouble() / total.toDouble();
|
final progress = count.toDouble() / total.toDouble();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
@ -291,12 +376,58 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
} on dio.DioError catch(err) {
|
||||||
|
// TODO(PapaTutuWawa): React if we received an error that is not related to the
|
||||||
|
// connection.
|
||||||
|
_log.finest('Failed to download: $err');
|
||||||
|
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isRequestOkay(response.statusCode)) {
|
if (!isRequestOkay(response.statusCode)) {
|
||||||
// TODO(PapaTutuWawa): Error handling
|
_log.warning('HTTP GET of ${job.location.url} returned ${response.statusCode}');
|
||||||
// TODO(PapaTutuWawa): Trigger event
|
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||||
_log.warning('HTTP GET of ${job.url} returned ${response.statusCode}');
|
return;
|
||||||
} else {
|
} 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
|
// Check the MIME type
|
||||||
final notification = GetIt.I.get<NotificationsService>();
|
final notification = GetIt.I.get<NotificationsService>();
|
||||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||||
@ -309,9 +440,15 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
// Find out the dimensions
|
// Find out the dimensions
|
||||||
// TODO(Unknown): Restrict to the library's supported file types
|
// TODO(Unknown): Restrict to the library's supported file types
|
||||||
final size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
|
Size? size;
|
||||||
mediaWidth = size.width;
|
try {
|
||||||
mediaHeight = size.height;
|
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/')) {
|
} else if (mime.startsWith('video/')) {
|
||||||
// TODO(Unknown): Also figure out the thumbnail size here
|
// TODO(Unknown): Also figure out the thumbnail size here
|
||||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||||
@ -326,17 +463,24 @@ class HttpFileTransferService {
|
|||||||
mediaType: mime,
|
mediaType: mime,
|
||||||
mediaWidth: mediaWidth,
|
mediaWidth: mediaWidth,
|
||||||
mediaHeight: mediaHeight,
|
mediaHeight: mediaHeight,
|
||||||
|
mediaSize: File(downloadedPath).lengthSync(),
|
||||||
isFileUploadNotification: false,
|
isFileUploadNotification: false,
|
||||||
|
warningType: integrityCheckPassed ?
|
||||||
|
null :
|
||||||
|
warningFileIntegrityCheckFailed,
|
||||||
|
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||||
|
messageChatEncryptedButFileNot :
|
||||||
|
null,
|
||||||
|
isDownloading: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(MessageUpdatedEvent(message: msg.copyWith(isDownloading: false)));
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||||
await notification.showNotification(msg, '');
|
await notification.showNotification(msg, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
final conv = (await GetIt.I.get<ConversationService>().getConversationByJid(job.conversationJid))!;
|
|
||||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||||
downloadedPath,
|
downloadedPath,
|
||||||
msg.timestamp,
|
msg.timestamp,
|
||||||
@ -348,16 +492,16 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||||
}
|
}
|
||||||
} on dio.DioError catch(err) {
|
|
||||||
// TODO(PapaTutuWawa): React if we received an error that is not related to the
|
|
||||||
// connection.
|
|
||||||
_log.finest('Error: $err');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Free the download resources for the next one
|
// Free the download resources for the next one
|
||||||
|
await _pickNextDownloadTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickNextDownloadTask() async {
|
||||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
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();
|
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||||
} else {
|
} else {
|
||||||
|
@ -1,15 +1,17 @@
|
|||||||
import 'package:meta/meta.dart';
|
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/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/staging/extensible_file_thumbnails.dart';
|
|
||||||
|
|
||||||
/// A job describing the download of a file.
|
/// A job describing the download of a file.
|
||||||
@immutable
|
@immutable
|
||||||
class FileUploadJob {
|
class FileUploadJob {
|
||||||
|
const FileUploadJob(this.recipients, this.path, this.mime, this.encryptMap, this.messageMap, this.thumbnails);
|
||||||
const FileUploadJob(this.recipients, this.path, this.mime, this.messageMap, this.thumbnails);
|
|
||||||
final List<String> recipients;
|
final List<String> recipients;
|
||||||
final String path;
|
final String path;
|
||||||
final String? mime;
|
final String? mime;
|
||||||
|
// Recipient -> Should encrypt
|
||||||
|
final Map<String, bool> encryptMap;
|
||||||
// Recipient -> Message
|
// Recipient -> Message
|
||||||
final Map<String, Message> messageMap;
|
final Map<String, Message> messageMap;
|
||||||
final List<Thumbnail> thumbnails;
|
final List<Thumbnail> thumbnails;
|
||||||
@ -21,19 +23,25 @@ class FileUploadJob {
|
|||||||
path == other.path &&
|
path == other.path &&
|
||||||
messageMap == other.messageMap &&
|
messageMap == other.messageMap &&
|
||||||
mime == other.mime &&
|
mime == other.mime &&
|
||||||
thumbnails == other.thumbnails;
|
thumbnails == other.thumbnails &&
|
||||||
|
encryptMap == other.encryptMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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.
|
/// A job describing the upload of a file.
|
||||||
@immutable
|
@immutable
|
||||||
class FileDownloadJob {
|
class FileDownloadJob {
|
||||||
|
const FileDownloadJob(
|
||||||
const FileDownloadJob(this.url, this.mId, this.conversationJid, this.mimeGuess, {this.shouldShowNotification = true});
|
this.location,
|
||||||
final String url;
|
this.mId,
|
||||||
|
this.conversationJid,
|
||||||
|
this.mimeGuess, {
|
||||||
|
this.shouldShowNotification = true,
|
||||||
|
});
|
||||||
|
final MediaFileLocation location;
|
||||||
final int mId;
|
final int mId;
|
||||||
final String conversationJid;
|
final String conversationJid;
|
||||||
final String? mimeGuess;
|
final String? mimeGuess;
|
||||||
@ -42,12 +50,13 @@ class FileDownloadJob {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return other is FileDownloadJob &&
|
return other is FileDownloadJob &&
|
||||||
url == other.url &&
|
location == other.location &&
|
||||||
mId == other.mId &&
|
mId == other.mId &&
|
||||||
conversationJid == other.conversationJid &&
|
conversationJid == other.conversationJid &&
|
||||||
mimeGuess == other.mimeGuess &&
|
mimeGuess == other.mimeGuess &&
|
||||||
shouldShowNotification == other.shouldShowNotification;
|
shouldShowNotification == other.shouldShowNotification;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 'dart:collection';
|
||||||
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxxyv2/service/database/database.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';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
|
||||||
class MessageService {
|
class MessageService {
|
||||||
|
|
||||||
MessageService() : _messageCache = HashMap(), _log = Logger('MessageService');
|
MessageService() : _messageCache = HashMap(), _log = Logger('MessageService');
|
||||||
final HashMap<String, List<Message>> _messageCache;
|
final HashMap<String, List<Message>> _messageCache;
|
||||||
final Logger _log;
|
final Logger _log;
|
||||||
@ -36,8 +35,12 @@ class MessageService {
|
|||||||
bool isMedia,
|
bool isMedia,
|
||||||
String sid,
|
String sid,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
|
bool encrypted,
|
||||||
{
|
{
|
||||||
String? srcUrl,
|
String? srcUrl,
|
||||||
|
String? key,
|
||||||
|
String? iv,
|
||||||
|
String? encryptionScheme,
|
||||||
String? mediaUrl,
|
String? mediaUrl,
|
||||||
String? mediaType,
|
String? mediaType,
|
||||||
String? thumbnailData,
|
String? thumbnailData,
|
||||||
@ -46,6 +49,13 @@ class MessageService {
|
|||||||
String? originId,
|
String? originId,
|
||||||
String? quoteId,
|
String? quoteId,
|
||||||
String? filename,
|
String? filename,
|
||||||
|
int? errorType,
|
||||||
|
int? warningType,
|
||||||
|
Map<String, String>? plaintextHashes,
|
||||||
|
Map<String, String>? ciphertextHashes,
|
||||||
|
bool isDownloading = false,
|
||||||
|
bool isUploading = false,
|
||||||
|
int? mediaSize,
|
||||||
}
|
}
|
||||||
) async {
|
) async {
|
||||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
||||||
@ -56,7 +66,11 @@ class MessageService {
|
|||||||
isMedia,
|
isMedia,
|
||||||
sid,
|
sid,
|
||||||
isFileUploadNotification,
|
isFileUploadNotification,
|
||||||
|
encrypted,
|
||||||
srcUrl: srcUrl,
|
srcUrl: srcUrl,
|
||||||
|
key: key,
|
||||||
|
iv: iv,
|
||||||
|
encryptionScheme: encryptionScheme,
|
||||||
mediaUrl: mediaUrl,
|
mediaUrl: mediaUrl,
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
thumbnailData: thumbnailData,
|
thumbnailData: thumbnailData,
|
||||||
@ -65,6 +79,13 @@ class MessageService {
|
|||||||
originId: originId,
|
originId: originId,
|
||||||
quoteId: quoteId,
|
quoteId: quoteId,
|
||||||
filename: filename,
|
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
|
// Only update the cache if the conversation already has been loaded. This prevents
|
||||||
@ -87,31 +108,66 @@ class MessageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Message?> getMessageById(String conversationJid, int id) async {
|
||||||
|
if (!_messageCache.containsKey(conversationJid)) {
|
||||||
|
await getMessagesForJid(conversationJid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstWhereOrNull(
|
||||||
|
_messageCache[conversationJid]!,
|
||||||
|
(message) => message.id == id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
||||||
Future<Message> updateMessage(int id, {
|
Future<Message> updateMessage(int id, {
|
||||||
String? mediaUrl,
|
Object? body = notSpecified,
|
||||||
String? mediaType,
|
Object? mediaUrl = notSpecified,
|
||||||
|
Object? mediaType = notSpecified,
|
||||||
|
bool? isMedia,
|
||||||
bool? received,
|
bool? received,
|
||||||
bool? displayed,
|
bool? displayed,
|
||||||
bool? acked,
|
bool? acked,
|
||||||
int? errorType,
|
Object? errorType = notSpecified,
|
||||||
|
Object? warningType = notSpecified,
|
||||||
bool? isFileUploadNotification,
|
bool? isFileUploadNotification,
|
||||||
String? srcUrl,
|
Object? srcUrl = notSpecified,
|
||||||
int? mediaWidth,
|
Object? key = notSpecified,
|
||||||
int? mediaHeight,
|
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 {
|
}) async {
|
||||||
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
||||||
id,
|
id,
|
||||||
|
body: body,
|
||||||
mediaUrl: mediaUrl,
|
mediaUrl: mediaUrl,
|
||||||
mediaType: mediaType,
|
mediaType: mediaType,
|
||||||
received: received,
|
received: received,
|
||||||
displayed: displayed,
|
displayed: displayed,
|
||||||
acked: acked,
|
acked: acked,
|
||||||
errorType: errorType,
|
errorType: errorType,
|
||||||
|
warningType: warningType,
|
||||||
isFileUploadNotification: isFileUploadNotification,
|
isFileUploadNotification: isFileUploadNotification,
|
||||||
srcUrl: srcUrl,
|
srcUrl: srcUrl,
|
||||||
|
key: key,
|
||||||
|
iv: iv,
|
||||||
|
encryptionScheme: encryptionScheme,
|
||||||
mediaWidth: mediaWidth,
|
mediaWidth: mediaWidth,
|
||||||
mediaHeight: mediaHeight,
|
mediaHeight: mediaHeight,
|
||||||
|
mediaSize: mediaSize,
|
||||||
|
isUploading: isUploading,
|
||||||
|
isDownloading: isDownloading,
|
||||||
|
originId: originId,
|
||||||
|
sid: sid,
|
||||||
|
isRetracted: isRetracted,
|
||||||
|
isMedia: isMedia,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import 'package:moxxyv2/xmpp/xeps/xep_0030/helpers.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0030/xep_0030.dart';
|
|
||||||
|
|
||||||
class MoxxyDiscoManager extends DiscoManager {
|
class MoxxyDiscoManager extends DiscoManager {
|
||||||
@override
|
@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:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/xmpp/reconnect.dart';
|
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
/// This class implements a reconnection policy that is connectivity aware with a random
|
/// This class implements a reconnection policy that is connectivity aware with a random
|
||||||
@ -13,7 +13,7 @@ import 'package:synchronized/synchronized.dart';
|
|||||||
/// connected. Otherwise, we idle until we have a connection again.
|
/// connected. Otherwise, we idle until we have a connection again.
|
||||||
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||||
|
|
||||||
MoxxyReconnectionPolicy({ bool isTesting = false })
|
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
|
||||||
: _isTesting = isTesting,
|
: _isTesting = isTesting,
|
||||||
_timerLock = Lock(),
|
_timerLock = Lock(),
|
||||||
_log = Logger('MoxxyReconnectionPolicy'),
|
_log = Logger('MoxxyReconnectionPolicy'),
|
||||||
@ -28,6 +28,9 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
|||||||
/// Just for testing purposes
|
/// Just for testing purposes
|
||||||
final bool _isTesting;
|
final bool _isTesting;
|
||||||
|
|
||||||
|
/// Maximum backoff time
|
||||||
|
final int? maxBackoffTime;
|
||||||
|
|
||||||
/// To be called when the conectivity changes
|
/// To be called when the conectivity changes
|
||||||
Future<void> onConnectivityChanged(bool regained, bool lost) async {
|
Future<void> onConnectivityChanged(bool regained, bool lost) async {
|
||||||
// Do nothing if we should not reconnect
|
// Do nothing if we should not reconnect
|
||||||
@ -78,7 +81,18 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
|||||||
Future<void> _attemptReconnection(bool immediately) async {
|
Future<void> _attemptReconnection(bool immediately) async {
|
||||||
if (await testAndSetIsReconnecting()) {
|
if (await testAndSetIsReconnecting()) {
|
||||||
// Attempt reconnecting
|
// 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();
|
await _stopTimer();
|
||||||
if (immediately) {
|
if (immediately) {
|
||||||
_log.finest('Immediately attempting reconnection...');
|
_log.finest('Immediately attempting reconnection...');
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/xmpp/roster.dart';
|
|
||||||
|
|
||||||
class MoxxyRosterManager extends RosterManager {
|
class MoxxyRosterManager extends RosterManager {
|
||||||
@override
|
@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 'dart:async';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.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 {
|
class MoxxyStreamManagementManager extends StreamManagementManager {
|
||||||
@override
|
@override
|
||||||
bool shouldTriggerAckedEvent(Stanza stanza) {
|
bool shouldTriggerAckedEvent(Stanza stanza) {
|
||||||
// TODO(PapaTutuWawa): Once OMEMO is supported, add the encrypted element here
|
|
||||||
return stanza.tag == 'message' &&
|
return stanza.tag == 'message' &&
|
||||||
stanza.id != null && (
|
stanza.id != null && (
|
||||||
stanza.firstTag('body') != null ||
|
stanza.firstTag('body') != null ||
|
||||||
stanza.firstTag('x', xmlns: oobDataXmlns) != null ||
|
stanza.firstTag('x', xmlns: oobDataXmlns) != null ||
|
||||||
stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null ||
|
stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null ||
|
||||||
stanza.firstTag('file-upload', xmlns: fileUploadNotificationXmlns) != null
|
stanza.firstTag('file-upload', xmlns: fileUploadNotificationXmlns) != null ||
|
||||||
|
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -30,7 +27,7 @@ class MoxxyStreamManagementManager extends StreamManagementManager {
|
|||||||
Future<void> loadState() async {
|
Future<void> loadState() async {
|
||||||
final state = await GetIt.I.get<XmppService>().getXmppState();
|
final state = await GetIt.I.get<XmppService>().getXmppState();
|
||||||
if (state.smState != null) {
|
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
|
// TODO(Unknown): Add resolution dependent drawables for the notification icon
|
||||||
class NotificationsService {
|
class NotificationsService {
|
||||||
|
|
||||||
NotificationsService() : _log = Logger('NotificationsService');
|
NotificationsService() : _log = Logger('NotificationsService');
|
||||||
// ignore: unused_field
|
// ignore: unused_field
|
||||||
final Logger _log;
|
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:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/events.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/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.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].
|
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
|
||||||
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
|
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
|
||||||
@ -343,7 +339,7 @@ class RosterService {
|
|||||||
|
|
||||||
Future<void> requestRoster() async {
|
Future<void> requestRoster() async {
|
||||||
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
|
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
|
||||||
MayFail<RosterRequestResult?> result;
|
Result<RosterRequestResult?, RosterError> result;
|
||||||
if (roster.rosterVersioningAvailable()) {
|
if (roster.rosterVersioningAvailable()) {
|
||||||
_log.fine('Stream supports roster versioning');
|
_log.fine('Stream supports roster versioning');
|
||||||
result = await roster.requestRosterPushes();
|
result = await roster.requestRosterPushes();
|
||||||
@ -353,17 +349,18 @@ class RosterService {
|
|||||||
result = await roster.requestRoster();
|
result = await roster.requestRoster();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.isError()) {
|
if (result.isType<RosterError>()) {
|
||||||
_log.warning('Failed to request roster');
|
_log.warning('Failed to request roster');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.getValue() != null) {
|
final value = result.get<RosterRequestResult?>();
|
||||||
|
if (value != null) {
|
||||||
final currentRoster = await getRoster();
|
final currentRoster = await getRoster();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
await processRosterDiff(
|
await processRosterDiff(
|
||||||
currentRoster,
|
currentRoster,
|
||||||
result.getValue()!.items,
|
value.items,
|
||||||
false,
|
false,
|
||||||
addRosterItemFromData,
|
addRosterItemFromData,
|
||||||
updateRosterItem,
|
updateRosterItem,
|
||||||
|
@ -1,26 +1,33 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/awaitabledatasender.dart';
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.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/avatars.dart';
|
||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||||
import 'package:moxxyv2/service/conversation.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/database/database.dart';
|
||||||
import 'package:moxxyv2/service/events.dart';
|
import 'package:moxxyv2/service/events.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||||
import 'package:moxxyv2/service/managers/disco.dart';
|
import 'package:moxxyv2/service/language.dart';
|
||||||
import 'package:moxxyv2/service/managers/roster.dart';
|
|
||||||
import 'package:moxxyv2/service/managers/stream.dart';
|
|
||||||
import 'package:moxxyv2/service/message.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/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/notifications.dart';
|
||||||
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.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/events.dart';
|
||||||
import 'package:moxxyv2/shared/logging.dart';
|
import 'package:moxxyv2/shared/logging.dart';
|
||||||
import 'package:moxxyv2/ui/events.dart' as ui_events;
|
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 {
|
Future<void> initializeServiceIfNeeded() async {
|
||||||
final logger = GetIt.I.get<Logger>();
|
final logger = GetIt.I.get<Logger>();
|
||||||
@ -73,7 +53,9 @@ Future<void> initializeServiceIfNeeded() async {
|
|||||||
// ignore: cascade_invocations
|
// ignore: cascade_invocations
|
||||||
logger.info('Service is running. Sending pre start command');
|
logger.info('Service is running. Sending pre start command');
|
||||||
await handler.getDataSender().sendData(
|
await handler.getDataSender().sendData(
|
||||||
PerformPreStartCommand(),
|
PerformPreStartCommand(
|
||||||
|
systemLocaleCode: WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
|
||||||
|
),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -95,8 +77,7 @@ void sendEvent(BackgroundEvent event, { String? id }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void setupLogging() {
|
void setupLogging() {
|
||||||
//Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||||
Logger.root.level = Level.ALL;
|
|
||||||
Logger.root.onRecord.listen((record) {
|
Logger.root.onRecord.listen((record) {
|
||||||
final logMessageHeader = '[${record.level.name}] (${record.loggerName}) ${record.time}: ';
|
final logMessageHeader = '[${record.level.name}] (${record.loggerName}) ${record.time}: ';
|
||||||
var msg = record.message;
|
var msg = record.message;
|
||||||
@ -155,6 +136,7 @@ Future<void> entrypoint() async {
|
|||||||
// Register singletons
|
// Register singletons
|
||||||
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
|
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
|
||||||
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
|
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
|
||||||
|
GetIt.I.registerSingleton<LanguageService>(LanguageService());
|
||||||
|
|
||||||
setupLogging();
|
setupLogging();
|
||||||
setupBackgroundEventHandler();
|
setupBackgroundEventHandler();
|
||||||
@ -171,30 +153,39 @@ Future<void> entrypoint() async {
|
|||||||
GetIt.I.registerSingleton<RosterService>(RosterService());
|
GetIt.I.registerSingleton<RosterService>(RosterService());
|
||||||
GetIt.I.registerSingleton<ConversationService>(ConversationService());
|
GetIt.I.registerSingleton<ConversationService>(ConversationService());
|
||||||
GetIt.I.registerSingleton<MessageService>(MessageService());
|
GetIt.I.registerSingleton<MessageService>(MessageService());
|
||||||
|
GetIt.I.registerSingleton<OmemoService>(OmemoService());
|
||||||
|
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
||||||
final xmpp = XmppService();
|
final xmpp = XmppService();
|
||||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||||
|
|
||||||
await GetIt.I.get<NotificationsService>().init();
|
await GetIt.I.get<NotificationsService>().init();
|
||||||
|
|
||||||
|
if (!kDebugMode) {
|
||||||
|
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
|
||||||
|
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
|
||||||
|
}
|
||||||
|
|
||||||
// Init the UDPLogger
|
// Init the UDPLogger
|
||||||
await initUDPLogger();
|
await initUDPLogger();
|
||||||
|
|
||||||
GetIt.I.registerSingleton<MoxxyReconnectionPolicy>(MoxxyReconnectionPolicy());
|
GetIt.I.registerSingleton<MoxxyReconnectionPolicy>(MoxxyReconnectionPolicy());
|
||||||
final connection = XmppConnection(GetIt.I.get<MoxxyReconnectionPolicy>())
|
final connection = XmppConnection(
|
||||||
..registerManagers([
|
GetIt.I.get<MoxxyReconnectionPolicy>(),
|
||||||
|
MoxxyTCPSocketWrapper(),
|
||||||
|
)..registerManagers([
|
||||||
MoxxyStreamManagementManager(),
|
MoxxyStreamManagementManager(),
|
||||||
MoxxyDiscoManager(),
|
MoxxyDiscoManager(),
|
||||||
MoxxyRosterManager(),
|
MoxxyRosterManager(),
|
||||||
|
MoxxyOmemoManager(),
|
||||||
PingManager(),
|
PingManager(),
|
||||||
MessageManager(),
|
MessageManager(),
|
||||||
PresenceManager(),
|
PresenceManager('http://moxxy.im'),
|
||||||
CSIManager(),
|
CSIManager(),
|
||||||
CarbonsManager(),
|
CarbonsManager(),
|
||||||
PubSubManager(),
|
PubSubManager(),
|
||||||
VCardManager(),
|
VCardManager(),
|
||||||
UserAvatarManager(),
|
UserAvatarManager(),
|
||||||
StableIdManager(),
|
StableIdManager(),
|
||||||
SIMSManager(),
|
|
||||||
MessageDeliveryReceiptManager(),
|
MessageDeliveryReceiptManager(),
|
||||||
ChatMarkerManager(),
|
ChatMarkerManager(),
|
||||||
OOBManager(),
|
OOBManager(),
|
||||||
@ -204,6 +195,10 @@ Future<void> entrypoint() async {
|
|||||||
ChatStateManager(),
|
ChatStateManager(),
|
||||||
HttpFileUploadManager(),
|
HttpFileUploadManager(),
|
||||||
FileUploadNotificationManager(),
|
FileUploadNotificationManager(),
|
||||||
|
EmeManager(),
|
||||||
|
CryptographicHashManager(),
|
||||||
|
DelayedDeliveryManager(),
|
||||||
|
MessageRetractionManager(),
|
||||||
])
|
])
|
||||||
..registerFeatureNegotiators([
|
..registerFeatureNegotiators([
|
||||||
ResourceBindingNegotiator(),
|
ResourceBindingNegotiator(),
|
||||||
@ -211,11 +206,10 @@ Future<void> entrypoint() async {
|
|||||||
StreamManagementNegotiator(),
|
StreamManagementNegotiator(),
|
||||||
CSINegotiator(),
|
CSINegotiator(),
|
||||||
RosterFeatureNegotiator(),
|
RosterFeatureNegotiator(),
|
||||||
// TODO(Unknown): This one may not work
|
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
||||||
//SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
|
||||||
SaslPlainNegotiator(),
|
|
||||||
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
||||||
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
||||||
|
SaslPlainNegotiator(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<XmppConnection>(connection);
|
GetIt.I.registerSingleton<XmppConnection>(connection);
|
||||||
@ -227,8 +221,16 @@ Future<void> entrypoint() async {
|
|||||||
|
|
||||||
final settings = await xmpp.getConnectionSettings();
|
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');
|
GetIt.I.get<Logger>().finest('Got settings');
|
||||||
if (settings != null) {
|
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
|
// The title of the notification will be changed as soon as the connection state
|
||||||
// of [XmppConnection] changes.
|
// of [XmppConnection] changes.
|
||||||
await connection.getManagerById<MoxxyStreamManagementManager>(smManager)!.loadState();
|
await connection.getManagerById<MoxxyStreamManagementManager>(smManager)!.loadState();
|
||||||
@ -236,7 +238,7 @@ Future<void> entrypoint() async {
|
|||||||
} else {
|
} else {
|
||||||
GetIt.I.get<BackgroundService>().setNotification(
|
GetIt.I.get<BackgroundService>().setNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
'Idle',
|
t.notifications.permanent.idle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
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.freezed.dart';
|
||||||
part 'state.g.dart';
|
part 'state.g.dart';
|
||||||
@ -29,6 +30,41 @@ class XmppState with _$XmppState {
|
|||||||
@Default(false) bool askedStoragePermission,
|
@Default(false) bool askedStoragePermission,
|
||||||
}) = _XmppState;
|
}) = _XmppState;
|
||||||
|
|
||||||
|
const XmppState._();
|
||||||
|
|
||||||
// JSON serialization
|
// JSON serialization
|
||||||
factory XmppState.fromJson(Map<String, dynamic> json) => _$XmppStateFromJson(json);
|
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:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:image_size_getter/file_input.dart';
|
import 'package:image_size_getter/file_input.dart';
|
||||||
import 'package:image_size_getter/image_size_getter.dart' as image_size;
|
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:mime/mime.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.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/avatars.dart';
|
||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
@ -21,87 +21,24 @@ import 'package:moxxyv2/service/helpers.dart';
|
|||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/notifications.dart';
|
import 'package:moxxyv2/service/notifications.dart';
|
||||||
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/state.dart';
|
import 'package:moxxyv2/service/state.dart';
|
||||||
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/migrator.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/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:path/path.dart' as pathlib;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
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 {
|
class XmppService {
|
||||||
|
|
||||||
XmppService() :
|
XmppService() :
|
||||||
_currentlyOpenedChatJid = '',
|
_currentlyOpenedChatJid = '',
|
||||||
_xmppConnectionSubscription = null,
|
_xmppConnectionSubscription = null,
|
||||||
@ -109,7 +46,6 @@ class XmppService {
|
|||||||
_eventHandler = EventHandler(),
|
_eventHandler = EventHandler(),
|
||||||
_appOpen = true,
|
_appOpen = true,
|
||||||
_loginTriggeredFromUI = false,
|
_loginTriggeredFromUI = false,
|
||||||
_migrator = _XmppStateMigrator(),
|
|
||||||
_log = Logger('XmppService') {
|
_log = Logger('XmppService') {
|
||||||
_eventHandler.addMatchers([
|
_eventHandler.addMatchers([
|
||||||
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
|
EventTypeMatcher<ConnectionStateChangedEvent>(_onConnectionStateChanged),
|
||||||
@ -124,11 +60,11 @@ class XmppService {
|
|||||||
EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
|
EventTypeMatcher<BlocklistBlockPushEvent>(_onBlocklistBlockPush),
|
||||||
EventTypeMatcher<BlocklistUnblockPushEvent>(_onBlocklistUnblockPush),
|
EventTypeMatcher<BlocklistUnblockPushEvent>(_onBlocklistUnblockPush),
|
||||||
EventTypeMatcher<BlocklistUnblockAllPushEvent>(_onBlocklistUnblockAllPush),
|
EventTypeMatcher<BlocklistUnblockAllPushEvent>(_onBlocklistUnblockAllPush),
|
||||||
|
EventTypeMatcher<StanzaSendingCancelledEvent>(_onStanzaSendingCancelled),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
final Logger _log;
|
final Logger _log;
|
||||||
final EventHandler _eventHandler;
|
final EventHandler _eventHandler;
|
||||||
final _XmppStateMigrator _migrator;
|
|
||||||
bool _loginTriggeredFromUI;
|
bool _loginTriggeredFromUI;
|
||||||
bool _appOpen;
|
bool _appOpen;
|
||||||
String _currentlyOpenedChatJid;
|
String _currentlyOpenedChatJid;
|
||||||
@ -138,14 +74,14 @@ class XmppService {
|
|||||||
Future<XmppState> getXmppState() async {
|
Future<XmppState> getXmppState() async {
|
||||||
if (_state != null) return _state!;
|
if (_state != null) return _state!;
|
||||||
|
|
||||||
_state = await _migrator.load();
|
_state = await GetIt.I.get<DatabaseService>().getXmppState();
|
||||||
return _state!;
|
return _state!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A wrapper to modify the [XmppState] and commit it.
|
/// A wrapper to modify the [XmppState] and commit it.
|
||||||
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
|
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
|
||||||
_state = func(_state!);
|
_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.
|
/// Stores whether the app is open or not. Useful for notifications.
|
||||||
@ -207,6 +143,7 @@ class XmppService {
|
|||||||
for (final recipient in recipients) {
|
for (final recipient in recipients) {
|
||||||
final sid = conn.generateId();
|
final sid = conn.generateId();
|
||||||
final originId = conn.generateId();
|
final originId = conn.generateId();
|
||||||
|
final conversation = await cs.getConversationByJid(recipient);
|
||||||
final message = await ms.addMessageFromData(
|
final message = await ms.addMessageFromData(
|
||||||
body,
|
body,
|
||||||
timestamp,
|
timestamp,
|
||||||
@ -215,9 +152,17 @@ class XmppService {
|
|||||||
false,
|
false,
|
||||||
sid,
|
sid,
|
||||||
false,
|
false,
|
||||||
|
conversation!.encrypted,
|
||||||
originId: originId,
|
originId: originId,
|
||||||
quoteId: quotedMessage?.sid,
|
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.
|
// Using the same ID should be fine.
|
||||||
sendEvent(
|
sendEvent(
|
||||||
@ -232,42 +177,76 @@ class XmppService {
|
|||||||
requestDeliveryReceipt: true,
|
requestDeliveryReceipt: true,
|
||||||
id: sid,
|
id: sid,
|
||||||
originId: originId,
|
originId: originId,
|
||||||
quoteBody: quotedMessage?.body,
|
quoteBody: createFallbackBodyForQuotedMessage(quotedMessage),
|
||||||
quoteFrom: quotedMessage?.sender,
|
quoteFrom: quotedMessage?.sender,
|
||||||
quoteId: quotedMessage?.sid,
|
quoteId: quotedMessage?.sid,
|
||||||
chatState: chatState,
|
chatState: chatState,
|
||||||
|
shouldEncrypt: newConversation.encrypted,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final conversation = await cs.getConversationByJid(recipient);
|
|
||||||
final newConversation = await cs.updateConversation(
|
|
||||||
conversation!.id,
|
|
||||||
lastMessageBody: body,
|
|
||||||
lastChangeTimestamp: timestamp,
|
|
||||||
);
|
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
ConversationUpdatedEvent(conversation: newConversation),
|
ConversationUpdatedEvent(conversation: newConversation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _getMessageSrcUrl(MessageEvent event) {
|
MediaFileLocation? _getMessageSrcUrl(MessageEvent event) {
|
||||||
if (event.sfs != null) {
|
if (event.sfs != null) {
|
||||||
return event.sfs!.url;
|
final source = firstWhereOrNull(
|
||||||
} else if (event.sims != null) {
|
event.sfs!.sources,
|
||||||
return event.sims!.url;
|
(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) {
|
} 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _acknowledgeMessage(MessageEvent event) async {
|
Future<void> _acknowledgeMessage(MessageEvent event) async {
|
||||||
final info = await GetIt.I.get<XmppConnection>().getDiscoManager().discoInfoQuery(event.fromJid.toString());
|
final result = await GetIt.I.get<XmppConnection>().getDiscoManager().discoInfoQuery(event.fromJid.toString());
|
||||||
if (info == null) return;
|
if (result.isType<DiscoError>()) return;
|
||||||
|
|
||||||
|
final info = result.get<DiscoInfo>();
|
||||||
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
|
if (event.isMarkable && info.features.contains(chatMarkersXmlns)) {
|
||||||
unawaited(
|
unawaited(
|
||||||
GetIt.I.get<XmppConnection>().sendStanza(
|
GetIt.I.get<XmppConnection>().sendStanza(
|
||||||
@ -354,6 +333,10 @@ class XmppService {
|
|||||||
final thumbnails = <String, List<Thumbnail>>{};
|
final thumbnails = <String, List<Thumbnail>>{};
|
||||||
// Path -> Dimensions
|
// Path -> Dimensions
|
||||||
final dimensions = <String, Size>{};
|
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
|
// Create the messages and shared media entries
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
@ -361,13 +344,20 @@ class XmppService {
|
|||||||
final pathMime = lookupMimeType(path);
|
final pathMime = lookupMimeType(path);
|
||||||
|
|
||||||
for (final recipient in recipients) {
|
for (final recipient in recipients) {
|
||||||
|
final conversation = await cs.getConversationByJid(recipient);
|
||||||
|
encrypt[recipient] = conversation?.encrypted ?? prefs.enableOmemoByDefault;
|
||||||
|
|
||||||
// TODO(Unknown): Do the same for videos
|
// TODO(Unknown): Do the same for videos
|
||||||
if (pathMime != null && pathMime.startsWith('image/')) {
|
if (pathMime != null && pathMime.startsWith('image/')) {
|
||||||
|
try {
|
||||||
final imageSize = image_size.ImageSizeGetter.getSize(FileInput(File(path)));
|
final imageSize = image_size.ImageSizeGetter.getSize(FileInput(File(path)));
|
||||||
dimensions[path] = Size(
|
dimensions[path] = Size(
|
||||||
imageSize.width.toDouble(),
|
imageSize.width.toDouble(),
|
||||||
imageSize.height.toDouble(),
|
imageSize.height.toDouble(),
|
||||||
);
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
_log.warning('Failed to get image dimensions for $path');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final msg = await ms.addMessageFromData(
|
final msg = await ms.addMessageFromData(
|
||||||
@ -378,11 +368,14 @@ class XmppService {
|
|||||||
true,
|
true,
|
||||||
conn.generateId(),
|
conn.generateId(),
|
||||||
false,
|
false,
|
||||||
|
encrypt[recipient]!,
|
||||||
mediaUrl: path,
|
mediaUrl: path,
|
||||||
mediaType: pathMime,
|
mediaType: pathMime,
|
||||||
originId: conn.generateId(),
|
originId: conn.generateId(),
|
||||||
mediaWidth: dimensions[path]?.width.toInt(),
|
mediaWidth: dimensions[path]?.width.toInt(),
|
||||||
mediaHeight: dimensions[path]?.height.toInt(),
|
mediaHeight: dimensions[path]?.height.toInt(),
|
||||||
|
filename: pathlib.basename(path),
|
||||||
|
isUploading: true,
|
||||||
);
|
);
|
||||||
if (messages.containsKey(path)) {
|
if (messages.containsKey(path)) {
|
||||||
messages[path]![recipient] = msg;
|
messages[path]![recipient] = msg;
|
||||||
@ -390,7 +383,11 @@ class XmppService {
|
|||||||
messages[path] = { recipient: msg };
|
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
|
// Update conversation
|
||||||
final updatedConversation = await cs.updateConversation(
|
final updatedConversation = await cs.updateConversation(
|
||||||
conversation.id,
|
conversation.id,
|
||||||
lastMessageBody: mimeTypeToConversationBody(lastFileMime),
|
lastMessageBody: mimeTypeToEmoji(lastFileMime),
|
||||||
|
lastMessageId: lastMessageIds[recipient],
|
||||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
open: true,
|
open: true,
|
||||||
);
|
);
|
||||||
@ -427,13 +425,16 @@ class XmppService {
|
|||||||
final newConversation = await cs.addConversationFromData(
|
final newConversation = await cs.addConversationFromData(
|
||||||
// TODO(Unknown): Should we use the JID parser?
|
// TODO(Unknown): Should we use the JID parser?
|
||||||
rosterItem?.title ?? recipient.split('@').first,
|
rosterItem?.title ?? recipient.split('@').first,
|
||||||
mimeTypeToConversationBody(lastFileMime),
|
lastMessageIds[recipient]!,
|
||||||
|
false,
|
||||||
|
mimeTypeToEmoji(lastFileMime),
|
||||||
rosterItem?.avatarUrl ?? '',
|
rosterItem?.avatarUrl ?? '',
|
||||||
recipient,
|
recipient,
|
||||||
0,
|
0,
|
||||||
DateTime.now().millisecondsSinceEpoch,
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
|
prefs.enableOmemoByDefault,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notify the UI
|
// Notify the UI
|
||||||
@ -480,6 +481,7 @@ class XmppService {
|
|||||||
size: File(path).statSync().size,
|
size: File(path).statSync().size,
|
||||||
thumbnails: thumbnails[path] ?? [],
|
thumbnails: thumbnails[path] ?? [],
|
||||||
),
|
),
|
||||||
|
shouldEncrypt: encrypt[recipient]!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -489,6 +491,7 @@ class XmppService {
|
|||||||
recipients,
|
recipients,
|
||||||
path,
|
path,
|
||||||
pathMime,
|
pathMime,
|
||||||
|
encrypt,
|
||||||
messages[path]!,
|
messages[path]!,
|
||||||
thumbnails[path] ?? [],
|
thumbnails[path] ?? [],
|
||||||
),
|
),
|
||||||
@ -498,33 +501,51 @@ class XmppService {
|
|||||||
_log.finest('File upload done');
|
_log.finest('File upload done');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onConnectionStateChanged(ConnectionStateChangedEvent event, { dynamic extra }) async {
|
Future<void> _initializeOmemoService(String jid) async {
|
||||||
switch (event.state) {
|
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:
|
case XmppConnectionState.connected:
|
||||||
GetIt.I.get<BackgroundService>().setNotification(
|
GetIt.I.get<BackgroundService>().setNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
'Ready to receive messages',
|
t.notifications.permanent.ready,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case XmppConnectionState.connecting:
|
case XmppConnectionState.connecting:
|
||||||
GetIt.I.get<BackgroundService>().setNotification(
|
GetIt.I.get<BackgroundService>().setNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
'Connecting...',
|
t.notifications.permanent.connecting,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case XmppConnectionState.notConnected:
|
case XmppConnectionState.notConnected:
|
||||||
GetIt.I.get<BackgroundService>().setNotification(
|
GetIt.I.get<BackgroundService>().setNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
'Disconnected',
|
t.notifications.permanent.disconnect,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case XmppConnectionState.error:
|
case XmppConnectionState.error:
|
||||||
GetIt.I.get<BackgroundService>().setNotification(
|
GetIt.I.get<BackgroundService>().setNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
'Error',
|
t.notifications.permanent.error,
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnectionStateChanged(ConnectionStateChangedEvent event, { dynamic extra }) async {
|
||||||
|
setNotificationText(event.state);
|
||||||
|
|
||||||
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
|
await GetIt.I.get<ConnectivityWatcherService>().onConnectionStateChanged(
|
||||||
event.before, event.state,
|
event.before, event.state,
|
||||||
@ -541,6 +562,8 @@ class XmppService {
|
|||||||
),);
|
),);
|
||||||
|
|
||||||
_log.finest('Connection connected. Is resumed? ${event.resumed}');
|
_log.finest('Connection connected. Is resumed? ${event.resumed}');
|
||||||
|
unawaited(_initializeOmemoService(settings.jid.toString()));
|
||||||
|
|
||||||
if (!event.resumed) {
|
if (!event.resumed) {
|
||||||
// In section 5 of XEP-0198 it says that a client should not request the roster
|
// In section 5 of XEP-0198 it says that a client should not request the roster
|
||||||
// in case of a stream resumption.
|
// in case of a stream resumption.
|
||||||
@ -604,6 +627,8 @@ class XmppService {
|
|||||||
final bare = event.from.toBare();
|
final bare = event.from.toBare();
|
||||||
final conv = await cs.addConversationFromData(
|
final conv = await cs.addConversationFromData(
|
||||||
bare.toString().split('@')[0],
|
bare.toString().split('@')[0],
|
||||||
|
-1,
|
||||||
|
false,
|
||||||
'',
|
'',
|
||||||
'', // TODO(Unknown): avatarUrl
|
'', // TODO(Unknown): avatarUrl
|
||||||
bare.toString(),
|
bare.toString(),
|
||||||
@ -611,6 +636,7 @@ class XmppService {
|
|||||||
timestamp,
|
timestamp,
|
||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
|
prefs.enableOmemoByDefault,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(ConversationAddedEvent(conversation: conv));
|
sendEvent(ConversationAddedEvent(conversation: conv));
|
||||||
@ -672,14 +698,13 @@ class XmppService {
|
|||||||
|
|
||||||
/// Return true if [event] describes a message that we want to display.
|
/// Return true if [event] describes a message that we want to display.
|
||||||
bool _isMessageEventMessage(MessageEvent event) {
|
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.
|
/// Extract the thumbnail data from a message, if existent.
|
||||||
String? _getThumbnailData(MessageEvent event) {
|
String? _getThumbnailData(MessageEvent event) {
|
||||||
final thumbnails = firstNotNull([
|
final thumbnails = firstNotNull([
|
||||||
event.sfs?.metadata.thumbnails,
|
event.sfs?.metadata.thumbnails,
|
||||||
event.sims?.thumbnails,
|
|
||||||
event.fun?.thumbnails,
|
event.fun?.thumbnails,
|
||||||
]) ?? [];
|
]) ?? [];
|
||||||
for (final i in thumbnails) {
|
for (final i in thumbnails) {
|
||||||
@ -695,7 +720,6 @@ class XmppService {
|
|||||||
String? _getMimeGuess(MessageEvent event) {
|
String? _getMimeGuess(MessageEvent event) {
|
||||||
return firstNotNull([
|
return firstNotNull([
|
||||||
event.sfs?.metadata.mediaType,
|
event.sfs?.metadata.mediaType,
|
||||||
event.sims?.mediaType,
|
|
||||||
event.fun?.mediaType,
|
event.fun?.mediaType,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -718,16 +742,68 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if a file is embedded in [event]. If not, returns false.
|
/// 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
|
/// [embeddedFile] is the possible source of the file. If no file is present, then
|
||||||
/// [embeddedFileUrl] is null.
|
/// [embeddedFile] is null.
|
||||||
bool _isFileEmbedded(MessageEvent event, String? embeddedFileUrl) {
|
bool _isFileEmbedded(MessageEvent event, MediaFileLocation? embeddedFile) {
|
||||||
// True if we determine a file to be embedded. Checks if the Url is using HTTPS and
|
// 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.
|
// that the message body and the OOB url are the same if the OOB url is not null.
|
||||||
return embeddedFileUrl != null
|
return embeddedFile != null
|
||||||
&& Uri.parse(embeddedFileUrl).scheme == 'https'
|
&& Uri.parse(embeddedFile.url).scheme == 'https'
|
||||||
&& implies(event.oob != null, event.body == event.oob?.url);
|
&& implies(event.oob != null, event.body == event.oob?.url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 true if a file should be automatically downloaded. If it should not, it
|
||||||
/// returns false.
|
/// returns false.
|
||||||
/// [conversationJid] refers to the JID of the conversation the message was received in.
|
/// [conversationJid] refers to the JID of the conversation the message was received in.
|
||||||
@ -752,8 +828,13 @@ class XmppService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.messageRetraction != null) {
|
||||||
|
await _handleMessageRetraction(event, conversationJid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop the processing here if the event does not describe a displayable message
|
// Stop the processing here if the event does not describe a displayable message
|
||||||
if (!_isMessageEventMessage(event)) return;
|
if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;
|
||||||
|
|
||||||
final state = await getXmppState();
|
final state = await getXmppState();
|
||||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
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.
|
// 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
|
// 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.
|
// 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
|
// Indicates if we should auto-download the file, if a file is specified in the message
|
||||||
final shouldDownload = await _shouldDownloadFile(conversationJid);
|
final shouldDownload = await _shouldDownloadFile(conversationJid);
|
||||||
// The thumbnail for the embedded file.
|
// The thumbnail for the embedded file.
|
||||||
@ -814,19 +895,25 @@ class XmppService {
|
|||||||
isFileEmbedded || event.fun != null,
|
isFileEmbedded || event.fun != null,
|
||||||
event.sid,
|
event.sid,
|
||||||
event.fun != null,
|
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,
|
mediaType: mimeGuess,
|
||||||
thumbnailData: thumbnailData,
|
thumbnailData: thumbnailData,
|
||||||
mediaWidth: dimensions?.width.toInt(),
|
mediaWidth: dimensions?.width.toInt(),
|
||||||
mediaHeight: dimensions?.height.toInt(),
|
mediaHeight: dimensions?.height.toInt(),
|
||||||
quoteId: replyId,
|
quoteId: replyId,
|
||||||
filename: event.fun?.name,
|
originId: event.stanzaId.originId,
|
||||||
|
errorType: errorTypeFromException(event.other['encryption_error']),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Attempt to auto-download the embedded file
|
// Attempt to auto-download the embedded file
|
||||||
if (isFileEmbedded && shouldDownload) {
|
if (isFileEmbedded && shouldDownload) {
|
||||||
final fts = GetIt.I.get<HttpFileTransferService>();
|
final fts = GetIt.I.get<HttpFileTransferService>();
|
||||||
final metadata = await peekFile(embeddedFileUrl!);
|
final metadata = await peekFile(embeddedFile!.url);
|
||||||
|
|
||||||
if (metadata.mime != null) mimeGuess = metadata.mime;
|
if (metadata.mime != null) mimeGuess = metadata.mime;
|
||||||
|
|
||||||
@ -834,10 +921,13 @@ class XmppService {
|
|||||||
// "always download".
|
// "always download".
|
||||||
if (prefs.maximumAutoDownloadSize == -1
|
if (prefs.maximumAutoDownloadSize == -1
|
||||||
|| (metadata.size != null && metadata.size! < prefs.maximumAutoDownloadSize * 1000000)) {
|
|| (metadata.size != null && metadata.size! < prefs.maximumAutoDownloadSize * 1000000)) {
|
||||||
message = message.copyWith(isDownloading: true);
|
message = await ms.updateMessage(
|
||||||
|
message.id,
|
||||||
|
isDownloading: true,
|
||||||
|
);
|
||||||
await fts.downloadFile(
|
await fts.downloadFile(
|
||||||
FileDownloadJob(
|
FileDownloadJob(
|
||||||
embeddedFileUrl,
|
embeddedFile,
|
||||||
message.id,
|
message.id,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
mimeGuess,
|
mimeGuess,
|
||||||
@ -852,7 +942,7 @@ class XmppService {
|
|||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final ns = GetIt.I.get<NotificationsService>();
|
final ns = GetIt.I.get<NotificationsService>();
|
||||||
// The body to be displayed in the conversations list
|
// The body to be displayed in the conversations list
|
||||||
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToConversationBody(mimeGuess) : messageBody;
|
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
|
||||||
// Specifies if we have the conversation this message goes to opened
|
// Specifies if we have the conversation this message goes to opened
|
||||||
final isConversationOpened = _currentlyOpenedChatJid == conversationJid;
|
final isConversationOpened = _currentlyOpenedChatJid == conversationJid;
|
||||||
// The conversation we're about to modify, if it exists
|
// The conversation we're about to modify, if it exists
|
||||||
@ -867,6 +957,8 @@ class XmppService {
|
|||||||
conversation.id,
|
conversation.id,
|
||||||
lastMessageBody: conversationBody,
|
lastMessageBody: conversationBody,
|
||||||
lastChangeTimestamp: messageTimestamp,
|
lastChangeTimestamp: messageTimestamp,
|
||||||
|
lastMessageId: message.id,
|
||||||
|
lastMessageRetracted: false,
|
||||||
// Do not increment the counter for messages we sent ourselves (via Carbons)
|
// Do not increment the counter for messages we sent ourselves (via Carbons)
|
||||||
// or if we have the chat currently opened
|
// or if we have the chat currently opened
|
||||||
unreadCounter: isConversationOpened || sent
|
unreadCounter: isConversationOpened || sent
|
||||||
@ -890,6 +982,8 @@ class XmppService {
|
|||||||
// The conversation does not exist, so we must create it
|
// The conversation does not exist, so we must create it
|
||||||
final newConversation = await cs.addConversationFromData(
|
final newConversation = await cs.addConversationFromData(
|
||||||
rosterItem?.title ?? conversationJid.split('@')[0],
|
rosterItem?.title ?? conversationJid.split('@')[0],
|
||||||
|
message.id,
|
||||||
|
false,
|
||||||
conversationBody,
|
conversationBody,
|
||||||
rosterItem?.avatarUrl ?? '',
|
rosterItem?.avatarUrl ?? '',
|
||||||
conversationJid,
|
conversationJid,
|
||||||
@ -897,6 +991,7 @@ class XmppService {
|
|||||||
messageTimestamp,
|
messageTimestamp,
|
||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
|
message.encrypted,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notify the UI
|
// Notify the UI
|
||||||
@ -913,14 +1008,14 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify the UI of the message
|
// Notify the UI of the message
|
||||||
sendEvent(
|
if (message.isDownloading != (event.fun != null)) {
|
||||||
MessageAddedEvent(
|
message = await ms.updateMessage(
|
||||||
message: message.copyWith(
|
message.id,
|
||||||
isDownloading: event.fun != null,
|
isDownloading: event.fun != null,
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
sendEvent(MessageAddedEvent(message: message));
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleFileUploadNotificationReplacement(MessageEvent event, String conversationJid) async {
|
Future<void> _handleFileUploadNotificationReplacement(MessageEvent event, String conversationJid) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
@ -945,31 +1040,36 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The Url of the file embedded in the message, if there is one.
|
// 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?
|
// Is there even a file we can download?
|
||||||
final isFileEmbedded = _isFileEmbedded(event, embeddedFileUrl);
|
final isFileEmbedded = _isFileEmbedded(event, embeddedFile);
|
||||||
|
|
||||||
if (isFileEmbedded) {
|
if (isFileEmbedded) {
|
||||||
if (await _shouldDownloadFile(conversationJid)) {
|
final shouldDownload = await _shouldDownloadFile(conversationJid);
|
||||||
message = message.copyWith(isDownloading: true);
|
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(
|
await GetIt.I.get<HttpFileTransferService>().downloadFile(
|
||||||
FileDownloadJob(
|
FileDownloadJob(
|
||||||
embeddedFileUrl!,
|
embeddedFile,
|
||||||
message.id,
|
message.id,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
null,
|
null,
|
||||||
shouldShowNotification: false,
|
shouldShowNotification: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
message = await ms.updateMessage(
|
|
||||||
message.id,
|
|
||||||
srcUrl: embeddedFileUrl,
|
|
||||||
isFileUploadNotification: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tell the UI
|
|
||||||
sendEvent(MessageUpdatedEvent(message: message.copyWith(isDownloading: false)));
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.warning('Received a File Upload Notification replacement but the replacement contains no file!');
|
_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 {
|
Future<void> _onBlocklistUnblockAllPush(BlocklistUnblockAllPushEvent event, { dynamic extra }) async {
|
||||||
GetIt.I.get<BlocklistService>().onUnblockAllPush();
|
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 noError = 0;
|
||||||
const fileUploadFailedError = 1;
|
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".
|
/// Matches an event according to if the event "is T".
|
||||||
class EventTypeMatcher<T> extends EventMatcher<T> {
|
class EventTypeMatcher<T> extends EventMatcher<T> {
|
||||||
EventTypeMatcher(EventCallbackType<T> callback) : super(callback);
|
EventTypeMatcher(super.callback);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool matches(dynamic event) => event is T;
|
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
|
/// A simple system for registering event handlers. Those handlers are checked whenever
|
||||||
/// [run] is called.
|
/// [run] is called.
|
||||||
class EventHandler {
|
class EventHandler {
|
||||||
|
|
||||||
EventHandler() : _matchers = List.empty(growable: true);
|
EventHandler() : _matchers = List.empty(growable: true);
|
||||||
final List<EventMatcher<dynamic>> _matchers;
|
final List<EventMatcher<dynamic>> _matchers;
|
||||||
|
|
||||||
|
@ -2,6 +2,7 @@ import 'package:moxlib/awaitabledatasender.dart';
|
|||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'dart:core';
|
import 'dart:core';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
/// Add a leading zero, if required, to ensure that an integer is rendered
|
/// Add a leading zero, if required, to ensure that an integer is rendered
|
||||||
@ -15,23 +15,6 @@ String padInt(int i) {
|
|||||||
return i.toString();
|
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.
|
/// Format the timestamp of a conversation change into a nice string.
|
||||||
/// timestamp and now are both in millisecondsSinceEpoch.
|
/// timestamp and now are both in millisecondsSinceEpoch.
|
||||||
/// Ensures that now >= timestamp
|
/// Ensures that now >= timestamp
|
||||||
@ -218,19 +201,19 @@ String? guessMimeTypeFromExtension(String ext) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a combinatio of an emoji and its file type
|
/// Return an emoji for the MIME type [mime]. If [addTypeName] id true, then a human readable
|
||||||
String mimeTypeToConversationBody(String? mime) {
|
/// name for the MIME type will be appended.
|
||||||
|
String mimeTypeToEmoji(String? mime, {bool addTypeName = true}) {
|
||||||
if (mime != null) {
|
if (mime != null) {
|
||||||
if (mime.startsWith('image/')) {
|
if (mime.startsWith('image')) {
|
||||||
return '📷 Image';
|
return '🖼️${addTypeName ? " ${t.messages.image}" : ""}';
|
||||||
} else if (mime.startsWith('video/')) {
|
} else if (mime.startsWith('audio')) {
|
||||||
return '🎞️ Video';
|
return '🎙${addTypeName ? " ${t.messages.audio}" : ""}';
|
||||||
} else if (mime.startsWith('audio/')) {
|
} else if (mime.startsWith('video')) {
|
||||||
return '🎵 Audio';
|
return '🎬${addTypeName ? " ${t.messages.video}" : ""}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return '📁${addTypeName ? " ${t.messages.file}" : ""}';
|
||||||
return '📁 File';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse an Uri and return the "filename".
|
/// Parse an Uri and return the "filename".
|
||||||
@ -238,31 +221,16 @@ String filenameFromUrl(String url) {
|
|||||||
return Uri.parse(url).pathSegments.last;
|
return Uri.parse(url).pathSegments.last;
|
||||||
}
|
}
|
||||||
|
|
||||||
ChatState chatStateFromString(String raw) {
|
/// Attempts to escape [filename] such that it cannot be expanded into another path, i.e.
|
||||||
switch(raw) {
|
/// make "../" not dangerous.
|
||||||
case 'active': {
|
String escapeFilename(String filename) {
|
||||||
return ChatState.active;
|
return filename
|
||||||
}
|
.replaceAll('/', '%2F')
|
||||||
case 'composing': {
|
// ignore: use_raw_strings
|
||||||
return ChatState.composing;
|
.replaceAll('\\', '%5C')
|
||||||
}
|
.replaceAll('../', '..%2F');
|
||||||
case 'paused': {
|
|
||||||
return ChatState.paused;
|
|
||||||
}
|
|
||||||
case 'inactive': {
|
|
||||||
return ChatState.inactive;
|
|
||||||
}
|
|
||||||
case 'gone': {
|
|
||||||
return ChatState.gone;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return ChatState.gone;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String chatStateToString(ChatState state) => state.toString().split('.').last;
|
|
||||||
|
|
||||||
/// Return a version of the filename [filename] with [suffix] attached to the file's
|
/// Return a version of the filename [filename] with [suffix] attached to the file's
|
||||||
/// name while keeping the extension in [filename] intact.
|
/// name while keeping the extension in [filename] intact.
|
||||||
String filenameWithSuffix(String filename, String suffix) {
|
String filenameWithSuffix(String filename, String suffix) {
|
||||||
@ -309,3 +277,16 @@ bool isSent(Message message, String jid) {
|
|||||||
// TODO(PapaTutuWawa): Does this work?
|
// TODO(PapaTutuWawa): Does this work?
|
||||||
return message.sender.split('/').first == jid.split('/').first;
|
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:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
|
||||||
|
|
||||||
part 'conversation.freezed.dart';
|
part 'conversation.freezed.dart';
|
||||||
part 'conversation.g.dart';
|
part 'conversation.g.dart';
|
||||||
@ -23,6 +22,9 @@ class ConversationChatStateConverter implements JsonConverter<ChatState, Map<Str
|
|||||||
class Conversation with _$Conversation {
|
class Conversation with _$Conversation {
|
||||||
factory Conversation(
|
factory Conversation(
|
||||||
String title,
|
String title,
|
||||||
|
// NOTE: The internal database Id of the message
|
||||||
|
int lastMessageId,
|
||||||
|
bool lastMessageRetracted,
|
||||||
String lastMessageBody,
|
String lastMessageBody,
|
||||||
String avatarUrl,
|
String avatarUrl,
|
||||||
String jid,
|
String jid,
|
||||||
@ -39,6 +41,8 @@ class Conversation with _$Conversation {
|
|||||||
String subscription,
|
String subscription,
|
||||||
// Whether the chat is muted (true = muted, false = not muted)
|
// Whether the chat is muted (true = muted, false = not muted)
|
||||||
bool muted,
|
bool muted,
|
||||||
|
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
|
||||||
|
bool encrypted,
|
||||||
// The current chat state
|
// The current chat state
|
||||||
@ConversationChatStateConverter() ChatState chatState,
|
@ConversationChatStateConverter() ChatState chatState,
|
||||||
) = _Conversation;
|
) = _Conversation;
|
||||||
@ -56,7 +60,9 @@ class Conversation with _$Conversation {
|
|||||||
'sharedMedia': sharedMedia,
|
'sharedMedia': sharedMedia,
|
||||||
'inRoster': inRoster,
|
'inRoster': inRoster,
|
||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
|
'encrypted': intToBool(json['encrypted']! as int),
|
||||||
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
|
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||||
|
'lastMessageRetracted': intToBool(json['lastMessageRetracted']! as int)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +78,8 @@ class Conversation with _$Conversation {
|
|||||||
...map,
|
...map,
|
||||||
'open': boolToInt(open),
|
'open': boolToInt(open),
|
||||||
'muted': boolToInt(muted),
|
'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:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
|
|
||||||
part 'message.freezed.dart';
|
part 'message.freezed.dart';
|
||||||
part 'message.g.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
|
@freezed
|
||||||
class Message with _$Message {
|
class Message with _$Message {
|
||||||
// NOTE: id is the database id of the message
|
// NOTE: id is the database id of the message
|
||||||
@ -19,8 +35,10 @@ class Message with _$Message {
|
|||||||
String conversationJid,
|
String conversationJid,
|
||||||
bool isMedia,
|
bool isMedia,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
|
bool encrypted,
|
||||||
{
|
{
|
||||||
int? errorType,
|
int? errorType,
|
||||||
|
int? warningType,
|
||||||
String? mediaUrl,
|
String? mediaUrl,
|
||||||
@Default(false) bool isDownloading,
|
@Default(false) bool isDownloading,
|
||||||
@Default(false) bool isUploading,
|
@Default(false) bool isUploading,
|
||||||
@ -29,12 +47,19 @@ class Message with _$Message {
|
|||||||
int? mediaWidth,
|
int? mediaWidth,
|
||||||
int? mediaHeight,
|
int? mediaHeight,
|
||||||
String? srcUrl,
|
String? srcUrl,
|
||||||
|
String? key,
|
||||||
|
String? iv,
|
||||||
|
String? encryptionScheme,
|
||||||
@Default(false) bool received,
|
@Default(false) bool received,
|
||||||
@Default(false) bool displayed,
|
@Default(false) bool displayed,
|
||||||
@Default(false) bool acked,
|
@Default(false) bool acked,
|
||||||
|
@Default(false) bool isRetracted,
|
||||||
String? originId,
|
String? originId,
|
||||||
Message? quotes,
|
Message? quotes,
|
||||||
String? filename,
|
String? filename,
|
||||||
|
Map<String, String>? plaintextHashes,
|
||||||
|
Map<String, String>? ciphertextHashes,
|
||||||
|
int? mediaSize,
|
||||||
}
|
}
|
||||||
) = _Message;
|
) = _Message;
|
||||||
|
|
||||||
@ -51,15 +76,19 @@ class Message with _$Message {
|
|||||||
'acked': intToBool(json['acked']! as int),
|
'acked': intToBool(json['acked']! as int),
|
||||||
'isMedia': intToBool(json['isMedia']! as int),
|
'isMedia': intToBool(json['isMedia']! as int),
|
||||||
'isFileUploadNotification': intToBool(json['isFileUploadNotification']! 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);
|
}).copyWith(quotes: quotes);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toDatabaseJson(int? quoteId) {
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
final map = toJson()
|
final map = toJson()
|
||||||
..remove('id')
|
..remove('id')
|
||||||
..remove('quotes')
|
..remove('quotes');
|
||||||
..remove('isDownloading')
|
|
||||||
..remove('isUploading');
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...map,
|
...map,
|
||||||
@ -68,7 +97,49 @@ class Message with _$Message {
|
|||||||
'received': boolToInt(received),
|
'received': boolToInt(received),
|
||||||
'displayed': boolToInt(displayed),
|
'displayed': boolToInt(displayed),
|
||||||
'acked': boolToInt(acked),
|
'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,8 +3,6 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
part 'preferences.freezed.dart';
|
part 'preferences.freezed.dart';
|
||||||
part 'preferences.g.dart';
|
part 'preferences.g.dart';
|
||||||
|
|
||||||
// TODO(Unknown): Move into the models directory
|
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class PreferencesState with _$PreferencesState {
|
class PreferencesState with _$PreferencesState {
|
||||||
factory PreferencesState({
|
factory PreferencesState({
|
||||||
@ -26,6 +24,10 @@ class PreferencesState with _$PreferencesState {
|
|||||||
@Default(false) bool enableTwitterRedirect,
|
@Default(false) bool enableTwitterRedirect,
|
||||||
@Default(false) bool enableYoutubeRedirect,
|
@Default(false) bool enableYoutubeRedirect,
|
||||||
@Default(false) bool defaultMuteState,
|
@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;
|
}) = _PreferencesState;
|
||||||
|
|
||||||
// JSON serialization
|
// 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:flutter/widgets.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart' as events;
|
import 'package:moxxyv2/shared/events.dart' as events;
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.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/sendfiles_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
|
||||||
|
|
||||||
part 'conversation_bloc.freezed.dart';
|
part 'conversation_bloc.freezed.dart';
|
||||||
part 'conversation_event.dart';
|
part 'conversation_event.dart';
|
||||||
@ -45,6 +45,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||||
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
|
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
|
||||||
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
||||||
|
on<OmemoSetEvent>(_onOmemoSet);
|
||||||
|
on<MessageRetractedEvent>(_onMessageRetracted);
|
||||||
}
|
}
|
||||||
/// The current chat state with the conversation partner
|
/// The current chat state with the conversation partner
|
||||||
ChatState _currentChatState;
|
ChatState _currentChatState;
|
||||||
@ -326,4 +328,29 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
Future<void> _onOwnJidReceived(OwnJidReceivedEvent event, Emitter<ConversationState> emit) async {
|
Future<void> _onOwnJidReceived(OwnJidReceivedEvent event, Emitter<ConversationState> emit) async {
|
||||||
emit(state.copyWith(jid: event.jid));
|
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);
|
OwnJidReceivedEvent(this.jid);
|
||||||
final String 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;
|
||||||
|
}
|
||||||
|
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/commands.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
@ -73,16 +72,17 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result is LoginSuccessfulEvent) {
|
if (result is LoginSuccessfulEvent) {
|
||||||
GetIt.I.get<UIDataService>().isLoggedIn = true;
|
|
||||||
emit(state.copyWith(working: false));
|
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(
|
GetIt.I.get<ConversationsBloc>().add(
|
||||||
ConversationsInitEvent(
|
ConversationsInitEvent(
|
||||||
result.displayName,
|
result.preStart.displayName!,
|
||||||
state.jid,
|
state.jid,
|
||||||
// TODO(Unknown): ???
|
result.preStart.conversations!,
|
||||||
<Conversation>[],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
GetIt.I.get<NavigationBloc>().add(
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
@ -98,7 +98,10 @@ class LoginBloc extends Bloc<LoginEvent, LoginState> {
|
|||||||
return emit(
|
return emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
working: false,
|
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:bloc/bloc.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/events.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/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
|
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:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:get_it/get_it.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);
|
emit(event.preferences);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,16 +3,16 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:move_to_background/move_to_background.dart';
|
import 'package:move_to_background/move_to_background.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/shared/commands.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/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/newconversation_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/bloc/sendfiles_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
|
||||||
|
|
||||||
part 'share_selection_bloc.freezed.dart';
|
part 'share_selection_bloc.freezed.dart';
|
||||||
part 'share_selection_event.dart';
|
part 'share_selection_event.dart';
|
||||||
@ -26,11 +26,12 @@ enum ShareSelectionType {
|
|||||||
|
|
||||||
/// Create a common ground between Conversations and RosterItems
|
/// Create a common ground between Conversations and RosterItems
|
||||||
class ShareListItem {
|
class ShareListItem {
|
||||||
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation);
|
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation, this.isEncrypted);
|
||||||
final String avatarPath;
|
final String avatarPath;
|
||||||
final String jid;
|
final String jid;
|
||||||
final String title;
|
final String title;
|
||||||
final bool isConversation;
|
final bool isConversation;
|
||||||
|
final bool isEncrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
|
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
|
||||||
@ -65,6 +66,7 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
|||||||
c.jid,
|
c.jid,
|
||||||
c.title,
|
c.title,
|
||||||
true,
|
true,
|
||||||
|
c.encrypted,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -80,6 +82,7 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
|||||||
rosterItem.jid,
|
rosterItem.jid,
|
||||||
rosterItem.title,
|
rosterItem.title,
|
||||||
false,
|
false,
|
||||||
|
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@ -88,6 +91,7 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
|||||||
rosterItem.jid,
|
rosterItem.jid,
|
||||||
rosterItem.title,
|
rosterItem.title,
|
||||||
false,
|
false,
|
||||||
|
items[index].isEncrypted,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,12 +9,19 @@ const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(top: 4, botto
|
|||||||
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
|
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
|
||||||
|
|
||||||
const int primaryColorHexRGBO = 0xffcf4aff;
|
const int primaryColorHexRGBO = 0xffcf4aff;
|
||||||
|
const int primaryColorAltHexRGB = 0xff9c18cd;
|
||||||
|
const int primaryColorDisabledHexRGB = 0xff9a7fa9;
|
||||||
|
const int textColorDisabledHexRGB = 0xffcacaca;
|
||||||
const Color primaryColor = Color(primaryColorHexRGBO);
|
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 bubbleColorSentQuoted = bubbleColorSent;
|
||||||
const Color bubbleColorReceived = Color(0xff222222);
|
const Color bubbleColorReceived = Color(0xff222222);
|
||||||
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
|
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
|
||||||
|
const Color bubbleColorUnencrypted = Color(0xffd40000);
|
||||||
|
|
||||||
const double paddingVeryLarge = 64;
|
const double paddingVeryLarge = 64;
|
||||||
|
|
||||||
@ -53,6 +60,9 @@ const String privacyRoute = '$settingsRoute/privacy';
|
|||||||
const String networkRoute = '$settingsRoute/network';
|
const String networkRoute = '$settingsRoute/network';
|
||||||
const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
|
const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
|
||||||
const String conversationSettingsRoute = '$settingsRoute/conversation';
|
const String conversationSettingsRoute = '$settingsRoute/conversation';
|
||||||
|
const String appearanceRoute = '$settingsRoute/appearance';
|
||||||
const String blocklistRoute = '/blocklist';
|
const String blocklistRoute = '/blocklist';
|
||||||
const String shareSelectionRoute = '/share_selection';
|
const String shareSelectionRoute = '/share_selection';
|
||||||
const String serverInfoRoute = '$profileRoute/server_info';
|
const String serverInfoRoute = '$profileRoute/server_info';
|
||||||
|
const String devicesRoute = '$profileRoute/devices';
|
||||||
|
const String ownDevicesRoute = '$profileRoute/own_devices';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/awaitabledatasender.dart';
|
import 'package:moxlib/awaitabledatasender.dart';
|
||||||
@ -29,7 +29,7 @@ void setupEventHandler() {
|
|||||||
EventTypeMatcher<ProgressEvent>(onProgress),
|
EventTypeMatcher<ProgressEvent>(onProgress),
|
||||||
EventTypeMatcher<SelfAvatarChangedEvent>(onSelfAvatarChanged),
|
EventTypeMatcher<SelfAvatarChangedEvent>(onSelfAvatarChanged),
|
||||||
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
|
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
|
||||||
EventTypeMatcher<ServiceReadyEvent>(onServiceReady)
|
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@ -139,7 +139,9 @@ Future<void> onServiceReady(ServiceReadyEvent event, { dynamic extra }) async {
|
|||||||
await GetIt.I.get<Completer<void>>().future;
|
await GetIt.I.get<Completer<void>>().future;
|
||||||
GetIt.I.get<Logger>().fine('onServiceReady: Done');
|
GetIt.I.get<Logger>().fine('onServiceReady: Done');
|
||||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
PerformPreStartCommand(),
|
PerformPreStartCommand(
|
||||||
|
systemLocaleCode: WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
|
||||||
|
),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,37 +1,43 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/avatar.dart';
|
import 'package:moxxyv2/shared/avatar.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
|
||||||
/// Shows a dialog asking the user if they are sure that they want to proceed with an
|
/// Shows a dialog asking the user if they are sure that they want to proceed with an
|
||||||
/// action.
|
/// action. Resolves to true if the user pressed the confirm button. Returns false if
|
||||||
Future<void> showConfirmationDialog(String title, String body, BuildContext context, void Function() callback) async {
|
/// the cancel button was pressed.
|
||||||
await showDialog<dynamic>(
|
Future<bool> showConfirmationDialog(String title, String body, BuildContext context) async {
|
||||||
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
||||||
|
),
|
||||||
content: Text(body),
|
content: Text(body),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: callback,
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: const Text('Yes'),
|
child: Text(t.global.yes),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: Navigator.of(context).pop,
|
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.
|
/// 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) {
|
builder: (BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Not Implemented'),
|
title: const Text('Not Implemented'),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
||||||
|
),
|
||||||
content: SingleChildScrollView(
|
content: SingleChildScrollView(
|
||||||
child: ListBody(
|
child: ListBody(
|
||||||
children: [
|
children: [
|
||||||
@ -51,7 +60,7 @@ Future<void> showNotImplementedDialog(String feature, BuildContext context) asyn
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Okay'),
|
child: Text(t.global.dialogAccept),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@ -67,11 +76,14 @@ Future<void> showInfoDialog(String title, String body, BuildContext context) asy
|
|||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(textfieldRadiusRegular),
|
||||||
|
),
|
||||||
content: Text(body),
|
content: Text(body),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: Navigator.of(context).pop,
|
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;
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/bloc/addcontact_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.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';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
|
|
||||||
class AddContactPage extends StatelessWidget {
|
class AddContactPage extends StatelessWidget {
|
||||||
const AddContactPage({ Key? key }) : super(key: key);
|
const AddContactPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const AddContactPage(),
|
builder: (_) => const AddContactPage(),
|
||||||
@ -21,7 +22,7 @@ class AddContactPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<AddContactBloc, AddContactState>(
|
return BlocBuilder<AddContactBloc, AddContactState>(
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Add new contact'),
|
appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
Visibility(
|
||||||
@ -32,7 +33,7 @@ class AddContactPage extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
|
||||||
child: CustomTextField(
|
child: CustomTextField(
|
||||||
labelText: 'XMPP-Address',
|
labelText: t.pages.addcontact.xmppAddress,
|
||||||
onChanged: (value) => context.read<AddContactBloc>().add(
|
onChanged: (value) => context.read<AddContactBloc>().add(
|
||||||
JidChangedEvent(value),
|
JidChangedEvent(value),
|
||||||
),
|
),
|
||||||
@ -52,9 +53,7 @@ class AddContactPage extends StatelessWidget {
|
|||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
|
||||||
child: const Text(
|
child: Text(t.pages.addcontact.subtitle),
|
||||||
'You can add a contact either by typing in their XMPP address or by scanning their QR code',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
@ -63,10 +62,10 @@ class AddContactPage extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RoundedButton(
|
child: RoundedButton(
|
||||||
color: Colors.purple,
|
|
||||||
cornerRadius: 32,
|
cornerRadius: 32,
|
||||||
onTap: () => context.read<AddContactBloc>().add(AddedContactEvent()),
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/bloc/blocklist_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
@ -10,7 +11,7 @@ enum BlocklistOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class BlocklistPage extends StatelessWidget {
|
class BlocklistPage extends StatelessWidget {
|
||||||
const BlocklistPage({ Key? key }) : super(key: key);
|
const BlocklistPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const BlocklistPage(),
|
builder: (_) => const BlocklistPage(),
|
||||||
@ -30,9 +31,9 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Image.asset('assets/images/happy_news.png'),
|
child: Image.asset('assets/images/happy_news.png'),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text('You have no users blocked'),
|
child: Text(t.pages.blocklist.noUsersBlocked),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -58,16 +59,19 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.delete),
|
icon: const Icon(Icons.delete),
|
||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
onPressed: () => showConfirmationDialog(
|
onPressed: () async {
|
||||||
'Unblock $jid?',
|
final result = await showConfirmationDialog(
|
||||||
'Are you sure you want to unblock $jid? You will receive messages from this user again.',
|
t.pages.blocklist.unblockJidConfirmTitle(jid: jid),
|
||||||
|
t.pages.blocklist.unblockJidConfirmBody(jid: jid),
|
||||||
context,
|
context,
|
||||||
() {
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
context.read<BlocklistBloc>().add(UnblockedJidEvent(jid));
|
context.read<BlocklistBloc>().add(UnblockedJidEvent(jid));
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -80,29 +84,33 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
return BlocBuilder<BlocklistBloc, BlocklistState>(
|
return BlocBuilder<BlocklistBloc, BlocklistState>(
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(
|
appBar: BorderlessTopbar.simple(
|
||||||
'Blocklist',
|
t.pages.blocklist.title,
|
||||||
extra: [
|
extra: [
|
||||||
Expanded(child: Container()),
|
Expanded(child: Container()),
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
onSelected: (BlocklistOptions result) {
|
onSelected: (BlocklistOptions result) async {
|
||||||
if (result == BlocklistOptions.unblockAll) {
|
if (result == BlocklistOptions.unblockAll) {
|
||||||
showConfirmationDialog(
|
final result = await showConfirmationDialog(
|
||||||
'Are you sure?',
|
t.pages.blocklist.unblockAllConfirmTitle,
|
||||||
'Are you sure you want to unblock all users?',
|
t.pages.blocklist.unblockAllConfirmBody,
|
||||||
context,
|
context,
|
||||||
() {
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
context.read<BlocklistBloc>().add(UnblockedAllEvent());
|
context.read<BlocklistBloc>().add(UnblockedAllEvent());
|
||||||
|
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (BuildContext context) => [
|
itemBuilder: (BuildContext context) => [
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: BlocklistOptions.unblockAll,
|
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';
|
import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||||
|
|
||||||
class ConversationBottomRow extends StatelessWidget {
|
class ConversationBottomRow extends StatelessWidget {
|
||||||
|
const ConversationBottomRow(this.controller, this.isSpeedDialOpen, { super.key });
|
||||||
const ConversationBottomRow(this.controller, this.isSpeedDialOpen, {Key? key}) : super(key: key);
|
|
||||||
final TextEditingController controller;
|
final TextEditingController controller;
|
||||||
final ValueNotifier<bool> isSpeedDialOpen;
|
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/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
|
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
|
||||||
|
|
||||||
class ConversationPage extends StatefulWidget {
|
class ConversationPage extends StatefulWidget {
|
||||||
const ConversationPage({ Key? key }) : super(key: key);
|
const ConversationPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (context) => const ConversationPage(),
|
builder: (context) => const ConversationPage(),
|
||||||
@ -27,7 +26,6 @@ class ConversationPage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
|
class ConversationPageState extends State<ConversationPage> with TickerProviderStateMixin {
|
||||||
|
|
||||||
ConversationPageState() :
|
ConversationPageState() :
|
||||||
_isSpeedDialOpen = ValueNotifier(false),
|
_isSpeedDialOpen = ValueNotifier(false),
|
||||||
_controller = TextEditingController(),
|
_controller = TextEditingController(),
|
||||||
@ -84,6 +82,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
return ChatBubble(
|
return ChatBubble(
|
||||||
message: item,
|
message: item,
|
||||||
sentBySelf: isSent(item, jid),
|
sentBySelf: isSent(item, jid),
|
||||||
|
chatEncrypted: state.conversation!.encrypted,
|
||||||
start: start,
|
start: start,
|
||||||
end: end,
|
end: end,
|
||||||
between: between,
|
between: between,
|
||||||
@ -106,21 +105,25 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
child: const Text('Add to contacts'),
|
child: const Text('Add to contacts'),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
final jid = state.conversation!.jid;
|
final jid = state.conversation!.jid;
|
||||||
showConfirmationDialog(
|
final result = await showConfirmationDialog(
|
||||||
'Add $jid to your contacts?',
|
'Add $jid to your contacts?',
|
||||||
'Are you sure you want to add $jid to your conacts?',
|
'Are you sure you want to add $jid to your conacts?',
|
||||||
context,
|
context,
|
||||||
() {
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
// TODO(Unknown): Maybe show a progress indicator
|
// TODO(Unknown): Maybe show a progress indicator
|
||||||
// TODO(Unknown): Have the page update its state once the addition is done
|
// TODO(Unknown): Have the page update its state once the addition is done
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
context.read<ConversationBloc>().add(
|
context.read<ConversationBloc>().add(
|
||||||
JidAddedEvent(jid),
|
JidAddedEvent(jid),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -213,7 +216,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
// TODO(Unknown): Maybe replace the scaffold itself to prevent transparency
|
// TODO(Unknown): Maybe replace the scaffold itself to prevent transparency
|
||||||
backgroundColor: const Color.fromRGBO(0, 0, 0, 0),
|
backgroundColor: const Color.fromRGBO(0, 0, 0, 0),
|
||||||
appBar: const BorderlessTopbar(ConversationTopbarWidget()),
|
appBar: const ConversationTopbar(),
|
||||||
body: Column(
|
body: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -229,7 +232,7 @@ class ConversationPageState extends State<ConversationPage> with TickerProviderS
|
|||||||
BlocBuilder<ConversationBloc, ConversationState>(
|
BlocBuilder<ConversationBloc, ConversationState>(
|
||||||
// NOTE: We don't need to update when the jid changes as it should
|
// NOTE: We don't need to update when the jid changes as it should
|
||||||
// be static over the entire lifetime of the BLoC.
|
// 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(
|
builder: (context, state) => Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
reverse: true,
|
reverse: true,
|
||||||
|
@ -4,14 +4,18 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
|||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
|
|
||||||
/// Sends a block command to the service to block [jid].
|
/// Sends a block command to the service to block [jid].
|
||||||
void blockJid(String jid, BuildContext context) {
|
Future<void> blockJid(String jid, BuildContext context) async {
|
||||||
showConfirmationDialog(
|
final result = await showConfirmationDialog(
|
||||||
'Block $jid?',
|
'Block $jid?',
|
||||||
"Are you sure you want to block $jid? You won't receive messages from them until you unblock them.",
|
"Are you sure you want to block $jid? You won't receive messages from them until you unblock them.",
|
||||||
context,
|
context,
|
||||||
() {
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
context.read<ConversationBloc>().add(JidBlockedEvent(jid));
|
context.read<ConversationBloc>().add(JidBlockedEvent(jid));
|
||||||
|
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:get_it/get_it.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/conversation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
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/avatar.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
|
import 'package:moxxyv2/ui/widgets/chat/typing.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
|
||||||
|
|
||||||
enum ConversationOption {
|
enum ConversationOption {
|
||||||
close,
|
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
|
/// A custom version of the BorderlessTopbar to display the conversation topbar
|
||||||
/// bloc.
|
/// as it should
|
||||||
// TODO(Unknown): If the display name is too long, then it will cause an overflow.
|
// TODO(PapaTutuWawa): The conversation title may overflow the Topbar
|
||||||
class ConversationTopbarWidget extends StatelessWidget {
|
// TODO(Unknown): Maybe merge with BorderlessTopbar
|
||||||
const ConversationTopbarWidget({ Key? key }) : super(key: key);
|
class ConversationTopbar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
const ConversationTopbar({ super.key });
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(60);
|
||||||
|
|
||||||
bool _shouldRebuild(ConversationState prev, ConversationState next) {
|
bool _shouldRebuild(ConversationState prev, ConversationState next) {
|
||||||
return prev.conversation?.title != next.conversation?.title
|
return prev.conversation?.title != next.conversation?.title
|
||||||
|| prev.conversation?.avatarUrl != next.conversation?.avatarUrl
|
|| prev.conversation?.avatarUrl != next.conversation?.avatarUrl
|
||||||
|| prev.conversation?.chatState != next.conversation?.chatState
|
|| prev.conversation?.chatState != next.conversation?.chatState
|
||||||
|| prev.conversation?.jid != next.conversation?.jid;
|
|| prev.conversation?.jid != next.conversation?.jid
|
||||||
|
|| prev.conversation?.encrypted != next.conversation?.encrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChatState(ChatState state) {
|
Widget _buildChatState(ChatState state) {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ChatState.paused:
|
case ChatState.paused:
|
||||||
case ChatState.active:
|
case ChatState.active:
|
||||||
return const Text(
|
return Text(
|
||||||
'Online',
|
t.pages.conversation.online,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.green,
|
color: Colors.green,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -68,20 +74,26 @@ class ConversationTopbarWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isChatStateVisible(ChatState state) {
|
||||||
|
return state != ChatState.inactive && state != ChatState.gone;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<ConversationBloc, ConversationState>(
|
return BlocBuilder<ConversationBloc, ConversationState>(
|
||||||
buildWhen: _shouldRebuild,
|
buildWhen: _shouldRebuild,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return TopbarAvatarAndName(
|
return SizedBox(
|
||||||
IntrinsicHeight(
|
width: MediaQuery.of(context).size.width,
|
||||||
child: Column(
|
child: SafeArea(
|
||||||
|
child: ColoredBox(
|
||||||
|
color: Theme.of(context).scaffoldBackgroundColor,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Flex(
|
||||||
|
direction: Axis.horizontal,
|
||||||
children: [
|
children: [
|
||||||
TopbarTitleText(state.conversation!.title),
|
const BackButton(),
|
||||||
_buildChatState(state.conversation!.chatState)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Hero(
|
Hero(
|
||||||
tag: 'conversation_profile_picture',
|
tag: 'conversation_profile_picture',
|
||||||
child: Material(
|
child: Material(
|
||||||
@ -93,57 +105,106 @@ class ConversationTopbarWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
() => GetIt.I.get<profile.ProfileBloc>().add(
|
Expanded(
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => GetIt.I.get<profile.ProfileBloc>().add(
|
||||||
profile.ProfilePageRequestedEvent(
|
profile.ProfilePageRequestedEvent(
|
||||||
false,
|
false,
|
||||||
conversation: context.read<ConversationBloc>().state.conversation,
|
conversation: context.read<ConversationBloc>().state.conversation,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
extra: [
|
child: Stack(
|
||||||
// ignore: implicit_dynamic_type
|
children: [
|
||||||
PopupMenuButton(
|
AnimatedPositioned(
|
||||||
onSelected: (result) {
|
duration: const Duration(milliseconds: 200),
|
||||||
if (result == EncryptionOption.omemo) {
|
top: _isChatStateVisible(state.conversation!.chatState) ?
|
||||||
showNotImplementedDialog('End-to-End encryption', context);
|
0 :
|
||||||
}
|
10,
|
||||||
},
|
left: 0,
|
||||||
icon: const Icon(Icons.lock_open),
|
right: 0,
|
||||||
itemBuilder: (BuildContext c) => [
|
curve: Curves.easeInOutCubic,
|
||||||
popupItemWithIcon(EncryptionOption.none, 'Unencrypted', Icons.lock_open),
|
child: Row(
|
||||||
popupItemWithIcon(EncryptionOption.omemo, 'Encrypted', Icons.lock),
|
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
|
// ignore: implicit_dynamic_type
|
||||||
PopupMenuButton(
|
PopupMenuButton(
|
||||||
onSelected: (result) {
|
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) {
|
switch (result) {
|
||||||
case ConversationOption.close: {
|
case ConversationOption.close: {
|
||||||
showConfirmationDialog(
|
final result = await showConfirmationDialog(
|
||||||
'Close Chat',
|
t.pages.conversation.closeChatConfirmTitle,
|
||||||
'Are you sure you want to close this chat?',
|
t.pages.conversation.closeChatConfirmSubtext,
|
||||||
context,
|
context,
|
||||||
() {
|
);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
context.read<ConversationsBloc>().add(
|
context.read<ConversationsBloc>().add(
|
||||||
ConversationClosedEvent(state.conversation!.jid),
|
ConversationClosedEvent(state.conversation!.jid),
|
||||||
);
|
);
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case ConversationOption.block: {
|
case ConversationOption.block: {
|
||||||
blockJid(state.conversation!.jid, context);
|
await blockJid(state.conversation!.jid, context);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (BuildContext c) => [
|
itemBuilder: (BuildContext c) => [
|
||||||
popupItemWithIcon(ConversationOption.close, 'Close chat', Icons.close),
|
popupItemWithIcon(ConversationOption.close, t.pages.conversation.closeChat, Icons.close),
|
||||||
popupItemWithIcon(ConversationOption.block, 'Block contact', Icons.block)
|
popupItemWithIcon(ConversationOption.block, t.pages.conversation.blockUser, Icons.block)
|
||||||
],
|
],
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
|
||||||
import 'package:get_it/get_it.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/conversation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
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/avatar.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/conversation.dart';
|
import 'package:moxxyv2/ui/widgets/conversation.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
import 'package:moxxyv2/xmpp/xeps/xep_0085.dart';
|
|
||||||
|
|
||||||
enum ConversationsOptions {
|
enum ConversationsOptions {
|
||||||
settings
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
class ConversationsPage extends StatelessWidget {
|
class ConversationsPage extends StatelessWidget {
|
||||||
const ConversationsPage({ Key? key }) : super(key: key);
|
const ConversationsPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (context) => const ConversationsPage(),
|
builder: (context) => const ConversationsPage(),
|
||||||
@ -34,6 +35,7 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
itemCount: state.conversations.length,
|
itemCount: state.conversations.length,
|
||||||
itemBuilder: (_context, index) {
|
itemBuilder: (_context, index) {
|
||||||
final item = state.conversations[index];
|
final item = state.conversations[index];
|
||||||
|
|
||||||
return Dismissible(
|
return Dismissible(
|
||||||
key: ValueKey('conversation;$item'),
|
key: ValueKey('conversation;$item'),
|
||||||
onDismissed: (direction) => context.read<ConversationsBloc>().add(
|
onDismissed: (direction) => context.read<ConversationsBloc>().add(
|
||||||
@ -65,6 +67,7 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
item.lastChangeTimestamp,
|
item.lastChangeTimestamp,
|
||||||
true,
|
true,
|
||||||
typingIndicator: item.chatState == ChatState.composing,
|
typingIndicator: item.chatState == ChatState.composing,
|
||||||
|
lastMessageRetracted: item.lastMessageRetracted,
|
||||||
key: ValueKey('conversationRow;${item.jid}'),
|
key: ValueKey('conversationRow;${item.jid}'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -82,12 +85,12 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
// TODO(Unknown): Maybe somehow render the svg
|
// TODO(Unknown): Maybe somehow render the svg
|
||||||
child: Image.asset('assets/images/begin_chat.png'),
|
child: Image.asset('assets/images/begin_chat.png'),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Text('You have no open chats'),
|
child: Text(t.pages.conversations.noOpenChats),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Start a chat'),
|
child: Text(t.pages.conversations.startChat),
|
||||||
onPressed: () => Navigator.pushNamed(context, newConversationRoute),
|
onPressed: () => Navigator.pushNamed(context, newConversationRoute),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
@ -132,9 +135,9 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (BuildContext context) => [
|
itemBuilder: (BuildContext context) => [
|
||||||
const PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: ConversationsOptions.settings,
|
value: ConversationsOptions.settings,
|
||||||
child: Text('Settings'),
|
child: Text(t.pages.conversations.overlaySettings),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -155,7 +158,7 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
backgroundColor: primaryColor,
|
backgroundColor: primaryColor,
|
||||||
// TODO(Unknown): Theme dependent?
|
// TODO(Unknown): Theme dependent?
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
label: 'Join groupchat',
|
label: t.pages.conversations.speeddialJoinGroupchat,
|
||||||
),
|
),
|
||||||
SpeedDialChild(
|
SpeedDialChild(
|
||||||
child: const Icon(Icons.person_add),
|
child: const Icon(Icons.person_add),
|
||||||
@ -163,7 +166,7 @@ class ConversationsPage extends StatelessWidget {
|
|||||||
backgroundColor: primaryColor,
|
backgroundColor: primaryColor,
|
||||||
// TODO(Unknown): Theme dependent?
|
// TODO(Unknown): Theme dependent?
|
||||||
foregroundColor: Colors.white,
|
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:crop_your_image/crop_your_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/bloc/crop_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/button.dart';
|
import 'package:moxxyv2/ui/widgets/button.dart';
|
||||||
|
|
||||||
class CropPage extends StatelessWidget {
|
class CropPage extends StatelessWidget {
|
||||||
|
CropPage({ super.key }) : _controller = CropController();
|
||||||
CropPage({ Key? key }) : _controller = CropController(), super(key: key);
|
|
||||||
final CropController _controller;
|
final CropController _controller;
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
@ -59,10 +59,9 @@ class CropPage extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
RoundedButton(
|
RoundedButton(
|
||||||
color: primaryColor,
|
|
||||||
cornerRadius: 100,
|
cornerRadius: 100,
|
||||||
onTap: _controller.crop,
|
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:flutter/material.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/button.dart';
|
import 'package:moxxyv2/ui/widgets/button.dart';
|
||||||
|
|
||||||
class Intro extends StatelessWidget {
|
class Intro extends StatelessWidget {
|
||||||
const Intro({ Key? key }) : super(key: key);
|
const Intro({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const Intro(),
|
builder: (_) => const Intro(),
|
||||||
@ -36,11 +37,11 @@ class Intro extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||||
child: Text(
|
child: Text(
|
||||||
'An experiment into building a modern, easy and beautiful XMPP client.',
|
t.global.moxxySubtitle,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: fontsizeBody,
|
fontSize: fontsizeBody,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -51,23 +52,22 @@ class Intro extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RoundedButton(
|
child: RoundedButton(
|
||||||
color: Colors.purple,
|
|
||||||
cornerRadius: 32,
|
cornerRadius: 32,
|
||||||
onTap: () => Navigator.of(context).pushNamed(
|
onTap: () => Navigator.of(context).pushNamed(
|
||||||
loginRoute,
|
loginRoute,
|
||||||
),
|
),
|
||||||
child: const Text('Login'),
|
child: Text(t.pages.intro.loginButton),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Have no XMPP account? No worries, creating one is really easy.',
|
t.pages.intro.noAccount,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: fontsizeBody,
|
fontSize: fontsizeBody,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -78,7 +78,7 @@ class Intro extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(bottom: paddingVeryLarge)),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(bottom: paddingVeryLarge)),
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
child: const Text('Register'),
|
child: Text(t.pages.intro.registerButton),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// Navigator.pushNamed(context, registrationRoute);
|
// Navigator.pushNamed(context, registrationRoute);
|
||||||
showNotImplementedDialog('registration', context);
|
showNotImplementedDialog('registration', context);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/bloc/login_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/button.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';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
|
|
||||||
class Login extends StatelessWidget {
|
class Login extends StatelessWidget {
|
||||||
const Login({ Key? key }) : super(key: key);
|
const Login({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const Login(),
|
builder: (_) => const Login(),
|
||||||
@ -21,7 +22,7 @@ class Login extends StatelessWidget {
|
|||||||
builder: (BuildContext context, LoginState state) => WillPopScope(
|
builder: (BuildContext context, LoginState state) => WillPopScope(
|
||||||
onWillPop: () async => !state.working,
|
onWillPop: () async => !state.working,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Login'),
|
appBar: BorderlessTopbar.simple(t.pages.login.title),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
Visibility(
|
||||||
@ -35,7 +36,7 @@ class Login extends StatelessWidget {
|
|||||||
child: CustomTextField(
|
child: CustomTextField(
|
||||||
// ignore: avoid_dynamic_calls
|
// ignore: avoid_dynamic_calls
|
||||||
errorText: state.jidState.error,
|
errorText: state.jidState.error,
|
||||||
labelText: 'XMPP-Address',
|
labelText: t.pages.login.xmppAddress,
|
||||||
enabled: !state.working,
|
enabled: !state.working,
|
||||||
cornerRadius: textfieldRadiusRegular,
|
cornerRadius: textfieldRadiusRegular,
|
||||||
borderColor: primaryColor,
|
borderColor: primaryColor,
|
||||||
@ -49,7 +50,7 @@ class Login extends StatelessWidget {
|
|||||||
child: CustomTextField(
|
child: CustomTextField(
|
||||||
// ignore: avoid_dynamic_calls
|
// ignore: avoid_dynamic_calls
|
||||||
errorText: state.passwordState.error,
|
errorText: state.passwordState.error,
|
||||||
labelText: 'Password',
|
labelText: t.pages.login.password,
|
||||||
suffixIcon: Padding(
|
suffixIcon: Padding(
|
||||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
@ -71,12 +72,12 @@ class Login extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge).add(const EdgeInsets.only(top: 8)),
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
title: const Text('Advanced options'),
|
title: Text(t.pages.login.advancedOptions),
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
children: [
|
children: [
|
||||||
SwitchListTile(
|
SwitchListTile(
|
||||||
title: const Text('Create account on server'),
|
title: Text(t.pages.login.createAccount),
|
||||||
value: false,
|
value: false,
|
||||||
// TODO(Unknown): Implement
|
// TODO(Unknown): Implement
|
||||||
onChanged: state.working ? null : (value) {},
|
onChanged: state.working ? null : (value) {},
|
||||||
@ -92,9 +93,9 @@ class Login extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RoundedButton(
|
child: RoundedButton(
|
||||||
color: Colors.purple,
|
|
||||||
cornerRadius: 32,
|
cornerRadius: 32,
|
||||||
onTap: state.working ? null : () => context.read<LoginBloc>().add(LoginSubmittedEvent()),
|
enabled: !state.working,
|
||||||
|
onTap: () => context.read<LoginBloc>().add(LoginSubmittedEvent()),
|
||||||
child: const Text('Login'),
|
child: const Text('Login'),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/constants.dart';
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.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';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
|
|
||||||
class NewConversationPage extends StatelessWidget {
|
class NewConversationPage extends StatelessWidget {
|
||||||
const NewConversationPage({ Key? key }) : super(key: key);
|
const NewConversationPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const NewConversationPage(),
|
builder: (_) => const NewConversationPage(),
|
||||||
@ -49,7 +50,7 @@ class NewConversationPage extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
final maxTextWidth = MediaQuery.of(context).size.width * 0.6;
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Start new chat'),
|
appBar: BorderlessTopbar.simple(t.pages.newconversation.title),
|
||||||
body: BlocBuilder<NewConversationBloc, NewConversationState>(
|
body: BlocBuilder<NewConversationBloc, NewConversationState>(
|
||||||
builder: (BuildContext context, NewConversationState state) => ListView.builder(
|
builder: (BuildContext context, NewConversationState state) => ListView.builder(
|
||||||
itemCount: state.roster.length + 2,
|
itemCount: state.roster.length + 2,
|
||||||
@ -57,12 +58,12 @@ class NewConversationPage extends StatelessWidget {
|
|||||||
switch(index) {
|
switch(index) {
|
||||||
case 0: return _renderIconEntry(
|
case 0: return _renderIconEntry(
|
||||||
Icons.person_add,
|
Icons.person_add,
|
||||||
'Add contact',
|
t.pages.newconversation.addContact,
|
||||||
() => Navigator.pushNamed(context, addContactRoute),
|
() => Navigator.pushNamed(context, addContactRoute),
|
||||||
);
|
);
|
||||||
case 1: return _renderIconEntry(
|
case 1: return _renderIconEntry(
|
||||||
Icons.group_add,
|
Icons.group_add,
|
||||||
'Create groupchat',
|
t.pages.newconversation.createGroupchat,
|
||||||
() => showNotImplementedDialog('groupchat', context),
|
() => showNotImplementedDialog('groupchat', context),
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get_it/get_it.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/shared/models/conversation.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.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';
|
//import 'package:phosphor_flutter/phosphor_flutter.dart';
|
||||||
|
|
||||||
class ConversationProfileHeader extends StatelessWidget {
|
class ConversationProfileHeader extends StatelessWidget {
|
||||||
|
const ConversationProfileHeader(this.conversation, { super.key });
|
||||||
const ConversationProfileHeader(this.conversation, { Key? key }) : super(key: key);
|
|
||||||
final Conversation conversation;
|
final Conversation conversation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -55,8 +56,8 @@ class ConversationProfileHeader extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Tooltip(
|
Tooltip(
|
||||||
message: conversation.muted ?
|
message: conversation.muted ?
|
||||||
'Unmute chat' :
|
t.pages.profile.conversation.unmuteChatTooltip :
|
||||||
'Mute chat',
|
t.pages.profile.conversation.muteChatTooltip,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@ -84,8 +85,38 @@ class ConversationProfileHeader extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
conversation.muted ?
|
conversation.muted ?
|
||||||
'Unmute' :
|
t.pages.profile.conversation.unmuteChat :
|
||||||
'Mute',
|
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(
|
style: const TextStyle(
|
||||||
fontSize: fontsizeAppbar,
|
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';
|
import 'package:moxxyv2/ui/widgets/chat/shared/media.dart';
|
||||||
|
|
||||||
class ProfilePage extends StatelessWidget {
|
class ProfilePage extends StatelessWidget {
|
||||||
const ProfilePage({ Key? key }) : super(key: key);
|
const ProfilePage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const ProfilePage(),
|
builder: (_) => const ProfilePage(),
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
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/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
import 'package:moxxyv2/ui/widgets/avatar.dart';
|
||||||
|
import 'package:moxxyv2/ui/widgets/chat/shared/base.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|
||||||
class SelfProfileHeader extends StatelessWidget {
|
class SelfProfileHeader extends StatelessWidget {
|
||||||
@ -10,10 +15,8 @@ class SelfProfileHeader extends StatelessWidget {
|
|||||||
this.avatarUrl,
|
this.avatarUrl,
|
||||||
this.displayName,
|
this.displayName,
|
||||||
this.setAvatar,
|
this.setAvatar,
|
||||||
{
|
{ super.key, }
|
||||||
Key? key,
|
);
|
||||||
}
|
|
||||||
) : super(key: key);
|
|
||||||
final String jid;
|
final String jid;
|
||||||
final String avatarUrl;
|
final String avatarUrl;
|
||||||
final String displayName;
|
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/base.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/shared/image.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/shared/video.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/thumbnail.dart';
|
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
|
|
||||||
Widget _deleteIconWithShadow() {
|
Widget _deleteIconWithShadow() {
|
||||||
@ -24,8 +23,7 @@ Widget _deleteIconWithShadow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SendFilesPage extends StatelessWidget {
|
class SendFilesPage extends StatelessWidget {
|
||||||
|
const SendFilesPage({ super.key });
|
||||||
const SendFilesPage({ Key? key }) : super(key: key);
|
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (context) => const SendFilesPage(),
|
builder: (context) => const SendFilesPage(),
|
||||||
@ -126,14 +124,14 @@ class SendFilesPage extends StatelessWidget {
|
|||||||
return Image.file(
|
return Image.file(
|
||||||
File(path),
|
File(path),
|
||||||
);
|
);
|
||||||
} else if (mime.startsWith('video/')) {
|
} /*else if (mime.startsWith('video/')) {
|
||||||
// Render the video thumbnail
|
// Render the video thumbnail
|
||||||
// TODO(PapaTutuWawa): Maybe allow playing the video back inline
|
// TODO(PapaTutuWawa): Maybe allow playing the video back inline
|
||||||
return VideoThumbnailWidget(
|
return VideoThumbnailWidget(
|
||||||
path,
|
path,
|
||||||
Image.memory,
|
Image.memory,
|
||||||
);
|
);
|
||||||
} else {
|
}*/ else {
|
||||||
// Generic file
|
// Generic file
|
||||||
final width = MediaQuery.of(context).size.width;
|
final width = MediaQuery.of(context).size.width;
|
||||||
return Center(
|
return Center(
|
||||||
|
@ -9,7 +9,7 @@ const TextStyle _labelStyle = TextStyle(
|
|||||||
);
|
);
|
||||||
|
|
||||||
class ServerInfoPage extends StatelessWidget {
|
class ServerInfoPage extends StatelessWidget {
|
||||||
const ServerInfoPage({ Key? key }) : super(key: key);
|
const ServerInfoPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const ServerInfoPage(),
|
builder: (_) => const ServerInfoPage(),
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
import 'package:url_launcher/url_launcher.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(PapaTutuWawa): Include license text
|
||||||
// TODO(Unknown): Maybe include the version number
|
// TODO(Unknown): Maybe include the version number
|
||||||
class SettingsAboutPage extends StatelessWidget {
|
class SettingsAboutPage extends StatelessWidget {
|
||||||
const SettingsAboutPage({ Key? key }) : super(key: key);
|
const SettingsAboutPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const SettingsAboutPage(),
|
builder: (_) => const SettingsAboutPage(),
|
||||||
@ -16,7 +17,7 @@ class SettingsAboutPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
Future<void> _openUrl(String url) async {
|
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
|
// TODO(Unknown): Show a popup to copy the url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -24,7 +25,7 @@ class SettingsAboutPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('About'),
|
appBar: BorderlessTopbar.simple(t.pages.settings.about.title),
|
||||||
body: Padding(
|
body: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -33,26 +34,26 @@ class SettingsAboutPage extends StatelessWidget {
|
|||||||
'assets/images/logo.png',
|
'assets/images/logo.png',
|
||||||
width: 200, height: 200,
|
width: 200, height: 200,
|
||||||
),
|
),
|
||||||
const Text(
|
Text(
|
||||||
'moxxy',
|
t.global.title,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 40,
|
fontSize: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Padding(
|
Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'An experimental XMPP client that is beautiful, modern and easy to use',
|
t.global.moxxySubtitle,
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Text('Licensed under GPL3'),
|
Text(t.pages.settings.about.licensed),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
child: const Text('View source code'),
|
child: Text(t.pages.settings.about.viewSourceCode),
|
||||||
onPressed: () => _openUrl('https://github.com/PapaTutuWawa/moxxyv2'),
|
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';
|
import 'package:moxxyv2/ui/widgets/cancel_button.dart';
|
||||||
|
|
||||||
class CropBackgroundPage extends StatefulWidget {
|
class CropBackgroundPage extends StatefulWidget {
|
||||||
|
const CropBackgroundPage({ super.key });
|
||||||
const CropBackgroundPage({ Key? key }) : super(key: key);
|
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (context) => const CropBackgroundPage(),
|
builder: (context) => const CropBackgroundPage(),
|
||||||
@ -205,11 +204,8 @@ class CropBackgroundPageState extends State<CropBackgroundPage> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
RoundedButton(
|
RoundedButton(
|
||||||
color: primaryColor,
|
|
||||||
cornerRadius: 100,
|
cornerRadius: 100,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (state.isWorking) return;
|
|
||||||
|
|
||||||
context.read<CropBackgroundBloc>().add(
|
context.read<CropBackgroundBloc>().add(
|
||||||
BackgroundSetEvent(
|
BackgroundSetEvent(
|
||||||
_x,
|
_x,
|
||||||
@ -220,6 +216,7 @@ class CropBackgroundPageState extends State<CropBackgroundPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
enabled: !state.isWorking,
|
||||||
child: const Text('Set as background image'),
|
child: const Text('Set as background image'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -3,6 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/shared/models/preferences.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/cropbackground_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/cropbackground_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/preferences_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';
|
import 'package:settings_ui/settings_ui.dart';
|
||||||
|
|
||||||
class ConversationSettingsPage extends StatelessWidget {
|
class ConversationSettingsPage extends StatelessWidget {
|
||||||
|
const ConversationSettingsPage({ super.key });
|
||||||
const ConversationSettingsPage({ Key? key }): super(key: key);
|
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const ConversationSettingsPage(),
|
builder: (_) => const ConversationSettingsPage(),
|
||||||
@ -64,16 +64,16 @@ class ConversationSettingsPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Chat'),
|
appBar: BorderlessTopbar.simple(t.pages.settings.conversation.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => SettingsList(
|
builder: (context, state) => SettingsList(
|
||||||
sections: [
|
sections: [
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: const Text('Appearance'),
|
title: Text(t.pages.settings.conversation.appearance),
|
||||||
tiles: [
|
tiles: [
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: const Text('Select background image'),
|
title: Text(t.pages.settings.conversation.selectBackgroundImage),
|
||||||
description: const Text('This image will be the background of all your chats'),
|
description: Text(t.pages.settings.conversation.selectBackgroundImageDescription),
|
||||||
onPressed: (context) async {
|
onPressed: (context) async {
|
||||||
final backgroundPath = await _pickBackgroundImage();
|
final backgroundPath = await _pickBackgroundImage();
|
||||||
|
|
||||||
@ -86,27 +86,27 @@ class ConversationSettingsPage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: const Text('Remove background image'),
|
title: Text(t.pages.settings.conversation.removeBackgroundImage),
|
||||||
onPressed: (context) {
|
onPressed: (context) async {
|
||||||
showConfirmationDialog(
|
final result = await showConfirmationDialog(
|
||||||
'Are you sure?',
|
t.pages.settings.conversation.removeBackgroundImageConfirmTitle,
|
||||||
'Are you sure you want to remove your conversation background image?',
|
t.pages.settings.conversation.removeBackgroundImageConfirmBody,
|
||||||
context,
|
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(
|
SettingsSection(
|
||||||
title: const Text('New Conversations'),
|
title: Text(t.pages.settings.conversation.newChatsSection),
|
||||||
tiles: [
|
tiles: [
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Mute new chats by default'),
|
title: Text(t.pages.settings.conversation.newChatsMuteByDefault),
|
||||||
initialValue: state.defaultMuteState,
|
initialValue: state.defaultMuteState,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/shared/models/preferences.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
@ -7,8 +8,10 @@ import 'package:moxxyv2/ui/widgets/topbar.dart';
|
|||||||
import 'package:settings_ui/settings_ui.dart';
|
import 'package:settings_ui/settings_ui.dart';
|
||||||
|
|
||||||
class DebuggingPage extends StatelessWidget {
|
class DebuggingPage extends StatelessWidget {
|
||||||
|
DebuggingPage({ super.key })
|
||||||
DebuggingPage({ Key? key }) : _ipController = TextEditingController(), _passphraseController = TextEditingController(), _portController = TextEditingController(), super(key: key);
|
: _ipController = TextEditingController(),
|
||||||
|
_passphraseController = TextEditingController(),
|
||||||
|
_portController = TextEditingController();
|
||||||
final TextEditingController _ipController;
|
final TextEditingController _ipController;
|
||||||
final TextEditingController _portController;
|
final TextEditingController _portController;
|
||||||
final TextEditingController _passphraseController;
|
final TextEditingController _passphraseController;
|
||||||
@ -23,15 +26,15 @@ class DebuggingPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Debugging'),
|
appBar: BorderlessTopbar.simple(t.pages.settings.debugging.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => SettingsList(
|
builder: (context, state) => SettingsList(
|
||||||
sections: [
|
sections: [
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: const Text('General'),
|
title: Text(t.pages.settings.debugging.generalSection),
|
||||||
tiles: [
|
tiles: [
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Enable debugging'),
|
title: Text(t.pages.settings.debugging.generalEnableDebugging),
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
state.copyWith(debugEnabled: value),
|
state.copyWith(debugEnabled: value),
|
||||||
@ -40,14 +43,14 @@ class DebuggingPage extends StatelessWidget {
|
|||||||
initialValue: state.debugEnabled,
|
initialValue: state.debugEnabled,
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: const Text('Encryption password'),
|
title: Text(t.pages.settings.debugging.generalEncryptionPassword),
|
||||||
description: const Text('The logs may contain sensitive information so pick a strong passphrase'),
|
description: Text(t.pages.settings.debugging.generalEncryptionPasswordSubtext),
|
||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
title: const Text('Debug Passphrase'),
|
title: Text(t.pages.settings.debugging.generalEncryptionPassword),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
@ -55,7 +58,7 @@ class DebuggingPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Okay'),
|
child: Text(t.global.dialogAccept),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<PreferencesBloc>().add(
|
context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -71,21 +74,21 @@ class DebuggingPage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: const Text('Logging IP'),
|
title: Text(t.pages.settings.debugging.generalLoggingIp),
|
||||||
description: const Text('The IP the logs should be sent to'),
|
description: Text(t.pages.settings.debugging.generalLoggingIpSubtext),
|
||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
title: const Text('Logging IP'),
|
title: Text(t.pages.settings.debugging.generalLoggingIp),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
controller: _ipController,
|
controller: _ipController,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Okay'),
|
child: Text(t.global.dialogAccept),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<PreferencesBloc>().add(
|
context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -101,14 +104,14 @@ class DebuggingPage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: const Text('Logging Port'),
|
title: Text(t.pages.settings.debugging.generalLoggingPort),
|
||||||
description: const Text('The Port the logs should be sent to'),
|
description: Text(t.pages.settings.debugging.generalLoggingPortSubtext),
|
||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: true,
|
barrierDismissible: true,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
title: const Text('Logging Port'),
|
title: Text(t.pages.settings.debugging.generalLoggingPort),
|
||||||
content: TextField(
|
content: TextField(
|
||||||
minLines: 1,
|
minLines: 1,
|
||||||
controller: _portController,
|
controller: _portController,
|
||||||
@ -116,7 +119,7 @@ class DebuggingPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
child: const Text('Okay'),
|
child: Text(t.global.dialogAccept),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.read<PreferencesBloc>().add(
|
context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@ -14,8 +15,7 @@ class Library {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class LicenseRow extends StatelessWidget {
|
class LicenseRow extends StatelessWidget {
|
||||||
|
const LicenseRow({ required this.library, super.key });
|
||||||
const LicenseRow({ required this.library, Key? key }) : super(key: key);
|
|
||||||
final Library library;
|
final Library library;
|
||||||
|
|
||||||
Future<void> _openUrl() async {
|
Future<void> _openUrl() async {
|
||||||
@ -32,14 +32,14 @@ class LicenseRow extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(library.name),
|
title: Text(library.name),
|
||||||
subtitle: Text('Licensed under ${library.license}'),
|
subtitle: Text(t.pages.settings.licenses.licensedUnder(license: library.license)),
|
||||||
onTap: _openUrl,
|
onTap: _openUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsLicensesPage extends StatelessWidget {
|
class SettingsLicensesPage extends StatelessWidget {
|
||||||
const SettingsLicensesPage({ Key? key }) : super(key: key);
|
const SettingsLicensesPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const SettingsLicensesPage(),
|
builder: (_) => const SettingsLicensesPage(),
|
||||||
@ -51,7 +51,7 @@ class SettingsLicensesPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Licenses'),
|
appBar: BorderlessTopbar.simple(t.pages.settings.licenses.title),
|
||||||
body: ListView.builder(
|
body: ListView.builder(
|
||||||
itemCount: usedLibraryList.length,
|
itemCount: usedLibraryList.length,
|
||||||
itemBuilder: (context, index) => LicenseRow(library: usedLibraryList[index]),
|
itemBuilder: (context, index) => LicenseRow(library: usedLibraryList[index]),
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/shared/models/preferences.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
@ -22,8 +23,7 @@ const _autoDownloadSizes = <_AutoDownloadSizes>[
|
|||||||
];
|
];
|
||||||
|
|
||||||
class NetworkPage extends StatelessWidget {
|
class NetworkPage extends StatelessWidget {
|
||||||
|
const NetworkPage({ super.key });
|
||||||
const NetworkPage({ Key? key }): super(key: key);
|
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const NetworkPage(),
|
builder: (_) => const NetworkPage(),
|
||||||
@ -67,18 +67,18 @@ class NetworkPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Network'),
|
appBar: BorderlessTopbar.simple(t.pages.settings.network.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => SettingsList(
|
builder: (context, state) => SettingsList(
|
||||||
sections: [
|
sections: [
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: const Text('Automatic Downloads'),
|
title: Text(t.pages.settings.network.automaticDownloadsSection),
|
||||||
tiles: [
|
tiles: [
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: const Text('Moxxy will automatically download files on...'),
|
title: Text(t.pages.settings.network.automaticDownloadsText),
|
||||||
),
|
),
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Wifi'),
|
title: Text(t.pages.settings.network.wifi),
|
||||||
initialValue: state.autoDownloadWifi,
|
initialValue: state.autoDownloadWifi,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -87,7 +87,7 @@ class NetworkPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Mobile Data'),
|
title: Text(t.pages.settings.network.mobileData),
|
||||||
initialValue: state.autoDownloadMobile,
|
initialValue: state.autoDownloadMobile,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -96,8 +96,8 @@ class NetworkPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsTile(
|
SettingsTile(
|
||||||
title: const Text('Maximum Download Size'),
|
title: Text(t.pages.settings.network.automaticDownloadsMaximumSize),
|
||||||
description: const Text('The maximum file size for a file to be automatically downloaded'),
|
description: Text(t.pages.settings.network.automaticDownloadsMaximumSizeSubtext),
|
||||||
onPressed: (context) {
|
onPressed: (context) {
|
||||||
showModalBottomSheet<dynamic>(
|
showModalBottomSheet<dynamic>(
|
||||||
context: context,
|
context: context,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/shared/models/preferences.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
@ -8,7 +9,7 @@ import 'package:moxxyv2/ui/widgets/topbar.dart';
|
|||||||
import 'package:settings_ui/settings_ui.dart';
|
import 'package:settings_ui/settings_ui.dart';
|
||||||
|
|
||||||
class PrivacyPage extends StatelessWidget {
|
class PrivacyPage extends StatelessWidget {
|
||||||
const PrivacyPage({ Key? key }): super(key: key);
|
const PrivacyPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
builder: (_) => const PrivacyPage(),
|
builder: (_) => const PrivacyPage(),
|
||||||
@ -20,16 +21,16 @@ class PrivacyPage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: BorderlessTopbar.simple('Privacy'),
|
appBar: BorderlessTopbar.simple(t.pages.settings.privacy.title),
|
||||||
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
body: BlocBuilder<PreferencesBloc, PreferencesState>(
|
||||||
builder: (context, state) => SettingsList(
|
builder: (context, state) => SettingsList(
|
||||||
sections: [
|
sections: [
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: const Text('General'),
|
title: Text(t.pages.settings.privacy.generalSection),
|
||||||
tiles: [
|
tiles: [
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Show contact requests'),
|
title: Text(t.pages.settings.privacy.showContactRequests),
|
||||||
description: const Text('This will show people who added you to their contact list but sent no message yet'),
|
description: Text(t.pages.settings.privacy.showContactRequestsSubtext),
|
||||||
initialValue: state.showSubscriptionRequests,
|
initialValue: state.showSubscriptionRequests,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -38,8 +39,8 @@ class PrivacyPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Make profile picture public'),
|
title: Text(t.pages.settings.privacy.profilePictureVisibility),
|
||||||
description: const Text('If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.'),
|
description: Text(t.pages.settings.privacy.profilePictureVisibilitSubtext),
|
||||||
initialValue: state.isAvatarPublic,
|
initialValue: state.isAvatarPublic,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -48,8 +49,8 @@ class PrivacyPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Auto-accept subscription requests'),
|
title: Text(t.pages.settings.privacy.autoAcceptSubscriptionRequests),
|
||||||
description: const Text('If enabled, subscription requests will be automatically accepted if the user is in the contact list.'),
|
description: Text(t.pages.settings.privacy.autoAcceptSubscriptionRequestsSubtext),
|
||||||
initialValue: state.autoAcceptSubscriptionRequests,
|
initialValue: state.autoAcceptSubscriptionRequests,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -60,11 +61,11 @@ class PrivacyPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: const Text('Conversation'),
|
title: Text(t.pages.settings.privacy.conversationsSection),
|
||||||
tiles: [
|
tiles: [
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Send chat markers'),
|
title: Text(t.pages.settings.privacy.sendChatMarkers),
|
||||||
description: const Text('This will tell your conversation partner if you received or read a message'),
|
description: Text(t.pages.settings.privacy.sendChatMarkersSubtext),
|
||||||
initialValue: state.sendChatMarkers,
|
initialValue: state.sendChatMarkers,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -73,8 +74,8 @@ class PrivacyPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsTile.switchTile(
|
SettingsTile.switchTile(
|
||||||
title: const Text('Send chat states'),
|
title: Text(t.pages.settings.privacy.sendChatStates),
|
||||||
description: const Text('This will show your conversation partner if you are typing or looking at the chat'),
|
description: Text(t.pages.settings.privacy.sendChatStatesSubtext),
|
||||||
initialValue: state.sendChatStates,
|
initialValue: state.sendChatStates,
|
||||||
onToggle: (value) => context.read<PreferencesBloc>().add(
|
onToggle: (value) => context.read<PreferencesBloc>().add(
|
||||||
PreferencesChangedEvent(
|
PreferencesChangedEvent(
|
||||||
@ -85,7 +86,7 @@ class PrivacyPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
SettingsSection(
|
SettingsSection(
|
||||||
title: const Text('Redirects'),
|
title: Text(t.pages.settings.privacy.redirectsSection),
|
||||||
tiles: [
|
tiles: [
|
||||||
RedirectSettingsTile(
|
RedirectSettingsTile(
|
||||||
'Youtube',
|
'Youtube',
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user