Compare commits
136 Commits
v0.4.2
...
e205785246
| Author | SHA1 | Date | |
|---|---|---|---|
| e205785246 | |||
| 3edda978fb | |||
|
|
ebabb0e445 | ||
| 117d263e25 | |||
| 63e66e5dce | |||
| 140a16ec0c | |||
| 44b95bbb5b | |||
| 4eeaa8c37b | |||
| c95f2efd65 | |||
| 9dbf4b5467 | |||
| 0a120f1073 | |||
|
|
269738e618 | ||
| 1e94910ebd | |||
| 63d251a7f1 | |||
| 799af75bcc | |||
| 8966c490fe | |||
| 4c09dbab60 | |||
| 6fd5c33b0a | |||
| 30e8a885bb | |||
| 42c695a2a1 | |||
| 3ef2f3b8d6 | |||
| ae995b8670 | |||
| 75c2f103bd | |||
| bc7958559a | |||
|
|
11228a0de0 | ||
| b6fe5cc29b | |||
| cb34b51149 | |||
| 6c9421a21a | |||
| 684a3a658d | |||
| 0ab7cccef6 | |||
| c405717bcc | |||
| 7dcd14ef3a | |||
| 4f033438ed | |||
| 21e40c38ca | |||
| 53e3b3c561 | |||
| d6416c77b8 | |||
| 9ade143352 | |||
| dc05876ac7 | |||
| d691d63353 | |||
| b666a06252 | |||
| 27b209b4d8 | |||
| d0cdb2cebe | |||
| 2123f5b51f | |||
| 93511da3f1 | |||
| 267eef2a55 | |||
| 8f69b9ff3d | |||
| 0d182cc4e5 | |||
| f6bfbff62c | |||
| dd0cb88841 | |||
| 23ed1f6b1a | |||
| d12154b4ba | |||
| b625ee5c4e | |||
| 4e48962fae | |||
| 21832042df | |||
| d48c8371e4 | |||
| 67f6fb8236 | |||
| 369cc3e823 | |||
| 4857245a96 | |||
|
|
2f39d4b60b | ||
|
|
0528aca3ad | ||
| d99e5d8801 | |||
| 5cda06db24 | |||
| c93c3140cf | |||
| f17bba7282 | |||
| fe3dbd265e | |||
| e6eee134bd | |||
| 1f5c75a647 | |||
| d70527d65f | |||
| efb3f4b371 | |||
| 71f5e8f0b4 | |||
| a13bdd2e2b | |||
| 7fbc1ba812 | |||
| 7f864f1d25 | |||
|
|
ef3c11e870 | ||
|
|
c20bc964c3 | ||
| dd2629d073 | |||
| e5fa199925 | |||
| 675a647a8d | |||
| f845c4134c | |||
| 6442e9cab5 | |||
| 0629a3d5bd | |||
| 62d18588d7 | |||
|
|
4ee191e238 | ||
| 351de5ee93 | |||
| dae8ddb3b5 | |||
| 631a62e8ce | |||
| 59877a3e60 | |||
| 08da843d50 | |||
| 949781003a | |||
| 4338c7a777 | |||
| 86de5cd22d | |||
| bf754a4e51 | |||
| 8913977c0a | |||
| f4be336e39 | |||
| 836fe1bf87 | |||
| 7bca95203e | |||
| 059a22cbe8 | |||
| 2740692772 | |||
| c0008fece9 | |||
| d7bb54a088 | |||
| eb41b3f0f7 | |||
| e3cbc47cc8 | |||
| 75767d26b4 | |||
| a01667a8f7 | |||
| e4dec4168c | |||
| 59f1a3a289 | |||
| 9c8aec6543 | |||
| 7c8a368d73 | |||
| 0bda382e40 | |||
| 330b4dd69f | |||
| 7a7e43eb3c | |||
| 5e797d6b54 | |||
| 1b3dd0634b | |||
| b1bdacb834 | |||
| a4b9485019 | |||
| 20489fbb25 | |||
| a2fa000a31 | |||
| 343f0e7dae | |||
| f0f13097c0 | |||
| 0025e83be5 | |||
| ffb8e9f3fc | |||
| 8081931c55 | |||
| 792276d06a | |||
| 58edc256fd | |||
| f30d04a593 | |||
| cc42f32b21 | |||
| 353623c5ae | |||
| a09c30a076 | |||
| 3bfd72fde1 | |||
| 39e6b4a48b | |||
| 32b2e35d42 | |||
| 8e1bcbfd1d | |||
| 336a6d56cd | |||
| a283454cae | |||
| 8b16a8a37b | |||
| 727a1a3423 |
@@ -3,6 +3,11 @@
|
|||||||
Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working
|
Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working
|
||||||
on the Moxxy codebase.
|
on the Moxxy codebase.
|
||||||
|
|
||||||
|
## Non-Code Contributions
|
||||||
|
### Translations
|
||||||
|
|
||||||
|
You can contribute to Moxxy by translating parts of Moxxy into a language you can speak. To do that, head over to [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/), where you can start translating.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before building or working on Moxxy, please make sure that your development environment is correctly set up.
|
Before building or working on Moxxy, please make sure that your development environment is correctly set up.
|
||||||
@@ -51,7 +56,22 @@ Before creating a pull request, please make sure you checked every item on the f
|
|||||||
If you think that your code is ready for a pull request, but you are not sure if it is ready, prefix the PR's title with "WIP: ", so that discussion
|
If you think that your code is ready for a pull request, but you are not sure if it is ready, prefix the PR's title with "WIP: ", so that discussion
|
||||||
can happen there. If you think your PR is ready for review, remove the "WIP: " prefix.
|
can happen there. If you think your PR is ready for review, remove the "WIP: " prefix.
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
#### `data_classes.yaml`
|
||||||
|
|
||||||
|
When you add, remove, or modify data classes in `data_classes.yaml`, you need to rebuild the classes using `flutter pub run build_runner build`. However, there appears
|
||||||
|
to be a bug in my own build runner script, which prevents the data classes from being
|
||||||
|
rebuilt if they are changed. To fix this, remove the generated data classes by running
|
||||||
|
`rm lib/shared/*.moxxy.dart`, after which build_runner will rebuild the data classes.
|
||||||
|
|
||||||
### Code Guidelines
|
### Code Guidelines
|
||||||
|
#### Translations
|
||||||
|
|
||||||
|
If your code adds new strings that should be translated, only add them to the base
|
||||||
|
language, which is English. Even if you know more than English, do not add the keys
|
||||||
|
to other language files. To prevent merge conflicts between Weblate and the repository,
|
||||||
|
all other languages are managed via [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/).
|
||||||
|
|
||||||
#### Commit messages
|
#### Commit messages
|
||||||
|
|
||||||
Commit messages should be uniformly formatted. `gitlint` is a linter for commit messages that enforces those guidelines. They are defined in the `.gitlint` file
|
Commit messages should be uniformly formatted. `gitlint` is a linter for commit messages that enforces those guidelines. They are defined in the `.gitlint` file
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
An experimental XMPP client that tries to be as easy, modern and beautiful as possible.
|
An experimental XMPP client that tries to be as easy, modern and beautiful as possible.
|
||||||
|
|
||||||
The code is also available on [codeberg](https://codeberg.org/moxxy/moxxy).
|
The code is also available on [Codeberg](https://codeberg.org/moxxy/moxxy).
|
||||||
|
|
||||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/org.moxxy.moxxyv2)
|
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/org.moxxy.moxxyv2)
|
||||||
|
|
||||||
@@ -19,6 +19,12 @@ For build and contribution guidelines, please refer to [`CONTRIBUTING.md`](./CON
|
|||||||
|
|
||||||
Also, feel free to join the development chat at `moxxy@muc.moxxy.org`.
|
Also, feel free to join the development chat at `moxxy@muc.moxxy.org`.
|
||||||
|
|
||||||
|
### Translating
|
||||||
|
|
||||||
|
If you want to contribute by translating Moxxy, you can do that on [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/).
|
||||||
|
|
||||||
|
[](https://translate.codeberg.org/engage/moxxy/)
|
||||||
|
|
||||||
## A Bit of History
|
## A Bit of History
|
||||||
|
|
||||||
This project is the successor of moxxyv1, which was written in *React Native* and abandoned
|
This project is the successor of moxxyv1, which was written in *React Native* and abandoned
|
||||||
|
|||||||
@@ -38,6 +38,14 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="*/*" />
|
<data android:mimeType="*/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
|
<!-- Enable usage of direct share -->
|
||||||
|
<meta-data
|
||||||
|
android:name="android.service.chooser.chooser_target_service"
|
||||||
|
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/share_targets" />
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
|||||||
7
android/app/src/main/res/xml/share_targets.xml
Normal file
7
android/app/src/main/res/xml/share_targets.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<share-target android:targetClass="org.moxxy.moxxyv2.MainActivity">
|
||||||
|
<data android:mimeType="*/*" />
|
||||||
|
<category android:name="org.moxxy.moxxyv2.dynamic_share_target" />
|
||||||
|
</share-target>
|
||||||
|
</shortcuts>
|
||||||
@@ -26,6 +26,6 @@ subprojects {
|
|||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|
||||||
task clean(type: Delete) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
{
|
|
||||||
"@@name": "English",
|
|
||||||
"global": {
|
|
||||||
"title": "Moxxy",
|
|
||||||
"moxxySubtitle": "An experiment into building a modern, easy and beautiful XMPP client.",
|
|
||||||
"dialogAccept": "Okay",
|
|
||||||
"dialogCancel": "Cancel",
|
|
||||||
"yes": "Yes",
|
|
||||||
"no": "No"
|
|
||||||
},
|
|
||||||
"notifications": {
|
|
||||||
"permanent": {
|
|
||||||
"idle": "Idle",
|
|
||||||
"ready": "Ready to receive messages",
|
|
||||||
"connecting": "Connecting...",
|
|
||||||
"disconnect": "Disconnected",
|
|
||||||
"error": "Error"
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"reply": "Reply",
|
|
||||||
"markAsRead": "Mark as read"
|
|
||||||
},
|
|
||||||
"channels": {
|
|
||||||
"messagesChannelName": "Messages",
|
|
||||||
"messagesChannelDescription": "The notification channel for received messages",
|
|
||||||
"warningChannelName": "Warnings",
|
|
||||||
"warningChannelDescription": "Warnings related to Moxxy"
|
|
||||||
},
|
|
||||||
"titles": {
|
|
||||||
"error": "Error"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"dateTime": {
|
|
||||||
"justNow": "Just now",
|
|
||||||
"nMinutesAgo": "${min}min ago",
|
|
||||||
"mondayAbbrev": "Mon",
|
|
||||||
"tuesdayAbbrev": "Tue",
|
|
||||||
"wednessdayAbbrev": "Wed",
|
|
||||||
"thursdayAbbrev": "Thu",
|
|
||||||
"fridayAbbrev": "Fri",
|
|
||||||
"saturdayAbbrev": "Sat",
|
|
||||||
"sundayAbbrev": "Sun",
|
|
||||||
"january": "January",
|
|
||||||
"february": "February",
|
|
||||||
"march": "March",
|
|
||||||
"april": "April",
|
|
||||||
"may": "May",
|
|
||||||
"june": "June",
|
|
||||||
"july": "July",
|
|
||||||
"august": "August",
|
|
||||||
"september": "September",
|
|
||||||
"october": "October",
|
|
||||||
"november": "November",
|
|
||||||
"december": "December",
|
|
||||||
"today": "Today",
|
|
||||||
"yesterday": "Yesterday"
|
|
||||||
},
|
|
||||||
"messages": {
|
|
||||||
"image": "Image",
|
|
||||||
"video": "Video",
|
|
||||||
"audio": "Audio",
|
|
||||||
"file": "File",
|
|
||||||
"sticker": "Sticker",
|
|
||||||
"retracted": "The message has been retracted",
|
|
||||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
|
||||||
"you": "You"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"omemo": {
|
|
||||||
"couldNotPublish": "Could not publish the cryptographic identity to the server. This means that end-to-end encryption may not work.",
|
|
||||||
"notEncryptedForDevice": "This message was not encrypted for this device",
|
|
||||||
"invalidHmac": "Could not decrypt message",
|
|
||||||
"noDecryptionKey": "No decryption key available",
|
|
||||||
"messageInvalidAfixElement": "Invalid encrypted message",
|
|
||||||
|
|
||||||
"verificationInvalidOmemoUrl": "Invalid OMEMO:2 fingerprint",
|
|
||||||
"verificationWrongJid": "Wrong XMPP-address",
|
|
||||||
"verificationWrongDevice": "Wrong OMEMO:2 device",
|
|
||||||
"verificationNotInList": "Wrong OMEMO:2 device",
|
|
||||||
"verificationWrongFingerprint": "Wrong OMEMO:2 fingerprint"
|
|
||||||
},
|
|
||||||
"connection": {
|
|
||||||
"connectionTimeout": "Could not connect to server",
|
|
||||||
"saslAccountDisabled": "Your account is disabled",
|
|
||||||
"saslInvalidCredentials": "Your account credentials are invalid",
|
|
||||||
"unrecoverable": "Connection lost due to unrecoverable error"
|
|
||||||
},
|
|
||||||
"login": {
|
|
||||||
"saslFailed": "Invalid login credentials",
|
|
||||||
"startTlsFailed": "Failed to establish a secure connection",
|
|
||||||
"noConnection": "Failed to establish a connection",
|
|
||||||
"unspecified": "Unspecified error"
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"unspecified": "Unknown error",
|
|
||||||
"fileUploadFailed": "The file upload failed",
|
|
||||||
"contactDoesntSupportOmemo": "The contact does not support encryption using OMEMO:2",
|
|
||||||
"fileDownloadFailed": "The file download failed",
|
|
||||||
"serviceUnavailable": "The message could not be delivered to the contact",
|
|
||||||
"remoteServerTimeout": "The message could not be delivered to the contact's server",
|
|
||||||
"remoteServerNotFound": "The message could not be delivered to the contact's server as it cannot be found",
|
|
||||||
"failedToEncrypt": "The message could not be encrypted",
|
|
||||||
"failedToEncryptFile": "The file could not be encrypted",
|
|
||||||
"failedToDecryptFile": "The file could not be decrypted",
|
|
||||||
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
|
||||||
},
|
|
||||||
"conversation": {
|
|
||||||
"audioRecordingError": "Failed to finalize audio recording",
|
|
||||||
"openFileNoAppError": "No app found to open this file",
|
|
||||||
"openFileGenericError": "Failed to open file",
|
|
||||||
"messageErrorDialogTitle": "Error"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"warnings": {
|
|
||||||
"message": {
|
|
||||||
"integrityCheckFailed": "Could not verify file integrity"
|
|
||||||
},
|
|
||||||
"conversation": {
|
|
||||||
"holdForLonger": "Hold button longer to record a voice message"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pages": {
|
|
||||||
"intro": {
|
|
||||||
"noAccount": "Have no XMPP account? No worries, creating one is really easy.",
|
|
||||||
"loginButton": "Login",
|
|
||||||
"registerButton": "Register"
|
|
||||||
},
|
|
||||||
"login": {
|
|
||||||
"title": "Login",
|
|
||||||
"xmppAddress": "XMPP-Address",
|
|
||||||
"password": "Password",
|
|
||||||
"advancedOptions": "Advanced options",
|
|
||||||
"createAccount": "Create account on server"
|
|
||||||
},
|
|
||||||
"conversations": {
|
|
||||||
"speeddialNewChat": "New chat",
|
|
||||||
"speeddialJoinGroupchat": "Join groupchat",
|
|
||||||
"speeddialAddNoteToSelf": "Note to self",
|
|
||||||
"overlaySettings": "Settings",
|
|
||||||
"noOpenChats": "You have no open chats",
|
|
||||||
"startChat": "Start a chat",
|
|
||||||
"closeChat": "Close chat",
|
|
||||||
"closeChatBody": "Are you sure you want to close the chat with ${conversationTitle}?",
|
|
||||||
"markAsRead": "Mark as read"
|
|
||||||
},
|
|
||||||
"conversation": {
|
|
||||||
"unencrypted": "Unencrypted",
|
|
||||||
"encrypted": "Encrypted",
|
|
||||||
"closeChat": "Close chat",
|
|
||||||
"closeChatConfirmTitle": "Close chat",
|
|
||||||
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
|
||||||
"blockShort": "Block",
|
|
||||||
"blockUser": "Block user",
|
|
||||||
"online": "Online",
|
|
||||||
"retract": "Retract message",
|
|
||||||
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
|
|
||||||
"forward": "Forward",
|
|
||||||
"edit": "Edit",
|
|
||||||
"quote": "Quote",
|
|
||||||
"copy": "Copy content",
|
|
||||||
"addReaction": "Add reaction",
|
|
||||||
"showError": "Show error",
|
|
||||||
"showWarning": "Show warning",
|
|
||||||
"addToContacts": "Add to contacts",
|
|
||||||
"addToContactsTitle": "Add ${jid} to contacts",
|
|
||||||
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
|
|
||||||
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
|
||||||
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
|
||||||
"stickerSettings": "Sticker settings",
|
|
||||||
"newDeviceMessage": "${title} added a new encryption device",
|
|
||||||
"messageHint": "Send a message...",
|
|
||||||
"sendImages": "Send images",
|
|
||||||
"sendFiles": "Send files",
|
|
||||||
"takePhotos": "Take photos"
|
|
||||||
},
|
|
||||||
"addcontact": {
|
|
||||||
"title": "Add new contact",
|
|
||||||
"xmppAddress": "XMPP-Address",
|
|
||||||
"subtitle": "You can add a contact either by typing in their XMPP address or by scanning their QR code",
|
|
||||||
"buttonAddToContact": "Add to contacts"
|
|
||||||
},
|
|
||||||
"newconversation": {
|
|
||||||
"title": "Start new chat",
|
|
||||||
"addContact": "Add contact",
|
|
||||||
"createGroupchat": "Create groupchat"
|
|
||||||
},
|
|
||||||
"crop": {
|
|
||||||
"setProfilePicture": "Set as profile picture"
|
|
||||||
},
|
|
||||||
"shareselection": {
|
|
||||||
"shareWith": "Share with...",
|
|
||||||
"confirmTitle": "Send file",
|
|
||||||
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
|
||||||
},
|
|
||||||
"profile": {
|
|
||||||
"general": {
|
|
||||||
"omemo": "Security",
|
|
||||||
"profile": "Profile",
|
|
||||||
"media": "Media"
|
|
||||||
},
|
|
||||||
"conversation": {
|
|
||||||
"notifications": "Notifications",
|
|
||||||
"notificationsMuted": "Muted",
|
|
||||||
"notificationsEnabled": "Enabled",
|
|
||||||
"sharedMedia": "Media"
|
|
||||||
},
|
|
||||||
"owndevices": {
|
|
||||||
"title": "Own Devices",
|
|
||||||
"thisDevice": "This device",
|
|
||||||
"otherDevices": "Other devices",
|
|
||||||
"deleteDeviceConfirmTitle": "Delete device",
|
|
||||||
"deleteDeviceConfirmBody": "This means that contacts will not be able to encrypt for that device. Continue?",
|
|
||||||
"recreateOwnSessions": "Rebuild sessions",
|
|
||||||
"recreateOwnSessionsConfirmTitle": "Recreate own sessions?",
|
|
||||||
"recreateOwnSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
|
||||||
"recreateOwnDevice": "Recreate device",
|
|
||||||
"recreateOwnDeviceConfirmTitle": "Recreate own device?",
|
|
||||||
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
|
|
||||||
},
|
|
||||||
"devices": {
|
|
||||||
"title": "Security",
|
|
||||||
"recreateSessions": "Rebuild sessions",
|
|
||||||
"recreateSessionsConfirmTitle": "Rebuild sessions?",
|
|
||||||
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
|
||||||
"noSessions": "There are no cryptographic sessions that are used for end-to-end encryption."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"blocklist": {
|
|
||||||
"title": "Blocklist",
|
|
||||||
"noUsersBlocked": "You have no users blocked",
|
|
||||||
"unblockAll": "Unblock all",
|
|
||||||
"unblockAllConfirmTitle": "Are you sure?",
|
|
||||||
"unblockAllConfirmBody": "Are you sure you want to unblock all users?",
|
|
||||||
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
|
||||||
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
|
||||||
},
|
|
||||||
"cropbackground": {
|
|
||||||
"blur": "Blur background",
|
|
||||||
"setAsBackground": "Set as background image"
|
|
||||||
},
|
|
||||||
"stickerPack": {
|
|
||||||
"removeConfirmTitle": "Remove sticker pack",
|
|
||||||
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
|
|
||||||
"installConfirmTitle": "Install sticker pack",
|
|
||||||
"installConfirmBody": "Are you sure you want to install this sticker pack?",
|
|
||||||
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
|
|
||||||
"fetchingFailure": "Could not find the sticker pack"
|
|
||||||
},
|
|
||||||
"settings": {
|
|
||||||
"settings": {
|
|
||||||
"title": "Settings",
|
|
||||||
"conversationsSection": "Conversations",
|
|
||||||
"accountSection": "Account",
|
|
||||||
"signOut": "Sign out",
|
|
||||||
"signOutConfirmTitle": "Sign Out",
|
|
||||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
|
||||||
"miscellaneousSection": "Miscellaneous",
|
|
||||||
"debuggingSection": "Debugging",
|
|
||||||
"general": "General"
|
|
||||||
},
|
|
||||||
"about": {
|
|
||||||
"title": "About",
|
|
||||||
"licensed": "Licensed under GPL3",
|
|
||||||
"version": "Version ${version}",
|
|
||||||
"viewSourceCode": "View source code",
|
|
||||||
"nMoreToGo": "${n} more to go...",
|
|
||||||
"debugMenuShown": "You are now a developer!",
|
|
||||||
"debugMenuAlreadyShown": "You are already a developer!"
|
|
||||||
},
|
|
||||||
"appearance": {
|
|
||||||
"title": "Appearance",
|
|
||||||
"languageSection": "Language",
|
|
||||||
"language": "App language",
|
|
||||||
"languageSubtext": "Currently selected: $selectedLanguage",
|
|
||||||
"systemLanguage": "Default language"
|
|
||||||
},
|
|
||||||
"licenses": {
|
|
||||||
"title": "Open-Source Licenses",
|
|
||||||
"licensedUnder": "Licensed under $license"
|
|
||||||
},
|
|
||||||
"conversation": {
|
|
||||||
"title": "Chat",
|
|
||||||
"appearance": "Appearance",
|
|
||||||
"selectBackgroundImage": "Select background image",
|
|
||||||
"selectBackgroundImageDescription": "This image will be the background of all your chats",
|
|
||||||
"removeBackgroundImage": "Remove background image",
|
|
||||||
"removeBackgroundImageConfirmTitle": "Remove background image",
|
|
||||||
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
|
||||||
"newChatsSection": "New Conversations",
|
|
||||||
"newChatsMuteByDefault": "Mute new chats by default",
|
|
||||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
|
|
||||||
"behaviourSection": "Behaviour",
|
|
||||||
"contactsIntegration": "Contacts integration",
|
|
||||||
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
|
|
||||||
},
|
|
||||||
"debugging": {
|
|
||||||
"title": "Debugging options",
|
|
||||||
"generalSection": "General",
|
|
||||||
"generalEnableDebugging": "Enable debugging",
|
|
||||||
"generalEncryptionPassword": "Encryption password",
|
|
||||||
"generalEncryptionPasswordSubtext": "The logs may contain sensitive information so pick a strong passphrase",
|
|
||||||
"generalLoggingIp": "Logging IP",
|
|
||||||
"generalLoggingIpSubtext": "The IP the logs should be sent to",
|
|
||||||
"generalLoggingPort": "Logging Port",
|
|
||||||
"generalLoggingPortSubtext": "The IP the logs should be sent to"
|
|
||||||
},
|
|
||||||
"network": {
|
|
||||||
"title": "Network",
|
|
||||||
"automaticDownloadsSection": "Automatic Downloads",
|
|
||||||
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
|
||||||
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
|
||||||
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
|
||||||
"automaticDownloadAlways": "Always",
|
|
||||||
"wifi": "Wifi",
|
|
||||||
"mobileData": "Mobile data"
|
|
||||||
},
|
|
||||||
"privacy": {
|
|
||||||
"title": "Privacy",
|
|
||||||
"generalSection": "General",
|
|
||||||
"showContactRequests": "Show contact requests",
|
|
||||||
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
|
|
||||||
"profilePictureVisibility": "Make profile picture public",
|
|
||||||
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
|
|
||||||
"conversationsSection": "Conversation",
|
|
||||||
"sendChatMarkers": "Send chat markers",
|
|
||||||
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
|
|
||||||
"sendChatStates": "Send chat states",
|
|
||||||
"sendChatStatesSubtext": "This will show your conversation partner if you are typing or looking at the chat",
|
|
||||||
"redirectsSection": "Redirects",
|
|
||||||
"redirectText": "This will redirect $serviceName links that you tap to a proxy service, e.g. $exampleProxy",
|
|
||||||
"currentlySelected": "Currently selected: $proxy",
|
|
||||||
"redirectsTitle": "$serviceName Redirect",
|
|
||||||
"cannotEnableRedirect": "Cannot enable $serviceName redirects",
|
|
||||||
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
|
||||||
"urlEmpty": "URL cannot be empty",
|
|
||||||
"urlInvalid": "Invalid URL",
|
|
||||||
"redirectDialogTitle": "$serviceName Redirect",
|
|
||||||
"stickersPrivacy": "Keep sticker list public",
|
|
||||||
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
|
|
||||||
},
|
|
||||||
"stickers": {
|
|
||||||
"title": "Stickers",
|
|
||||||
"stickerSection": "Sticker",
|
|
||||||
"displayStickers": "Display stickers in chat",
|
|
||||||
"autoDownload": "Automatically download stickers",
|
|
||||||
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
|
|
||||||
"stickerPacksSection": "Sticker packs",
|
|
||||||
"importStickerPack": "Import sticker pack",
|
|
||||||
"importSuccess": "Sticker pack successfully imported",
|
|
||||||
"importFailure": "Failed to import sticker pack"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"@@name": "Deutsch",
|
"language": "Deutsch",
|
||||||
"global": {
|
"global": {
|
||||||
"title": "Moxxy",
|
"title": "Moxxy",
|
||||||
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
|
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
|
||||||
@@ -66,13 +66,18 @@
|
|||||||
"you": "Du"
|
"you": "Du"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
|
"general": {
|
||||||
|
"noInternet": "Keine Internetverbindung."
|
||||||
|
},
|
||||||
|
"filePicker": {
|
||||||
|
"permissionDenied": "Die Speicherberechtigung wurde nicht erteilt."
|
||||||
|
},
|
||||||
"omemo": {
|
"omemo": {
|
||||||
"couldNotPublish": "Konnte die kryptographische Identität nicht auf dem Server veröffentlichen. Ende-zu-Ende-Verschlüsselung funktioniert eventuell nicht.",
|
"couldNotPublish": "Konnte die kryptographische Identität nicht auf dem Server veröffentlichen. Ende-zu-Ende-Verschlüsselung funktioniert eventuell nicht.",
|
||||||
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
||||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||||
|
|
||||||
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||||
"verificationWrongJid": "Falsche XMPP-Addresse",
|
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||||
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||||
@@ -81,7 +86,7 @@
|
|||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"connectionTimeout": "Verbindung zum Server nicht möglich",
|
"connectionTimeout": "Verbindung zum Server nicht möglich",
|
||||||
"saslAccountDisabled": "Dein Account ist deaktiviert",
|
"saslAccountDisabled": "Dein Konto ist deaktiviert",
|
||||||
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
|
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
|
||||||
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
|
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
|
||||||
},
|
},
|
||||||
@@ -109,6 +114,11 @@
|
|||||||
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
||||||
"openFileGenericError": "Fehler beim Öffnen der Datei",
|
"openFileGenericError": "Fehler beim Öffnen der Datei",
|
||||||
"messageErrorDialogTitle": "Fehler"
|
"messageErrorDialogTitle": "Fehler"
|
||||||
|
},
|
||||||
|
"newChat": {
|
||||||
|
"remoteServerError": "Konnte den Server nicht erreichen.",
|
||||||
|
"groupchatUnsupported": "Das Beitreten eines Gruppenchats ist aktuell nicht unterstützt.",
|
||||||
|
"unknown": "Unbekannter Fehler."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
@@ -121,7 +131,7 @@
|
|||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"intro": {
|
"intro": {
|
||||||
"noAccount": "Kein XMPP-Account vorhanden? Einen zu erstellen ist sehr einfach.",
|
"noAccount": "Kein XMPP-Konto vorhanden? Keine Sorge, es ist ganz einfach, eines zu erstellen.",
|
||||||
"loginButton": "Einloggen",
|
"loginButton": "Einloggen",
|
||||||
"registerButton": "Registrieren"
|
"registerButton": "Registrieren"
|
||||||
},
|
},
|
||||||
@@ -130,7 +140,7 @@
|
|||||||
"xmppAddress": "XMPP-Adresse",
|
"xmppAddress": "XMPP-Adresse",
|
||||||
"password": "Passwort",
|
"password": "Passwort",
|
||||||
"advancedOptions": "Fortgeschrittene Optionen",
|
"advancedOptions": "Fortgeschrittene Optionen",
|
||||||
"createAccount": "Account auf dem Server erstellen"
|
"createAccount": "Konto auf dem Server erstellen"
|
||||||
},
|
},
|
||||||
"conversations": {
|
"conversations": {
|
||||||
"speeddialNewChat": "Neuer chat",
|
"speeddialNewChat": "Neuer chat",
|
||||||
@@ -158,30 +168,39 @@
|
|||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"quote": "Zitieren",
|
"quote": "Zitieren",
|
||||||
"copy": "Inhalt kopieren",
|
"copy": "Inhalt kopieren",
|
||||||
|
"messageCopied": "Nachrichteninhalt in die Zwischenablage kopiert",
|
||||||
"addReaction": "Reaktion hinzufügen",
|
"addReaction": "Reaktion hinzufügen",
|
||||||
"showError": "Fehler anzeigen",
|
"showError": "Fehler anzeigen",
|
||||||
"showWarning": "Warnung anzeigen",
|
"showWarning": "Warnung anzeigen",
|
||||||
|
"warning": "Warnung",
|
||||||
"addToContacts": "Zu Kontaken hinzufügen",
|
"addToContacts": "Zu Kontaken hinzufügen",
|
||||||
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
|
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
|
||||||
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
||||||
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||||
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||||
"stickerSettings": "Stickereinstellungen",
|
"stickerSettings": "Stickereinstellungen",
|
||||||
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
|
"newDeviceMessage": {
|
||||||
|
"one": "Ein neues Gerät wurde hinzugefügt",
|
||||||
|
"other": "Mehrere neue Geräte wurden hinzugefügt"
|
||||||
|
},
|
||||||
|
"replacedDeviceMessage": {
|
||||||
|
"one": "Ein Gerät hat sich verändert",
|
||||||
|
"other": "Mehrere Geräte haben sich verändert"
|
||||||
|
},
|
||||||
"messageHint": "Nachricht senden...",
|
"messageHint": "Nachricht senden...",
|
||||||
"sendImages": "Bilder senden",
|
"sendImages": "Bilder senden",
|
||||||
"sendFiles": "Dateien senden",
|
"sendFiles": "Dateien senden",
|
||||||
"takePhotos": "Bilder aufnehmen"
|
"takePhotos": "Bilder aufnehmen"
|
||||||
},
|
},
|
||||||
"addcontact": {
|
"startchat": {
|
||||||
"title": "Neuen Kontakt hinzufügen",
|
"title": "Neuer Chat",
|
||||||
"xmppAddress": "XMPP-Adresse",
|
"xmppAddress": "XMPP-Adresse",
|
||||||
"subtitle": "Du kannst einen Kontakt hinzufügen, indem Du entweder die XMPP-Adresse eingibst oder den QR-Code deines Kontaktes scannst",
|
"subtitle": "Du kannst einen neuen Chat beginnen, indem du entweder eine XMPP-Adresse eingibst oder einen QR-Code scannst.",
|
||||||
"buttonAddToContact": "Kontakt hinzufügen"
|
"buttonAddToContact": "Neuen Chat beginnen"
|
||||||
},
|
},
|
||||||
"newconversation": {
|
"newconversation": {
|
||||||
"title": "Neuer chat",
|
"title": "Neuer Chat",
|
||||||
"addContact": "Kontakt hinzufügen",
|
"startChat": "Neuen Chat beginnen",
|
||||||
"createGroupchat": "Gruppenchat erstellen"
|
"createGroupchat": "Gruppenchat erstellen"
|
||||||
},
|
},
|
||||||
"crop": {
|
"crop": {
|
||||||
@@ -250,7 +269,7 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
"conversationsSection": "Unterhaltungen",
|
"conversationsSection": "Unterhaltungen",
|
||||||
"accountSection": "Account",
|
"accountSection": "Konto",
|
||||||
"signOut": "Abmelden",
|
"signOut": "Abmelden",
|
||||||
"signOutConfirmTitle": "Abmelden",
|
"signOutConfirmTitle": "Abmelden",
|
||||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||||
@@ -311,7 +330,7 @@
|
|||||||
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
||||||
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
||||||
"automaticDownloadAlways": "Immer",
|
"automaticDownloadAlways": "Immer",
|
||||||
"wifi": "Wifi",
|
"wifi": "WLAN",
|
||||||
"mobileData": "Mobile Daten"
|
"mobileData": "Mobile Daten"
|
||||||
},
|
},
|
||||||
"privacy": {
|
"privacy": {
|
||||||
@@ -347,7 +366,43 @@
|
|||||||
"stickerPacksSection": "Stickerpacks",
|
"stickerPacksSection": "Stickerpacks",
|
||||||
"importStickerPack": "Stickerpack importieren",
|
"importStickerPack": "Stickerpack importieren",
|
||||||
"importSuccess": "Stickerpack erfolgreich importiert",
|
"importSuccess": "Stickerpack erfolgreich importiert",
|
||||||
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten"
|
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten",
|
||||||
|
"stickerPackSize": "(${size})"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"title": "Speicher",
|
||||||
|
"sizePlaceholder": "Berechne...",
|
||||||
|
"storageManagement": "Speicherverwaltung",
|
||||||
|
"removeOldMedia": {
|
||||||
|
"title": "Alte Medien entfernen",
|
||||||
|
"description": "Löscht alte Medien vom Gerät"
|
||||||
|
},
|
||||||
|
"removeOldMediaDialog": {
|
||||||
|
"title": "Medien löschen",
|
||||||
|
"options": {
|
||||||
|
"all": "Alle Medien",
|
||||||
|
"oneMonth": "Älter als 1 Monat",
|
||||||
|
"oneWeek": "Älter als 1 Woche"
|
||||||
|
},
|
||||||
|
"delete": "Löschen",
|
||||||
|
"confirmation": {
|
||||||
|
"body": "Bist Du dir sicher, dass du alte Medien löschen möchtest?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"viewMediaFiles": "Medien anzeigen",
|
||||||
|
"mediaFiles": "Medien",
|
||||||
|
"types": {
|
||||||
|
"media": "Medien",
|
||||||
|
"stickers": "Sticker"
|
||||||
|
},
|
||||||
|
"manageStickers": "Stickerpacks verwalten",
|
||||||
|
"storageUsed": "Speicherplatz verbraucht: ${size}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sharedMedia": {
|
||||||
|
"empty": {
|
||||||
|
"chat": "Keine Medien für diesen Chat vorhanden",
|
||||||
|
"general": "Keine Medien vorhanden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
413
assets/i18n/strings_en.i18n.json
Normal file
413
assets/i18n/strings_en.i18n.json
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
{
|
||||||
|
"language": "English",
|
||||||
|
"global": {
|
||||||
|
"title": "Moxxy",
|
||||||
|
"moxxySubtitle": "An experiment into building a modern, easy and beautiful XMPP client.",
|
||||||
|
"dialogAccept": "Okay",
|
||||||
|
"dialogCancel": "Cancel",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"permanent": {
|
||||||
|
"idle": "Idle",
|
||||||
|
"ready": "Ready to receive messages",
|
||||||
|
"connecting": "Connecting...",
|
||||||
|
"disconnect": "Disconnected",
|
||||||
|
"error": "Error"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"reply": "Reply",
|
||||||
|
"markAsRead": "Mark as read"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"messagesChannelName": "Messages",
|
||||||
|
"messagesChannelDescription": "The notification channel for received messages",
|
||||||
|
"warningChannelName": "Warnings",
|
||||||
|
"warningChannelDescription": "Warnings related to Moxxy"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"error": "Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dateTime": {
|
||||||
|
"justNow": "Just now",
|
||||||
|
"nMinutesAgo": "${min}min ago",
|
||||||
|
"mondayAbbrev": "Mon",
|
||||||
|
"tuesdayAbbrev": "Tue",
|
||||||
|
"wednessdayAbbrev": "Wed",
|
||||||
|
"thursdayAbbrev": "Thu",
|
||||||
|
"fridayAbbrev": "Fri",
|
||||||
|
"saturdayAbbrev": "Sat",
|
||||||
|
"sundayAbbrev": "Sun",
|
||||||
|
"january": "January",
|
||||||
|
"february": "February",
|
||||||
|
"march": "March",
|
||||||
|
"april": "April",
|
||||||
|
"may": "May",
|
||||||
|
"june": "June",
|
||||||
|
"july": "July",
|
||||||
|
"august": "August",
|
||||||
|
"september": "September",
|
||||||
|
"october": "October",
|
||||||
|
"november": "November",
|
||||||
|
"december": "December",
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"image": "Image",
|
||||||
|
"video": "Video",
|
||||||
|
"audio": "Audio",
|
||||||
|
"file": "File",
|
||||||
|
"sticker": "Sticker",
|
||||||
|
"retracted": "The message has been retracted",
|
||||||
|
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||||
|
"you": "You"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"general": {
|
||||||
|
"noInternet": "Not connected to the Internet."
|
||||||
|
},
|
||||||
|
"filePicker": {
|
||||||
|
"permissionDenied": "The storage permission has been denied."
|
||||||
|
},
|
||||||
|
"omemo": {
|
||||||
|
"couldNotPublish": "Could not publish the cryptographic identity to the server. This means that end-to-end encryption may not work.",
|
||||||
|
"notEncryptedForDevice": "This message was not encrypted for this device",
|
||||||
|
"invalidHmac": "Could not decrypt message",
|
||||||
|
"noDecryptionKey": "No decryption key available",
|
||||||
|
"messageInvalidAfixElement": "Invalid encrypted message",
|
||||||
|
|
||||||
|
"verificationInvalidOmemoUrl": "Invalid OMEMO:2 fingerprint",
|
||||||
|
"verificationWrongJid": "Wrong XMPP-address",
|
||||||
|
"verificationWrongDevice": "Wrong OMEMO:2 device",
|
||||||
|
"verificationNotInList": "Wrong OMEMO:2 device",
|
||||||
|
"verificationWrongFingerprint": "Wrong OMEMO:2 fingerprint"
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"connectionTimeout": "Could not connect to server",
|
||||||
|
"saslAccountDisabled": "Your account is disabled",
|
||||||
|
"saslInvalidCredentials": "Your account credentials are invalid",
|
||||||
|
"unrecoverable": "Connection lost due to unrecoverable error"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"saslFailed": "Invalid login credentials",
|
||||||
|
"startTlsFailed": "Failed to establish a secure connection",
|
||||||
|
"noConnection": "Failed to establish a connection",
|
||||||
|
"unspecified": "Unspecified error"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"unspecified": "Unknown error",
|
||||||
|
"fileUploadFailed": "The file upload failed",
|
||||||
|
"contactDoesntSupportOmemo": "The contact does not support encryption using OMEMO:2",
|
||||||
|
"fileDownloadFailed": "The file download failed",
|
||||||
|
"serviceUnavailable": "The message could not be delivered to the contact",
|
||||||
|
"remoteServerTimeout": "The message could not be delivered to the contact's server",
|
||||||
|
"remoteServerNotFound": "The message could not be delivered to the contact's server as it cannot be found",
|
||||||
|
"failedToEncrypt": "The message could not be encrypted",
|
||||||
|
"failedToEncryptFile": "The file could not be encrypted",
|
||||||
|
"failedToDecryptFile": "The file could not be decrypted",
|
||||||
|
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"audioRecordingError": "Failed to finalize audio recording",
|
||||||
|
"openFileNoAppError": "No app found to open this file",
|
||||||
|
"openFileGenericError": "Failed to open file",
|
||||||
|
"messageErrorDialogTitle": "Error"
|
||||||
|
},
|
||||||
|
"newChat": {
|
||||||
|
"remoteServerError": "Failed to contact the remote server.",
|
||||||
|
"groupchatUnsupported": "Joining a groupchat is currently not supported.",
|
||||||
|
"unknown": "Unknown error."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings": {
|
||||||
|
"message": {
|
||||||
|
"integrityCheckFailed": "Could not verify file integrity"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "Hold button longer to record a voice message"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"intro": {
|
||||||
|
"noAccount": "Have no XMPP account? No worries, creating one is really easy.",
|
||||||
|
"loginButton": "Login",
|
||||||
|
"registerButton": "Register"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Login",
|
||||||
|
"xmppAddress": "XMPP-Address",
|
||||||
|
"password": "Password",
|
||||||
|
"advancedOptions": "Advanced options",
|
||||||
|
"createAccount": "Create account on server"
|
||||||
|
},
|
||||||
|
"conversations": {
|
||||||
|
"speeddialNewChat": "New chat",
|
||||||
|
"speeddialJoinGroupchat": "Join groupchat",
|
||||||
|
"speeddialAddNoteToSelf": "Note to self",
|
||||||
|
"overlaySettings": "Settings",
|
||||||
|
"noOpenChats": "You have no open chats",
|
||||||
|
"startChat": "Start a chat",
|
||||||
|
"closeChat": "Close chat",
|
||||||
|
"closeChatBody": "Are you sure you want to close the chat with ${conversationTitle}?",
|
||||||
|
"markAsRead": "Mark as read"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"unencrypted": "Unencrypted",
|
||||||
|
"encrypted": "Encrypted",
|
||||||
|
"closeChat": "Close chat",
|
||||||
|
"closeChatConfirmTitle": "Close chat",
|
||||||
|
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
||||||
|
"blockShort": "Block",
|
||||||
|
"blockUser": "Block user",
|
||||||
|
"online": "Online",
|
||||||
|
"retract": "Retract message",
|
||||||
|
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
|
||||||
|
"forward": "Forward",
|
||||||
|
"edit": "Edit",
|
||||||
|
"quote": "Quote",
|
||||||
|
"copy": "Copy content",
|
||||||
|
"messageCopied": "Message content copied to clipboard",
|
||||||
|
"addReaction": "Add reaction",
|
||||||
|
"showError": "Show error",
|
||||||
|
"showWarning": "Show warning",
|
||||||
|
"warning": "Warning",
|
||||||
|
"addToContacts": "Add to contacts",
|
||||||
|
"addToContactsTitle": "Add ${jid} to contacts",
|
||||||
|
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
|
||||||
|
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
||||||
|
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
||||||
|
"stickerSettings": "Sticker settings",
|
||||||
|
"newDeviceMessage": {
|
||||||
|
"one": "A new device has been added",
|
||||||
|
"other": "Multiple new devices have been added"
|
||||||
|
},
|
||||||
|
"replacedDeviceMessage": {
|
||||||
|
"one": "A device has been changed",
|
||||||
|
"other": "Multiple devices have been added"
|
||||||
|
},
|
||||||
|
"messageHint": "Send a message...",
|
||||||
|
"sendImages": "Send images",
|
||||||
|
"sendFiles": "Send files",
|
||||||
|
"takePhotos": "Take photos"
|
||||||
|
},
|
||||||
|
"startchat": {
|
||||||
|
"title": "New Chat",
|
||||||
|
"xmppAddress": "XMPP address",
|
||||||
|
"subtitle": "You can start a new chat by either entering a XMPP address or by scanning their QR code.",
|
||||||
|
"buttonAddToContact": "Start new chat"
|
||||||
|
},
|
||||||
|
"newconversation": {
|
||||||
|
"title": "New chat",
|
||||||
|
"startChat": "Start new chat",
|
||||||
|
"createGroupchat": "New groupchat"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"setProfilePicture": "Set as profile picture"
|
||||||
|
},
|
||||||
|
"shareselection": {
|
||||||
|
"shareWith": "Share with...",
|
||||||
|
"confirmTitle": "Send file",
|
||||||
|
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"general": {
|
||||||
|
"omemo": "Security",
|
||||||
|
"profile": "Profile",
|
||||||
|
"media": "Media"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"notificationsMuted": "Muted",
|
||||||
|
"notificationsEnabled": "Enabled",
|
||||||
|
"sharedMedia": "Media"
|
||||||
|
},
|
||||||
|
"owndevices": {
|
||||||
|
"title": "Own Devices",
|
||||||
|
"thisDevice": "This device",
|
||||||
|
"otherDevices": "Other devices",
|
||||||
|
"deleteDeviceConfirmTitle": "Delete device",
|
||||||
|
"deleteDeviceConfirmBody": "This means that contacts will not be able to encrypt for that device. Continue?",
|
||||||
|
"recreateOwnSessions": "Rebuild sessions",
|
||||||
|
"recreateOwnSessionsConfirmTitle": "Recreate own sessions?",
|
||||||
|
"recreateOwnSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
||||||
|
"recreateOwnDevice": "Recreate device",
|
||||||
|
"recreateOwnDeviceConfirmTitle": "Recreate own device?",
|
||||||
|
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"title": "Security",
|
||||||
|
"recreateSessions": "Rebuild sessions",
|
||||||
|
"recreateSessionsConfirmTitle": "Rebuild sessions?",
|
||||||
|
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
||||||
|
"noSessions": "There are no cryptographic sessions that are used for end-to-end encryption."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"blocklist": {
|
||||||
|
"title": "Blocklist",
|
||||||
|
"noUsersBlocked": "You have no users blocked",
|
||||||
|
"unblockAll": "Unblock all",
|
||||||
|
"unblockAllConfirmTitle": "Are you sure?",
|
||||||
|
"unblockAllConfirmBody": "Are you sure you want to unblock all users?",
|
||||||
|
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
||||||
|
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
||||||
|
},
|
||||||
|
"cropbackground": {
|
||||||
|
"blur": "Blur background",
|
||||||
|
"setAsBackground": "Set as background image"
|
||||||
|
},
|
||||||
|
"stickerPack": {
|
||||||
|
"removeConfirmTitle": "Remove sticker pack",
|
||||||
|
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
|
||||||
|
"installConfirmTitle": "Install sticker pack",
|
||||||
|
"installConfirmBody": "Are you sure you want to install this sticker pack?",
|
||||||
|
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
|
||||||
|
"fetchingFailure": "Could not find the sticker pack"
|
||||||
|
},
|
||||||
|
"sharedMedia": {
|
||||||
|
"empty": {
|
||||||
|
"chat": "No shared media for this chat",
|
||||||
|
"general": "No media files available"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"conversationsSection": "Conversations",
|
||||||
|
"accountSection": "Account",
|
||||||
|
"signOut": "Sign out",
|
||||||
|
"signOutConfirmTitle": "Sign Out",
|
||||||
|
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||||
|
"miscellaneousSection": "Miscellaneous",
|
||||||
|
"debuggingSection": "Debugging",
|
||||||
|
"general": "General"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About",
|
||||||
|
"licensed": "Licensed under GPL3",
|
||||||
|
"version": "Version ${version}",
|
||||||
|
"viewSourceCode": "View source code",
|
||||||
|
"nMoreToGo": "${n} more to go...",
|
||||||
|
"debugMenuShown": "You are now a developer!",
|
||||||
|
"debugMenuAlreadyShown": "You are already a developer!"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Appearance",
|
||||||
|
"languageSection": "Language",
|
||||||
|
"language": "App language",
|
||||||
|
"languageSubtext": "Currently selected: $selectedLanguage",
|
||||||
|
"systemLanguage": "Default language"
|
||||||
|
},
|
||||||
|
"licenses": {
|
||||||
|
"title": "Open-Source Licenses",
|
||||||
|
"licensedUnder": "Licensed under $license"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"title": "Chat",
|
||||||
|
"appearance": "Appearance",
|
||||||
|
"selectBackgroundImage": "Select background image",
|
||||||
|
"selectBackgroundImageDescription": "This image will be the background of all your chats",
|
||||||
|
"removeBackgroundImage": "Remove background image",
|
||||||
|
"removeBackgroundImageConfirmTitle": "Remove background image",
|
||||||
|
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
||||||
|
"newChatsSection": "New Conversations",
|
||||||
|
"newChatsMuteByDefault": "Mute new chats by default",
|
||||||
|
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
|
||||||
|
"behaviourSection": "Behaviour",
|
||||||
|
"contactsIntegration": "Contacts integration",
|
||||||
|
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
|
||||||
|
},
|
||||||
|
"debugging": {
|
||||||
|
"title": "Debugging options",
|
||||||
|
"generalSection": "General",
|
||||||
|
"generalEnableDebugging": "Enable debugging",
|
||||||
|
"generalEncryptionPassword": "Encryption password",
|
||||||
|
"generalEncryptionPasswordSubtext": "The logs may contain sensitive information so pick a strong passphrase",
|
||||||
|
"generalLoggingIp": "Logging IP",
|
||||||
|
"generalLoggingIpSubtext": "The IP the logs should be sent to",
|
||||||
|
"generalLoggingPort": "Logging Port",
|
||||||
|
"generalLoggingPortSubtext": "The IP the logs should be sent to"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Network",
|
||||||
|
"automaticDownloadsSection": "Automatic Downloads",
|
||||||
|
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
||||||
|
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
||||||
|
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
||||||
|
"automaticDownloadAlways": "Always",
|
||||||
|
"wifi": "Wifi",
|
||||||
|
"mobileData": "Mobile data"
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Privacy",
|
||||||
|
"generalSection": "General",
|
||||||
|
"showContactRequests": "Show contact requests",
|
||||||
|
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
|
||||||
|
"profilePictureVisibility": "Make profile picture public",
|
||||||
|
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
|
||||||
|
"conversationsSection": "Conversation",
|
||||||
|
"sendChatMarkers": "Send chat markers",
|
||||||
|
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
|
||||||
|
"sendChatStates": "Send chat states",
|
||||||
|
"sendChatStatesSubtext": "This will show your conversation partner if you are typing or looking at the chat",
|
||||||
|
"redirectsSection": "Redirects",
|
||||||
|
"redirectText": "This will redirect $serviceName links that you tap to a proxy service, e.g. $exampleProxy",
|
||||||
|
"currentlySelected": "Currently selected: $proxy",
|
||||||
|
"redirectsTitle": "$serviceName Redirect",
|
||||||
|
"cannotEnableRedirect": "Cannot enable $serviceName redirects",
|
||||||
|
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
||||||
|
"urlEmpty": "URL cannot be empty",
|
||||||
|
"urlInvalid": "Invalid URL",
|
||||||
|
"redirectDialogTitle": "$serviceName Redirect",
|
||||||
|
"stickersPrivacy": "Keep sticker list public",
|
||||||
|
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
|
||||||
|
},
|
||||||
|
"stickers": {
|
||||||
|
"title": "Stickers",
|
||||||
|
"stickerSection": "Sticker",
|
||||||
|
"displayStickers": "Display stickers in chat",
|
||||||
|
"autoDownload": "Automatically download stickers",
|
||||||
|
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
|
||||||
|
"stickerPacksSection": "Sticker packs",
|
||||||
|
"importStickerPack": "Import sticker pack",
|
||||||
|
"importSuccess": "Sticker pack successfully imported",
|
||||||
|
"importFailure": "Failed to import sticker pack",
|
||||||
|
"stickerPackSize": "(${size})"
|
||||||
|
},
|
||||||
|
"stickerPacks": {
|
||||||
|
"title": "Sticker Packs"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"title": "Storage",
|
||||||
|
"storageUsed": "Storage used: ${size}",
|
||||||
|
"sizePlaceholder": "Computing...",
|
||||||
|
"storageManagement": "Storage Management",
|
||||||
|
"removeOldMedia" : {
|
||||||
|
"title": "Remove old media",
|
||||||
|
"description": "Removes old media files from the device"
|
||||||
|
},
|
||||||
|
"removeOldMediaDialog": {
|
||||||
|
"title": "Delete media files",
|
||||||
|
"options": {
|
||||||
|
"all": "All media files",
|
||||||
|
"oneWeek": "Older than 1 week",
|
||||||
|
"oneMonth": "Older than 1 month"
|
||||||
|
},
|
||||||
|
"delete": "Delete",
|
||||||
|
"confirmation": {
|
||||||
|
"body": "Are you sure you want to delete old media files?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"viewMediaFiles": "View media files",
|
||||||
|
"mediaFiles": "Media Files",
|
||||||
|
"types": {
|
||||||
|
"media": "Media",
|
||||||
|
"stickers": "Stickers"
|
||||||
|
},
|
||||||
|
"manageStickers": "Manage sticker packs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
assets/i18n/strings_ja.i18n.json
Normal file
90
assets/i18n/strings_ja.i18n.json
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"language": "日本語",
|
||||||
|
"global": {
|
||||||
|
"yes": "はい",
|
||||||
|
"no": "いいえ",
|
||||||
|
"dialogCancel": "キャンセル"
|
||||||
|
},
|
||||||
|
"dateTime": {
|
||||||
|
"thursdayAbbrev": "木",
|
||||||
|
"fridayAbbrev": "金",
|
||||||
|
"saturdayAbbrev": "土",
|
||||||
|
"january": "1月",
|
||||||
|
"february": "2月",
|
||||||
|
"march": "3月",
|
||||||
|
"may": "5月",
|
||||||
|
"june": "6月",
|
||||||
|
"july": "7月",
|
||||||
|
"september": "9月",
|
||||||
|
"october": "10月",
|
||||||
|
"justNow": "ちょうど今",
|
||||||
|
"nMinutesAgo": "${min}分前",
|
||||||
|
"mondayAbbrev": "月",
|
||||||
|
"tuesdayAbbrev": "火",
|
||||||
|
"wednessdayAbbrev": "水",
|
||||||
|
"sundayAbbrev": "日",
|
||||||
|
"april": "4月",
|
||||||
|
"august": "8月",
|
||||||
|
"november": "11月",
|
||||||
|
"december": "12月",
|
||||||
|
"today": "今日",
|
||||||
|
"yesterday": "昨日"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"audio": "音声",
|
||||||
|
"you": "自分",
|
||||||
|
"image": "画像",
|
||||||
|
"video": "ビデオ",
|
||||||
|
"file": "ファイル",
|
||||||
|
"sticker": "スタンプ",
|
||||||
|
"retracted": "メッセージ取り消された"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"connection": {
|
||||||
|
"connectionTimeout": "サーバー接続中にタイムアウトが発生しました",
|
||||||
|
"saslInvalidCredentials": "ユーザー名またはパスワードが無効"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"noConnection": "接続できませんでした",
|
||||||
|
"startTlsFailed": "接続できませんでした"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"fileUploadFailed": "アップロードに失敗しました",
|
||||||
|
"fileDownloadFailed": "ダウンロードに失敗しました",
|
||||||
|
"serviceUnavailable": "配信に失敗しました"
|
||||||
|
},
|
||||||
|
"omemo": {
|
||||||
|
"notEncryptedForDevice": "このデバイス向けにメッセージは暗号化されませんでした"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"messageErrorDialogTitle": "エラー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings": {
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "長押しすると音声記録できます"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"intro": {
|
||||||
|
"loginButton": "ログイン",
|
||||||
|
"registerButton": "新規登録",
|
||||||
|
"noAccount": "XMPPアドレスをお持ちですか?XMPPアドレスの作成は簡単です。"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "ログイン",
|
||||||
|
"xmppAddress": "XMPPアドレス",
|
||||||
|
"password": "パスワード",
|
||||||
|
"advancedOptions": "詳細設定"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"permanent": {
|
||||||
|
"connecting": "接続中…"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"reply": "返信",
|
||||||
|
"markAsRead": "既読"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
412
assets/i18n/strings_nl.i18n.json
Normal file
412
assets/i18n/strings_nl.i18n.json
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
{
|
||||||
|
"language": "Nederlands",
|
||||||
|
"global": {
|
||||||
|
"title": "Moxxy",
|
||||||
|
"dialogAccept": "Oké",
|
||||||
|
"dialogCancel": "Annuleren",
|
||||||
|
"yes": "Ja",
|
||||||
|
"no": "Nee",
|
||||||
|
"moxxySubtitle": "Een xmpp-experiment: het bouwen van een moderne, eenvoudige en mooie client."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"permanent": {
|
||||||
|
"idle": "Inactief",
|
||||||
|
"ready": "Klaar om berichten te ontvangen",
|
||||||
|
"connecting": "Bezig met verbinden…",
|
||||||
|
"disconnect": "Verbinding verbroken",
|
||||||
|
"error": "Foutmelding"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"reply": "Beantwoorden",
|
||||||
|
"markAsRead": "Markeren als gelezen"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"messagesChannelName": "Berichten",
|
||||||
|
"warningChannelName": "Waarschuwingen",
|
||||||
|
"warningChannelDescription": "Aan Moxxy gerelateerde waarschuwingen",
|
||||||
|
"messagesChannelDescription": "Het meldingskanaal voor het ontvangen van berichten"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"error": "Foutmelding"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dateTime": {
|
||||||
|
"justNow": "Zojuist",
|
||||||
|
"nMinutesAgo": "${min} min. geleden",
|
||||||
|
"mondayAbbrev": "ma",
|
||||||
|
"tuesdayAbbrev": "di",
|
||||||
|
"wednessdayAbbrev": "woe",
|
||||||
|
"thursdayAbbrev": "do",
|
||||||
|
"fridayAbbrev": "vrij",
|
||||||
|
"saturdayAbbrev": "za",
|
||||||
|
"sundayAbbrev": "zo",
|
||||||
|
"january": "januari",
|
||||||
|
"february": "februari",
|
||||||
|
"march": "maart",
|
||||||
|
"april": "april",
|
||||||
|
"may": "mei",
|
||||||
|
"june": "juni",
|
||||||
|
"july": "juli",
|
||||||
|
"august": "augustus",
|
||||||
|
"september": "september",
|
||||||
|
"october": "oktober",
|
||||||
|
"november": "november",
|
||||||
|
"december": "december",
|
||||||
|
"today": "Vandaag",
|
||||||
|
"yesterday": "Gisteren"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"image": "Afbeelding",
|
||||||
|
"video": "Video",
|
||||||
|
"audio": "Audio",
|
||||||
|
"file": "Bestand",
|
||||||
|
"sticker": "Sticker",
|
||||||
|
"retracted": "Dit bericht is herroepen",
|
||||||
|
"retractedFallback": "Er is een eerder bericht herroepen, maar je client heeft hier geen ondersteuning voor",
|
||||||
|
"you": "Ik"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"filePicker": {
|
||||||
|
"permissionDenied": "Het opslagrecht is geweigerd."
|
||||||
|
},
|
||||||
|
"omemo": {
|
||||||
|
"notEncryptedForDevice": "Dit bericht is niet versleuteld op dit apparaat",
|
||||||
|
"invalidHmac": "Het bericht kan niet worden ontsleuteld",
|
||||||
|
"noDecryptionKey": "Er is geen sleutel beschikbaar",
|
||||||
|
"messageInvalidAfixElement": "Het bericht is ongeldig versleuteld",
|
||||||
|
"verificationInvalidOmemoUrl": "Ongeldige OMEMO:2-vingerafdruk",
|
||||||
|
"verificationWrongJid": "Ongeldig xmpp-adres",
|
||||||
|
"verificationWrongDevice": "Ongeldig OMEMO:2-apparaat",
|
||||||
|
"verificationNotInList": "Ongeldig OMEMO:2-apparaat",
|
||||||
|
"verificationWrongFingerprint": "Ongeldige OMEMO:2-vingerafdruk",
|
||||||
|
"couldNotPublish": "De versleutelde identiteit kan niet worden gepubliceerd op de server. Hierdoor werkt eind-tot-eindversleuteling mogelijk niet."
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"saslAccountDisabled": "Je account is uitgeschakeld",
|
||||||
|
"saslInvalidCredentials": "Je inloggegevens zijn ongeldig",
|
||||||
|
"unrecoverable": "De verbinding is verbroken wegens een onoplosbare fout",
|
||||||
|
"connectionTimeout": "Er kan geen verbinding worden gemaakt met de server"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"saslFailed": "De inloggegevens zijn ongeldig",
|
||||||
|
"noConnection": "Er kan geen verbinding worden opgezet",
|
||||||
|
"unspecified": "Onbekende foutmelding",
|
||||||
|
"startTlsFailed": "Er kan geen beveiligde verbinding worden opgezet"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"unspecified": "Onbekende foutmelding",
|
||||||
|
"fileUploadFailed": "Het bestand kan niet worden geüpload",
|
||||||
|
"contactDoesntSupportOmemo": "Deze contactpersoon heeft geen ondersteuning voon OMEMO:2-versleuteling",
|
||||||
|
"fileDownloadFailed": "Het bestand kan niet worden opgehaald",
|
||||||
|
"serviceUnavailable": "Het bericht kan niet worden bezorgd",
|
||||||
|
"remoteServerTimeout": "Het bericht kan niet worden verstuurd naar de server",
|
||||||
|
"failedToEncrypt": "Het bericht kan niet worden versleuteld",
|
||||||
|
"failedToEncryptFile": "Het bestand kan niet worden versleuteld",
|
||||||
|
"failedToDecryptFile": "Het bestand kan niet worden ontsleuteld",
|
||||||
|
"fileNotEncrypted": "Het gesprek is versleuteld, maar het bestand niet",
|
||||||
|
"remoteServerNotFound": "Het bericht kan niet worden verstuurd naar de server omdat het niet bestaat"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"audioRecordingError": "De audio-opname kan niet worden afgerond",
|
||||||
|
"openFileGenericError": "Het bestand kan niet worden geopend",
|
||||||
|
"messageErrorDialogTitle": "Foutmelding",
|
||||||
|
"openFileNoAppError": "Er is geen app die dit bestand kan openen"
|
||||||
|
},
|
||||||
|
"general": {
|
||||||
|
"noInternet": "Er is geen internetverbinding."
|
||||||
|
},
|
||||||
|
"newChat": {
|
||||||
|
"groupchatUnsupported": "Deelnemen aan groepsgesprekken wordt momenteel niet ondersteund.",
|
||||||
|
"unknown": "Onbekende foutmelding.",
|
||||||
|
"remoteServerError": "Er kan geen verbinding worden gemaakt met de externe server."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"warnings": {
|
||||||
|
"message": {
|
||||||
|
"integrityCheckFailed": "De bestandsintegriteit kan niet worden vastgesteld"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "Houd langer ingedrukt om een spraakbericht op te nemen"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"intro": {
|
||||||
|
"loginButton": "Inloggen",
|
||||||
|
"registerButton": "Registreren",
|
||||||
|
"noAccount": "Geen xmpp-account? Geen zorgen: je maakt er in een handomdraai een aan."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Inloggen",
|
||||||
|
"xmppAddress": "Xmpp-adres",
|
||||||
|
"password": "Wachtwoord",
|
||||||
|
"advancedOptions": "Geavanceerde opties",
|
||||||
|
"createAccount": "Account aanmaken op server"
|
||||||
|
},
|
||||||
|
"conversations": {
|
||||||
|
"speeddialNewChat": "Nieuw gesprek",
|
||||||
|
"speeddialJoinGroupchat": "Deelnemen aan groepsgesprek",
|
||||||
|
"speeddialAddNoteToSelf": "Zelfmemo",
|
||||||
|
"overlaySettings": "Instellingen",
|
||||||
|
"noOpenChats": "Er zijn geen openstaande gesprekken",
|
||||||
|
"startChat": "Gesprek starten",
|
||||||
|
"closeChat": "Gesprek sluiten",
|
||||||
|
"closeChatBody": "Weet je zeker dat je het gesprek “${conversationTitle}” wilt sluiten?",
|
||||||
|
"markAsRead": "Markeren als gelezen"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"unencrypted": "Onversleuteld",
|
||||||
|
"encrypted": "Versleuteld",
|
||||||
|
"closeChat": "Gesprek sluiten",
|
||||||
|
"closeChatConfirmSubtext": "Weet je zeker dat je dit gesprek wilt sluiten?",
|
||||||
|
"blockShort": "Blokkeren",
|
||||||
|
"blockUser": "Gebruiker blokkeren",
|
||||||
|
"online": "Online",
|
||||||
|
"retract": "Bericht herroepen",
|
||||||
|
"forward": "Doorsturen",
|
||||||
|
"edit": "Bewerken",
|
||||||
|
"quote": "Citeren",
|
||||||
|
"copy": "Inhoud kopiëren",
|
||||||
|
"addReaction": "Reageren",
|
||||||
|
"showError": "Foutmelding tonen",
|
||||||
|
"showWarning": "Waarschuwing tonen",
|
||||||
|
"addToContacts": "Toevoegen aan contactpersonen",
|
||||||
|
"addToContactsTitle": "${jid} toevoegen aan contactpersonen",
|
||||||
|
"addToContactsBody": "Weet je zeker dat je ${jid} wilt toevoegen aan je contactpersonen?",
|
||||||
|
"stickerPickerNoStickersLine1": "Er zijn geen stickerpakketten beschikbaar.",
|
||||||
|
"stickerSettings": "Stickerinstellingen",
|
||||||
|
"newDeviceMessage": {
|
||||||
|
"one": "Er is een nieuw apparaat toegevoegd",
|
||||||
|
"other": "Er zijn meerdere nieuwe apparaten toegevoegd"
|
||||||
|
},
|
||||||
|
"replacedDeviceMessage": {
|
||||||
|
"one": "Er is een apparaat gewijzigd",
|
||||||
|
"other": "Er zijn meerdere apparaten toegevoegd"
|
||||||
|
},
|
||||||
|
"messageHint": "Verstuur een bericht…",
|
||||||
|
"sendImages": "Afbeeldingen versturen",
|
||||||
|
"sendFiles": "Bestanden versturen",
|
||||||
|
"closeChatConfirmTitle": "Gesprek sluiten",
|
||||||
|
"retractBody": "Weet je zeker dat je dit bericht wilt herroepen? Dit is slechts een verzoek aan de client dat niet in acht hoeft te worden genomen.",
|
||||||
|
"stickerPickerNoStickersLine2": "Installeer pakketten via de stickerinstellingen.",
|
||||||
|
"takePhotos": "Foto's maken",
|
||||||
|
"warning": "Waarschuwing",
|
||||||
|
"messageCopied": "De berichtinhoud is gekopieerd naar het klembord"
|
||||||
|
},
|
||||||
|
"newconversation": {
|
||||||
|
"title": "Nieuw gesprek",
|
||||||
|
"startChat": "Gesprek starten",
|
||||||
|
"createGroupchat": "Nieuw groepsgesprek"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"setProfilePicture": "Instellen als profielfoto"
|
||||||
|
},
|
||||||
|
"shareselection": {
|
||||||
|
"shareWith": "Delen met…",
|
||||||
|
"confirmTitle": "Bestand versturen",
|
||||||
|
"confirmBody": "Een of meerdere gesprekken zijn onversleuteld. Dit houdt in dat het bestand kan worden uitgelezen door de server. Weet je zeker dat je wilt doorgaan?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"general": {
|
||||||
|
"omemo": "Beveiliging",
|
||||||
|
"profile": "Profiel",
|
||||||
|
"media": "Media"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"notifications": "Meldingen",
|
||||||
|
"notificationsMuted": "Gedempt",
|
||||||
|
"notificationsEnabled": "Ingeschakeld",
|
||||||
|
"sharedMedia": "Media"
|
||||||
|
},
|
||||||
|
"owndevices": {
|
||||||
|
"title": "Mijn apparaten",
|
||||||
|
"thisDevice": "Dit apparaat",
|
||||||
|
"otherDevices": "Overige apparaten",
|
||||||
|
"deleteDeviceConfirmTitle": "Apparaat verwijderen",
|
||||||
|
"deleteDeviceConfirmBody": "Let op: hierdoor kunnen contactpersonen het apparaat niet meer versleutelen. Wil je doorgaan?",
|
||||||
|
"recreateOwnSessions": "Sessies heraanmaken",
|
||||||
|
"recreateOwnSessionsConfirmTitle": "Wil je je sessies heraanmaken?",
|
||||||
|
"recreateOwnDevice": "Apparaat heraanmaken",
|
||||||
|
"recreateOwnDeviceConfirmTitle": "Wil je je apparaat heraanmaken?",
|
||||||
|
"recreateOwnSessionsConfirmBody": "Hierdoor worden de versleutelde sessies opnieuw aangemaakt op je apparaten. Let op: doe dit alléén als apparaten ontsleutelfoutmeldingen tonen.",
|
||||||
|
"recreateOwnDeviceConfirmBody": "Hierdoor wordt de versleutelde identiteit van dit apparaat opnieuw aangemaakt. Dit kan even duren. Als contactpersonen je apparaat hebben goedgekeurd, dan dienen ze dit opnieuw te doen. Wil je doorgaan?"
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"title": "Beveiliging",
|
||||||
|
"recreateSessions": "Sessies heraanmaken",
|
||||||
|
"recreateSessionsConfirmTitle": "Wil je je sessies heraanmaken?",
|
||||||
|
"recreateSessionsConfirmBody": "Hierdoor worden alle versleutelde sessies opnieuw aangemaakt op je apparaten. Let op: doe dit alléén als je apparaten ontsleutelfoutmeldingen tonen.",
|
||||||
|
"noSessions": "Er zijn geen versleutelde sessies die worden gebruikt voor eind-tot-eindversleuteling."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"blocklist": {
|
||||||
|
"title": "Blokkadelijst",
|
||||||
|
"noUsersBlocked": "Er zijn geen geblokkeerde gebruikers",
|
||||||
|
"unblockAll": "Iedereen deblokkeren",
|
||||||
|
"unblockAllConfirmTitle": "Weet je het zeker?",
|
||||||
|
"unblockAllConfirmBody": "Weet je zeker dat je alle gebruikers wilt deblokkeren?",
|
||||||
|
"unblockJidConfirmTitle": "Wil je ${jid} deblokkeren?",
|
||||||
|
"unblockJidConfirmBody": "Weet je zeker dat je ${jid} wilt deblokkeren? Hierdoor ontvang je weer berichten van deze gebruiker."
|
||||||
|
},
|
||||||
|
"cropbackground": {
|
||||||
|
"blur": "Achtergrond vervagen",
|
||||||
|
"setAsBackground": "Instellen als achtergrond"
|
||||||
|
},
|
||||||
|
"stickerPack": {
|
||||||
|
"removeConfirmTitle": "Stickerpakket verwijderen",
|
||||||
|
"installConfirmTitle": "Stickerpakket installeren",
|
||||||
|
"installConfirmBody": "Weet je zeker dat je dit stickerpakket wilt installeren?",
|
||||||
|
"fetchingFailure": "Het stickerpakket is niet gevonden",
|
||||||
|
"removeConfirmBody": "Weet je zeker dat je dit stickerpakket wilt verwijderen?",
|
||||||
|
"restricted": "Dit stickerpakket is beperkt toegankelijk. Dit houdt in dat de stickers kunnen worden getoond, maar niet worden verstuurd."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"settings": {
|
||||||
|
"title": "Instellingen",
|
||||||
|
"conversationsSection": "Gesprekken",
|
||||||
|
"accountSection": "Account",
|
||||||
|
"signOut": "Uitloggen",
|
||||||
|
"signOutConfirmTitle": "Uitloggen",
|
||||||
|
"signOutConfirmBody": "Je staat op het punt om uit te loggen. Wil je doorgaan?",
|
||||||
|
"miscellaneousSection": "Overig",
|
||||||
|
"debuggingSection": "Foutopsporing",
|
||||||
|
"general": "Algemeen"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Over",
|
||||||
|
"licensed": "Uitgebracht onder de GPL3-licentie",
|
||||||
|
"version": "Versie ${version}",
|
||||||
|
"viewSourceCode": "Broncode bekijken",
|
||||||
|
"nMoreToGo": "Nog ${n} te gaan…",
|
||||||
|
"debugMenuShown": "Je bent nu een ontwikkelaar!",
|
||||||
|
"debugMenuAlreadyShown": "Je bent al een ontwikkelaar!"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Vormgeving",
|
||||||
|
"languageSection": "Taal",
|
||||||
|
"language": "Apptaal",
|
||||||
|
"languageSubtext": "Huidige taal: $selectedLanguage",
|
||||||
|
"systemLanguage": "Standaardtaal"
|
||||||
|
},
|
||||||
|
"licenses": {
|
||||||
|
"title": "Opensourcelicenties",
|
||||||
|
"licensedUnder": "Uitgebracht onder de $license-licentie"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"title": "Gesprek",
|
||||||
|
"appearance": "Vormgeving",
|
||||||
|
"selectBackgroundImage": "Kies een achtergrond",
|
||||||
|
"removeBackgroundImage": "Afbeelding verwijderen",
|
||||||
|
"removeBackgroundImageConfirmTitle": "Afbeelding verwijderen",
|
||||||
|
"removeBackgroundImageConfirmBody": "Weet je zeker dat je de huidige gespreksachtergrond wilt verwijderen?",
|
||||||
|
"newChatsSection": "Nieuwe gesprekken",
|
||||||
|
"newChatsMuteByDefault": "Nieuwe gesprekken dempen",
|
||||||
|
"newChatsE2EE": "Eind-tot-eindversleuteling standaard inschakelen (WAARSCHUWING: experimenteel)",
|
||||||
|
"behaviourSection": "Gedrag",
|
||||||
|
"contactsIntegration": "Contactpersoonintegratie",
|
||||||
|
"contactsIntegrationBody": "Schakel in om het adresboek te gebruiken om gesprekstitels en profielfoto's in te stellen. Er worden geen gegevens verstuurd naar de server.",
|
||||||
|
"selectBackgroundImageDescription": "Deze afbeelding wordt gebruikt als achtergrond in al je gesprekken"
|
||||||
|
},
|
||||||
|
"debugging": {
|
||||||
|
"title": "Foutopsporingsopties",
|
||||||
|
"generalSection": "Algemeen",
|
||||||
|
"generalEnableDebugging": "Foutopsporing inschakelen",
|
||||||
|
"generalEncryptionPassword": "Versleutelwachtwoord",
|
||||||
|
"generalLoggingIp": "Ip-log",
|
||||||
|
"generalLoggingIpSubtext": "Het ip-adres waar de logboeken naartoe dienen te worden gestuurd",
|
||||||
|
"generalLoggingPort": "Logpoort",
|
||||||
|
"generalLoggingPortSubtext": "Het ip-adres waar de logboeken naartoe dienen te worden gestuurd",
|
||||||
|
"generalEncryptionPasswordSubtext": "Let op: de logboeken kunnen privéinformatie bevatten, dus stel een sterk wachtwoord in"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"title": "Netwerk",
|
||||||
|
"automaticDownloadsSection": "Automatisch ophalen",
|
||||||
|
"automaticDownloadsMaximumSize": "Maximale downloadomvang",
|
||||||
|
"automaticDownloadsMaximumSizeSubtext": "De maximale bestandsgrootte van automatisch op te halen bestanden",
|
||||||
|
"automaticDownloadAlways": "Ieder netwerk",
|
||||||
|
"wifi": "Wifi",
|
||||||
|
"mobileData": "Mobiel internet",
|
||||||
|
"automaticDownloadsText": "Moxxy zal bestanden automatisch ophalen bij gebruik van…"
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"title": "Privacy",
|
||||||
|
"generalSection": "Algemeen",
|
||||||
|
"showContactRequests": "Contactpersoonverzoeken tonen",
|
||||||
|
"profilePictureVisibility": "Profielfoto aan iedereen tonen",
|
||||||
|
"conversationsSection": "Gesprek",
|
||||||
|
"sendChatMarkersSubtext": "Hiermee kan je gesprekspartner zien of je een bericht gelezen of ontvangen hebt",
|
||||||
|
"sendChatMarkers": "Gespreksacties versturen",
|
||||||
|
"sendChatStates": "Gespreksstatussen versturen",
|
||||||
|
"sendChatStatesSubtext": "Hiermee kan je gesprekspartner zien of je aan het typen of het gesprek aan het bekijken bent",
|
||||||
|
"redirectsSection": "Doorverwijzingen",
|
||||||
|
"redirectText": "Hiermee worden $serviceName-links doorverwezen naar een proxy, bijvoorbeeld $exampleProxy",
|
||||||
|
"currentlySelected": "Huidige proxy: $proxy",
|
||||||
|
"redirectsTitle": "$serviceName-doorverwijzing",
|
||||||
|
"cannotEnableRedirect": "$serviceName-doorverwijzingen mislukt",
|
||||||
|
"cannotEnableRedirectSubtext": "Stel eerst een proxy als doorverwijzing in. Druk hiervoor op het veld naast de schakelaar.",
|
||||||
|
"urlEmpty": "Voer een url in",
|
||||||
|
"urlInvalid": "De url is ongeldig",
|
||||||
|
"redirectDialogTitle": "$serviceName-doorverwijzing",
|
||||||
|
"stickersPrivacy": "Stickerlijst aan iedereen tonen",
|
||||||
|
"stickersPrivacySubtext": "Schakel in om je lijst met stickerpakketten aan iedereen te tonen",
|
||||||
|
"showContactRequestsSubtext": "Hiermee worden verzoeken getoond van personen die je hebben toegevoegd, maar nog geen bericht hebben gestuurd",
|
||||||
|
"profilePictureVisibilitSubtext": "Schakel in om iedereen je profielfoto te tonen; schakel uit om alleen gebruikers op je lijst je profielfoto te tonen"
|
||||||
|
},
|
||||||
|
"stickers": {
|
||||||
|
"title": "Stickers",
|
||||||
|
"stickerSection": "Sticker",
|
||||||
|
"displayStickers": "Stickers in gesprekken tonen",
|
||||||
|
"autoDownload": "Stickers automatisch ophalen",
|
||||||
|
"autoDownloadBody": "Schakel in om stickers automatisch op te halen na het toevoegen van de afzender",
|
||||||
|
"stickerPacksSection": "Stickerpakketten",
|
||||||
|
"importStickerPack": "Stickerpakket importeren",
|
||||||
|
"importSuccess": "Het stickerpakket is geïmporteerd",
|
||||||
|
"importFailure": "Het stickerpakket kan niet worden geïmporteerd",
|
||||||
|
"stickerPackSize": "(${size})"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"title": "Opslag",
|
||||||
|
"storageUsed": "In gebruik: ${size}",
|
||||||
|
"sizePlaceholder": "Bezig met berekenen…",
|
||||||
|
"storageManagement": "Opslagbeheer",
|
||||||
|
"removeOldMedia": {
|
||||||
|
"title": "Oude media verwijderen",
|
||||||
|
"description": "Verwijdert oude mediabestanden van het apparaat"
|
||||||
|
},
|
||||||
|
"removeOldMediaDialog": {
|
||||||
|
"title": "Mediabestanden verwijderen",
|
||||||
|
"options": {
|
||||||
|
"all": "Alle mediabestanden",
|
||||||
|
"oneWeek": "Ouder dan 1 week",
|
||||||
|
"oneMonth": "Ouder dan 1 maand"
|
||||||
|
},
|
||||||
|
"delete": "Verwijderen",
|
||||||
|
"confirmation": {
|
||||||
|
"body": "Weet je zeker dat je oude mediabestanden wilt verwijderen?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"viewMediaFiles": "Mediabestanden bekijken",
|
||||||
|
"mediaFiles": "Mediabestanden",
|
||||||
|
"types": {
|
||||||
|
"media": "Media",
|
||||||
|
"stickers": "Stickers"
|
||||||
|
},
|
||||||
|
"manageStickers": "Stickerpakketten beheren"
|
||||||
|
},
|
||||||
|
"stickerPacks": {
|
||||||
|
"title": "Stickerpakketten"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"startchat": {
|
||||||
|
"title": "Nieuw gesprek",
|
||||||
|
"xmppAddress": "Xmpp-adres",
|
||||||
|
"subtitle": "Je kunt een nieuw gesprek starten door een xmpp-adres in te voeren of een QR-code te scannen.",
|
||||||
|
"buttonAddToContact": "Gesprek starten"
|
||||||
|
},
|
||||||
|
"sharedMedia": {
|
||||||
|
"empty": {
|
||||||
|
"chat": "Er is geen gedeelde media in dit gesprek",
|
||||||
|
"general": "Er zijn geen mediabestanden beschikbaar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
379
assets/i18n/strings_ru.i18n.json
Normal file
379
assets/i18n/strings_ru.i18n.json
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
{
|
||||||
|
"global": {
|
||||||
|
"title": "Moxxy",
|
||||||
|
"dialogAccept": "Принять",
|
||||||
|
"dialogCancel": "Отмена",
|
||||||
|
"yes": "Да",
|
||||||
|
"no": "Нет",
|
||||||
|
"moxxySubtitle": "Эксперементальный XMPP-клиент, простой, современный и красивый."
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"permanent": {
|
||||||
|
"idle": "Idle",
|
||||||
|
"ready": "Готов к приему сообщений",
|
||||||
|
"connecting": "Подключение...",
|
||||||
|
"disconnect": "Отключен",
|
||||||
|
"error": "Ошибка"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"reply": "Ответ",
|
||||||
|
"markAsRead": "Прочитано"
|
||||||
|
},
|
||||||
|
"channels": {
|
||||||
|
"messagesChannelName": "Сообщения",
|
||||||
|
"warningChannelName": "Предупреждения",
|
||||||
|
"warningChannelDescription": "Предупреждения, связанные с Moxxy",
|
||||||
|
"messagesChannelDescription": "Канал уведомлений о полученных сообщениях"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"error": "Ошибка"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"dateTime": {
|
||||||
|
"justNow": "Сейчас",
|
||||||
|
"nMinutesAgo": "${min}минут назад",
|
||||||
|
"mondayAbbrev": "Пн",
|
||||||
|
"tuesdayAbbrev": "Вт",
|
||||||
|
"wednessdayAbbrev": "Ср",
|
||||||
|
"thursdayAbbrev": "Чт",
|
||||||
|
"fridayAbbrev": "Пт",
|
||||||
|
"sundayAbbrev": "Вс",
|
||||||
|
"january": "Январь",
|
||||||
|
"february": "Февраль",
|
||||||
|
"may": "Май",
|
||||||
|
"june": "Июнь",
|
||||||
|
"october": "Октябрь",
|
||||||
|
"november": "Ноябрь",
|
||||||
|
"december": "Декабрь",
|
||||||
|
"today": "Сегодня",
|
||||||
|
"saturdayAbbrev": "Сб",
|
||||||
|
"march": "Март",
|
||||||
|
"april": "Апрель",
|
||||||
|
"july": "Июль",
|
||||||
|
"august": "Август",
|
||||||
|
"september": "Сентябрь",
|
||||||
|
"yesterday": "Вчера"
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"image": "Изображение",
|
||||||
|
"video": "Видео",
|
||||||
|
"file": "Файл",
|
||||||
|
"sticker": "Стикер",
|
||||||
|
"you": "Ты",
|
||||||
|
"audio": "Аудио",
|
||||||
|
"retracted": "Сообщение удалено",
|
||||||
|
"retractedFallback": "Предыдущее сообщение было удалено, но это не поддерживается Вашим клиентом"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"filePicker": {
|
||||||
|
"permissionDenied": "Доступ к хранилищу не был выдан"
|
||||||
|
},
|
||||||
|
"omemo": {
|
||||||
|
"notEncryptedForDevice": "Сообщение зашифровано, но не для этого устройства",
|
||||||
|
"invalidHmac": "Не удалось расшифровать сообщение",
|
||||||
|
"noDecryptionKey": "Нет ключа для расшифровки",
|
||||||
|
"verificationWrongFingerprint": "Неправильный OMEMO:2 отпечаток",
|
||||||
|
"couldNotPublish": "Не удалось опубликовать ключи шифрования на сервере. Это означает, что сквозное шифрование может не работать.",
|
||||||
|
"messageInvalidAfixElement": "Ошибка в зашифрованном сообщении",
|
||||||
|
"verificationInvalidOmemoUrl": "неверный отпечаток OMEMO:2",
|
||||||
|
"verificationWrongJid": "Неправильный XMPP-адрес",
|
||||||
|
"verificationWrongDevice": "Неправильное OMEMO:2 устройство",
|
||||||
|
"verificationNotInList": "Неправильное OMEMO:2 устройство"
|
||||||
|
},
|
||||||
|
"connection": {
|
||||||
|
"connectionTimeout": "Нет соединения с сервером",
|
||||||
|
"saslInvalidCredentials": "Данные учетной записи недействительны",
|
||||||
|
"unrecoverable": "Соединение прервано из-за ошибки",
|
||||||
|
"saslAccountDisabled": "Аккаунт отключен"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"saslFailed": "Неверный логин",
|
||||||
|
"startTlsFailed": "Не удалось установить безопасное соединение",
|
||||||
|
"noConnection": "Не удалось установить соединение",
|
||||||
|
"unspecified": "Неопределенная ошибка"
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"fileDownloadFailed": "Не удалось загрузить файл",
|
||||||
|
"remoteServerTimeout": "Сообщение не доставлено на сервер получателя",
|
||||||
|
"unspecified": "Неизвесная ошибка",
|
||||||
|
"fileUploadFailed": "Не удалось отправить файл",
|
||||||
|
"contactDoesntSupportOmemo": "Получатель не поддерживает OMEMO:2 шифрование",
|
||||||
|
"serviceUnavailable": "Сообщение не доставлено получателю",
|
||||||
|
"remoteServerNotFound": "Cообщение не доставлено, не найден сервер получателя",
|
||||||
|
"failedToEncrypt": "Сообщение не может быть зашифровано",
|
||||||
|
"failedToEncryptFile": "Файл не может быть зашифрован",
|
||||||
|
"failedToDecryptFile": "Файл не может быть расшифрован",
|
||||||
|
"fileNotEncrypted": "Этот чат зашифрован, но файл нет"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"audioRecordingError": "Не удалось завершить аудиозапись",
|
||||||
|
"openFileNoAppError": "Приложения для открытия этого файла не найдены",
|
||||||
|
"openFileGenericError": "Не удалось открыть файл",
|
||||||
|
"messageErrorDialogTitle": "Ошибка"
|
||||||
|
},
|
||||||
|
"newChat": {
|
||||||
|
"groupchatUnsupported": "Вступление в групповой чат пока не поддерживается.",
|
||||||
|
"remoteServerError": "Не удалось связаться с удалённым сервером.",
|
||||||
|
"unknown": "Неизвестная ошибка."
|
||||||
|
},
|
||||||
|
"general": {
|
||||||
|
"noInternet": "Нет подключения к интернету."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"intro": {
|
||||||
|
"registerButton": "Регистрация",
|
||||||
|
"loginButton": "Login",
|
||||||
|
"noAccount": "Нет XMPP аккаунта? Зарегистрируйтесь на одном из серверов, это не сложно."
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Login",
|
||||||
|
"password": "Пароль",
|
||||||
|
"xmppAddress": "XMPP-адрес",
|
||||||
|
"advancedOptions": "Расширенные опции",
|
||||||
|
"createAccount": "Зарегистрироваться на сервере"
|
||||||
|
},
|
||||||
|
"conversations": {
|
||||||
|
"speeddialNewChat": "Написать",
|
||||||
|
"speeddialJoinGroupchat": "Групповой чат",
|
||||||
|
"speeddialAddNoteToSelf": "Заметка себе",
|
||||||
|
"overlaySettings": "Настройки",
|
||||||
|
"startChat": "Начать диалог",
|
||||||
|
"markAsRead": "Пометить как прочитанное",
|
||||||
|
"noOpenChats": "У вас пока нет диалогов",
|
||||||
|
"closeChat": "Завершить диалог",
|
||||||
|
"closeChatBody": "Вы уверены, что хотите завершить диалог с ${conversationTitle}?"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"unencrypted": "Незашифрованно",
|
||||||
|
"encrypted": "Зашифрованно",
|
||||||
|
"closeChat": "Закрыть чат",
|
||||||
|
"closeChatConfirmTitle": "Закрыть чат",
|
||||||
|
"closeChatConfirmSubtext": "Вы уверены что хотите закрыть этот чат?",
|
||||||
|
"blockShort": "Заблокировать",
|
||||||
|
"blockUser": "Заблокировать пользователя",
|
||||||
|
"online": "В сети",
|
||||||
|
"retract": "Отозвать сообщение",
|
||||||
|
"copy": "Копировать содержимое",
|
||||||
|
"addReaction": "Реакция",
|
||||||
|
"showError": "Показать ошибки",
|
||||||
|
"showWarning": "Показать предупреждения",
|
||||||
|
"sendFiles": "Отправить файл",
|
||||||
|
"takePhotos": "Сделать фотографии",
|
||||||
|
"retractBody": "Вы уверены, что хотите отозвать сообщение? Помните, что это всего лишь просьба, которую клиент не обязан выполнять.",
|
||||||
|
"forward": "Переслать",
|
||||||
|
"edit": "Изменить",
|
||||||
|
"quote": "Цитировать",
|
||||||
|
"addToContacts": "Добавить в контакты",
|
||||||
|
"addToContactsTitle": "Добавить ${jid} в контакты",
|
||||||
|
"addToContactsBody": "Вы уверены, что хотите добавить ${jid} в контакты?",
|
||||||
|
"stickerPickerNoStickersLine1": "Нет установленных стикерпаков.",
|
||||||
|
"stickerPickerNoStickersLine2": "Их можно установить в настройках стикеров.",
|
||||||
|
"stickerSettings": "Настройки стикеров",
|
||||||
|
"newDeviceMessage": {
|
||||||
|
"one": "Добавлено новое устройство",
|
||||||
|
"other": "Добавлено несколько новых устройств"
|
||||||
|
},
|
||||||
|
"replacedDeviceMessage": {
|
||||||
|
"one": "Устройство было изменено",
|
||||||
|
"other": "Добавлено несколько устройств"
|
||||||
|
},
|
||||||
|
"messageHint": "Сообщение...",
|
||||||
|
"sendImages": "Отправить изображение",
|
||||||
|
"messageCopied": "Сообщение скопировано в буфер",
|
||||||
|
"warning": "Предупреждение"
|
||||||
|
},
|
||||||
|
"startchat": {
|
||||||
|
"xmppAddress": "XMPP-адрес",
|
||||||
|
"buttonAddToContact": "Добавить в контакты",
|
||||||
|
"title": "Добавить контакт",
|
||||||
|
"subtitle": "Вы можете добавить контакт введя его XMPP адрес или отсканировав QR код"
|
||||||
|
},
|
||||||
|
"newconversation": {
|
||||||
|
"title": "Новый чат",
|
||||||
|
"startChat": "Добавить контакт",
|
||||||
|
"createGroupchat": "Создать новый групповой чат"
|
||||||
|
},
|
||||||
|
"shareselection": {
|
||||||
|
"shareWith": "Поделиться с...",
|
||||||
|
"confirmTitle": "Отправить файл",
|
||||||
|
"confirmBody": "Один или несколько чатов не зашифрованы, из-за чего файл будет доступен администрации сервера. Вы уверены, что хотите продолжить?"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"general": {
|
||||||
|
"omemo": "Безопасность",
|
||||||
|
"profile": "Профиль",
|
||||||
|
"media": "Медиа"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"sharedMedia": "Медиа",
|
||||||
|
"notifications": "Уведомления",
|
||||||
|
"notificationsMuted": "Без звука",
|
||||||
|
"notificationsEnabled": "Включено"
|
||||||
|
},
|
||||||
|
"owndevices": {
|
||||||
|
"thisDevice": "Это устройство",
|
||||||
|
"recreateOwnDevice": "Восстановить устройство",
|
||||||
|
"title": "Мои устройства",
|
||||||
|
"otherDevices": "Другие устройства",
|
||||||
|
"deleteDeviceConfirmTitle": "Удалить устройство",
|
||||||
|
"deleteDeviceConfirmBody": "Контакты не смогут быть зашифрованы для этого устройства. Продолжить?",
|
||||||
|
"recreateOwnSessions": "Пересоздать сеанс",
|
||||||
|
"recreateOwnSessionsConfirmTitle": "Пересоздать свои сеансы?",
|
||||||
|
"recreateOwnSessionsConfirmBody": "Создать новые ключи шифрования для этого устройства. Используйте только в крайнем случае.",
|
||||||
|
"recreateOwnDeviceConfirmTitle": "Восстановить это устройство?",
|
||||||
|
"recreateOwnDeviceConfirmBody": "Это создаст новый криптографический отпечаток устройства, что займёт некоторое время. Если ваше устройство было подтверждено контактами, им придётся сделать это снова. Продолжить?"
|
||||||
|
},
|
||||||
|
"devices": {
|
||||||
|
"title": "Безопасность",
|
||||||
|
"recreateSessionsConfirmTitle": "Пересоздать сеанс?",
|
||||||
|
"noSessions": "Нет криптографических сессий, используемых для сквозного шифрования.",
|
||||||
|
"recreateSessions": "Пересоздать сеанс",
|
||||||
|
"recreateSessionsConfirmBody": "Создать новые ключи шифрования для этого устройства. Используйте только в крайнем случае."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"blocklist": {
|
||||||
|
"unblockAll": "Разблокировать всех",
|
||||||
|
"unblockJidConfirmTitle": "Разблокировать ${jid}?",
|
||||||
|
"title": "Блоклист",
|
||||||
|
"noUsersBlocked": "Нет заблокированных пользователей",
|
||||||
|
"unblockAllConfirmTitle": "Вы уверены?",
|
||||||
|
"unblockAllConfirmBody": "Вы действительно хотите разблокировать всех пользователей?",
|
||||||
|
"unblockJidConfirmBody": "Вы уверены, что хотите разблокировать ${jid}? Вы снова будете получать сообщения от этого пользователя."
|
||||||
|
},
|
||||||
|
"cropbackground": {
|
||||||
|
"blur": "Размыть фон",
|
||||||
|
"setAsBackground": "Установить фоновое изображение"
|
||||||
|
},
|
||||||
|
"crop": {
|
||||||
|
"setProfilePicture": "Загрузить аватар"
|
||||||
|
},
|
||||||
|
"stickerPack": {
|
||||||
|
"removeConfirmTitle": "Удалить стикерпак",
|
||||||
|
"removeConfirmBody": "Вы действительно хотите удалить этот стикерпак?",
|
||||||
|
"installConfirmTitle": "добавить стикерпак",
|
||||||
|
"installConfirmBody": "Вы действительно хотите установить этот стикерпак?",
|
||||||
|
"restricted": "Этот стикерпак ограничен, стикеры будут отображаться, но отправить их нельзя.",
|
||||||
|
"fetchingFailure": "Стикерпак не найден"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"about": {
|
||||||
|
"version": "Версия ${version}",
|
||||||
|
"debugMenuShown": "Теперь ты разработчик ^_^",
|
||||||
|
"debugMenuAlreadyShown": "Ты уже разработчик :^",
|
||||||
|
"title": "О нас",
|
||||||
|
"viewSourceCode": "Исходный код",
|
||||||
|
"nMoreToGo": "Осталось еще ${n}...",
|
||||||
|
"licensed": "Лицензировано под GPL3"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"removeBackgroundImageConfirmBody": "Вы действительно хотите удалить фон?",
|
||||||
|
"title": "Чат",
|
||||||
|
"appearance": "Внешний вид",
|
||||||
|
"selectBackgroundImage": "Выбрать фон",
|
||||||
|
"removeBackgroundImage": "Удалить фон",
|
||||||
|
"removeBackgroundImageConfirmTitle": "Удалить фон",
|
||||||
|
"newChatsSection": "Новые чаты",
|
||||||
|
"newChatsMuteByDefault": "Отключать звук в новых чатах по умолчанию",
|
||||||
|
"newChatsE2EE": "Включить оконечное шифрование по умолчанию",
|
||||||
|
"behaviourSection": "Поведение",
|
||||||
|
"contactsIntegration": "Синхронизация контактов",
|
||||||
|
"selectBackgroundImageDescription": "Это изображение будет фоном для ваших чатов",
|
||||||
|
"contactsIntegrationBody": "При включении данные из Контактов будут использованы для названий чатов и фото профилей. На сервер ничего отправлено не будет."
|
||||||
|
},
|
||||||
|
"debugging": {
|
||||||
|
"title": "Опции отладки",
|
||||||
|
"generalSection": "Основные",
|
||||||
|
"generalEnableDebugging": "Включить отладку",
|
||||||
|
"generalEncryptionPassword": "Пароль шифрования",
|
||||||
|
"generalEncryptionPasswordSubtext": "Журналы могут содержать конфиденциальную информацию, поэтому поставте надежный пароль",
|
||||||
|
"generalLoggingIpSubtext": "IP, на который должны отправляться журналы",
|
||||||
|
"generalLoggingIp": "IP для логов",
|
||||||
|
"generalLoggingPort": "порт для логов",
|
||||||
|
"generalLoggingPortSubtext": "IP, на который должны отправляться журналы"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"automaticDownloadsSection": "Автоматическая загрузка",
|
||||||
|
"title": "Сеть",
|
||||||
|
"automaticDownloadsMaximumSizeSubtext": "Максимальный размер, при котором файлы будут автоматически загружаться",
|
||||||
|
"automaticDownloadsMaximumSize": "Максимальный размер для загрузки",
|
||||||
|
"automaticDownloadAlways": "Всегда",
|
||||||
|
"wifi": "Wifi",
|
||||||
|
"mobileData": "Мобильный интернет",
|
||||||
|
"automaticDownloadsText": "Moxxy будет автоматически загружать файлы до..."
|
||||||
|
},
|
||||||
|
"privacy": {
|
||||||
|
"showContactRequests": "Показывать запрос в контакты",
|
||||||
|
"showContactRequestsSubtext": "Это покажет людей, добавивших вас в свой список контактов",
|
||||||
|
"generalSection": "Основные",
|
||||||
|
"profilePictureVisibility": "Сделать фото профиля публичным",
|
||||||
|
"sendChatMarkers": "Отправлять маркеры",
|
||||||
|
"redirectText": "Это позволит перенаправлять ссылки с ${serviceName} на прокси, такие как ${exampleProxy}",
|
||||||
|
"redirectsSection": "Перенаправление",
|
||||||
|
"currentlySelected": "Выбрано сейчас: $proxy",
|
||||||
|
"redirectsTitle": "$serviceName Перенаправление",
|
||||||
|
"urlEmpty": "URL не может быть пустым",
|
||||||
|
"title": "Приватность",
|
||||||
|
"conversationsSection": "Диалог",
|
||||||
|
"sendChatMarkersSubtext": "Это сообщит вашему собеседнику о получении или прочтении сообщения",
|
||||||
|
"sendChatStates": "Отправлять состояние чата",
|
||||||
|
"sendChatStatesSubtext": "Собеседник будет видеть, когда вы набираете сообщение или просматриваете чат",
|
||||||
|
"cannotEnableRedirect": "Не работает перенаправление $serviceName",
|
||||||
|
"cannotEnableRedirectSubtext": "Сначала нужно добавить прокси сервер. Для этого нажмите слева от переключателя",
|
||||||
|
"urlInvalid": "Недопустимый URL",
|
||||||
|
"redirectDialogTitle": "$serviceName Перенаправление",
|
||||||
|
"stickersPrivacy": "Публиковать список стикеров",
|
||||||
|
"stickersPrivacySubtext": "Когда включено, все могут видет установленные у вас стикеры",
|
||||||
|
"profilePictureVisibilitSubtext": "Когда включено, все видят Ваш аватар; когда выключено - только контакты"
|
||||||
|
},
|
||||||
|
"stickers": {
|
||||||
|
"importSuccess": "Стикерпаки успешно импортированы",
|
||||||
|
"title": "Стикеры",
|
||||||
|
"stickerSection": "Стикеры",
|
||||||
|
"displayStickers": "Показывать стикеры",
|
||||||
|
"autoDownload": "Загружать стикеры автоматически",
|
||||||
|
"autoDownloadBody": "Стикеры будут автоматически загружаться, если их отправитель у вас в контактах",
|
||||||
|
"stickerPacksSection": "Стикерпаки",
|
||||||
|
"importStickerPack": "Импортировать стикерпаки",
|
||||||
|
"importFailure": "Ошибка при импорте стикерпаков"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Настройки",
|
||||||
|
"conversationsSection": "Чаты",
|
||||||
|
"accountSection": "Учётная запись",
|
||||||
|
"signOut": "Выйти",
|
||||||
|
"signOutConfirmTitle": "Выйти",
|
||||||
|
"signOutConfirmBody": "Вы хотите выйти, продолжить?",
|
||||||
|
"miscellaneousSection": "Другое",
|
||||||
|
"debuggingSection": "Отладка",
|
||||||
|
"general": "Основные"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"title": "Внешний вид",
|
||||||
|
"languageSection": "Язык",
|
||||||
|
"language": "Язык в приложении",
|
||||||
|
"systemLanguage": "Как в системе",
|
||||||
|
"languageSubtext": "Выбранный язык: ${selectedLanguage}"
|
||||||
|
},
|
||||||
|
"licenses": {
|
||||||
|
"title": "Открытые лицензии",
|
||||||
|
"licensedUnder": "Лицензировано под ${license}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sharedMedia": {
|
||||||
|
"empty": {
|
||||||
|
"chat": "Нет общих медиафайлов для этого чата",
|
||||||
|
"general": "Нет доступных медиаустройств"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"language": "Русский",
|
||||||
|
"warnings": {
|
||||||
|
"message": {
|
||||||
|
"integrityCheckFailed": "Не удалось проверить целостность файла"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "Удерживайте для записи"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@ targets:
|
|||||||
options:
|
options:
|
||||||
input_directory: assets/i18n
|
input_directory: assets/i18n
|
||||||
output_directory: lib/i18n
|
output_directory: lib/i18n
|
||||||
|
fallback_strategy: base_locale
|
||||||
|
base_locale: en
|
||||||
|
|||||||
1
fastlane/metadata/android/de-DE/short_description.txt
Normal file
1
fastlane/metadata/android/de-DE/short_description.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Moxxy ist ein experimenteller XMPP-Client, der modern und einfach sein soll.
|
||||||
1
fastlane/metadata/android/de-DE/title.txt
Normal file
1
fastlane/metadata/android/de-DE/title.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Moxxy
|
||||||
12
fastlane/metadata/android/en-US/changelogs/11.txt
Normal file
12
fastlane/metadata/android/en-US/changelogs/11.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
Many changes in this release are under the hood, but there are many changes nonetheless:
|
||||||
|
|
||||||
|
- Messages that are sent while offline are now queued up until we're online again
|
||||||
|
- Moxxy now makes use of SFS's caching possibilities. Receiving files sent via SFS are thus only downloaded if the file is not already locally available
|
||||||
|
- Messages and shared media files are now shown in paged lists
|
||||||
|
- Reworked various pages, like the Conversation page and the profile page
|
||||||
|
- Rework the reactions UI
|
||||||
|
- Add a "note to self" feature. This was a teaser task in the context of this year's GSoC
|
||||||
|
- Chat states are no longer sent if a chat is no longer focused
|
||||||
|
- Sending a sticker when a message is selected for quoting, the sticker is sent as a reply to that message
|
||||||
|
- The database design was massively overhauled
|
||||||
|
- The emoji/sticker picker should no longer jump around when switching from the keyboard
|
||||||
7
fastlane/metadata/android/en-US/changelogs/12.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/12.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
This is a hotfix release.
|
||||||
|
|
||||||
|
Sending a message with no attached file results in a gray
|
||||||
|
box being displayed over the entire message list. This release
|
||||||
|
contains a fix for that.
|
||||||
|
|
||||||
|
(I also dropped my fork of the Flutter SDK)
|
||||||
6
fastlane/metadata/android/en-US/changelogs/13.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/13.txt
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
- (Hopefully) fix OMEMO between two Moxxy clients.
|
||||||
|
- Allow correcting messages older than the last one. Whether all clients will accept such a correction is unclear.
|
||||||
|
- Add (incomplete) translations for Dutch, Japanese, and Russian.
|
||||||
|
- Fix having to long-press a message bubble on its corner to active the selection menu.
|
||||||
|
- If enabled, read markers are automatically sent.
|
||||||
|
- Highlight legacy quotes in text messages.
|
||||||
10
fastlane/metadata/android/nl-NL/changelogs/11.txt
Normal file
10
fastlane/metadata/android/nl-NL/changelogs/11.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
- Offline-berichten worden verstuurd als de verbinding hersteld is;
|
||||||
|
- SFS-cache, waardoor downloaden alleen plaatsvindt indien niet lokaal beschikbaar;
|
||||||
|
- Berichten en mediabestanden worden op pagina's getoond;
|
||||||
|
- Diverse pagina's bijgewerkt;
|
||||||
|
- Reacties herontworpen;
|
||||||
|
- Zelfmemofunctie;
|
||||||
|
- Gespreksstatussen worden niet meer verstuurd indien ongefocust;
|
||||||
|
- Stickers als antwoord op citaten;
|
||||||
|
- Nieuw databankontwerp;
|
||||||
|
- Verbeterde emoji-/stickerkeuze i.c.m. toetsenbord.
|
||||||
5
fastlane/metadata/android/nl-NL/changelogs/12.txt
Normal file
5
fastlane/metadata/android/nl-NL/changelogs/12.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
Dit is een oplossingsversie:
|
||||||
|
|
||||||
|
Het versturen van een bericht zonder bijlage zorgde voor een grijs vlak op de berichtenlijst. Dat is nu opgelost.
|
||||||
|
|
||||||
|
(Ook ben ik gestopt met de ontwikkeling van mijn afsplitsing van de Flutter-sdk.)
|
||||||
5
fastlane/metadata/android/nl-NL/changelogs/13.txt
Normal file
5
fastlane/metadata/android/nl-NL/changelogs/13.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-(Hopelijk) Oplossing voor OMEMO tussen twee Moxxy-clients;
|
||||||
|
-Oplossing voor het lang ingedrukt houden van een bericht om het keuzemenu te openen;
|
||||||
|
-Leesmarkeringen worden voortaan automatisch verzonden (indien ingeschakeld);
|
||||||
|
-Nieuw: (onvolledige) Nederlandse, Japanse en Russische vertalingen;
|
||||||
|
-Nieuw: bewerken van berichten ouder dan het recentste bericht. Onduidelijk of alle clients dit op de juiste manier tonen.
|
||||||
7
fastlane/metadata/android/nl-NL/changelogs/9.txt
Normal file
7
fastlane/metadata/android/nl-NL/changelogs/9.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
|
||||||
|
* Maybe fix a connection race condition
|
||||||
|
* Allow sharing media with the app when it was closed
|
||||||
|
* Make quotes prettier
|
||||||
|
* Make the bottom part of the conversation page prettier
|
||||||
|
* Fix roster fetching
|
||||||
|
* Fix OMEMO key generation
|
||||||
24
fastlane/metadata/android/nl-NL/full_description.txt
Normal file
24
fastlane/metadata/android/nl-NL/full_description.txt
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
Moxxy is een experimentele xmpp-client met als doel modern gebruiksgemak.
|
||||||
|
|
||||||
|
Let op: Moxxy is momenteel in de alfafase. Dit houdt in dat er gegarandeerd bugs en
|
||||||
|
problemen zullen zijn. Gebruik Moxxy dus niet voor belangrijke zaken.
|
||||||
|
|
||||||
|
Huidige functies:
|
||||||
|
<ul>
|
||||||
|
<li>Verstuur bestanden en afbeeldingen;</li>
|
||||||
|
<li>Stel je profielfoto in;</li>
|
||||||
|
<li>Typmeldingen en berichtstatussen;</li>
|
||||||
|
<li>Gespreksachtergronden;</li>
|
||||||
|
<li>Draait op de achtergrond zónder pushmeldingen;</li>
|
||||||
|
<li>OMEMO (momenteel niet compatibel met de meeste apps);</li>
|
||||||
|
<li>Stickers.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
Voor de beste gebruikservaring is het belangrijk om een server te gebruiken met:
|
||||||
|
<ul>
|
||||||
|
<li>Ondersteuning voor TLS/StartTLS op dezelfde domeinnaam als in de Jid;</li>
|
||||||
|
<li>Ondersteuning voor SCRAM-SHA-1, SCRAM-SHA-256 of SCRAM-SHA-512;</li>
|
||||||
|
<li>Ondersteuning voor HTTP-bestandsupload;</li>
|
||||||
|
<li>Ondersteuning voor streambeheer;</li>
|
||||||
|
<li>Ondersteuning voor Client State Indication.</li>
|
||||||
|
</ul>
|
||||||
1
fastlane/metadata/android/nl-NL/short_description.txt
Normal file
1
fastlane/metadata/android/nl-NL/short_description.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Moxxy is een experimentele xmpp-client met als doel modern gebruiksgemak.
|
||||||
1
fastlane/metadata/android/nl-NL/title.txt
Normal file
1
fastlane/metadata/android/nl-NL/title.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Moxxy
|
||||||
123
flake.lock
generated
123
flake.lock
generated
@@ -1,6 +1,66 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"android-nixpkgs": {
|
||||||
|
"inputs": {
|
||||||
|
"devshell": "devshell",
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1689798050,
|
||||||
|
"narHash": "sha256-ZyFPra7N0MF803o55dYQQyX9b/BmXr6QTCyN7slRThY=",
|
||||||
|
"owner": "tadfisher",
|
||||||
|
"repo": "android-nixpkgs",
|
||||||
|
"rev": "9aa0e2990da86de8ca203af313668851dcb9ea6e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "tadfisher",
|
||||||
|
"repo": "android-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devshell": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"android-nixpkgs",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1688380630,
|
||||||
|
"narHash": "sha256-8ilApWVb1mAi4439zS3iFeIT0ODlbrifm/fegWwgHjA=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "devshell",
|
||||||
|
"rev": "f9238ec3d75cefbb2b42a44948c4e8fb1ae9a205",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "devshell",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1689068808,
|
||||||
|
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1667395993,
|
"lastModified": 1667395993,
|
||||||
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
||||||
@@ -17,24 +77,71 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1676076353,
|
"lastModified": 1689679375,
|
||||||
"narHash": "sha256-mdUtE8Tp40cZETwcq5tCwwLqkJVV1ULJQ5GKRtbshag=",
|
"narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
|
||||||
"owner": "AtaraxiaSjel",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "5deb99bdccbbb97e7562dee4ba8a3ee3021688e6",
|
"rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "AtaraxiaSjel",
|
"owner": "NixOS",
|
||||||
"ref": "update/flutter",
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1689752456,
|
||||||
|
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixpkgs-unstable",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"android-nixpkgs": "android-nixpkgs",
|
||||||
"nixpkgs": "nixpkgs"
|
"flake-utils": "flake-utils_2",
|
||||||
|
"nixpkgs": "nixpkgs_2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
71
flake.nix
71
flake.nix
@@ -1,34 +1,47 @@
|
|||||||
{
|
{
|
||||||
description = "Moxxy v2";
|
description = "Moxxy v2";
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:AtaraxiaSjel/nixpkgs/update/flutter";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
android-nixpkgs.url = "github:tadfisher/android-nixpkgs";
|
||||||
};
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let
|
outputs = { self, nixpkgs, flake-utils, android-nixpkgs }: flake-utils.lib.eachDefaultSystem (system: let
|
||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
config = {
|
config = {
|
||||||
android_sdk.accept_license = true;
|
android_sdk.accept_license = true;
|
||||||
allowUnfree = true;
|
allowUnfree = true;
|
||||||
|
|
||||||
|
# Fix to allow building the NDK package
|
||||||
|
# TODO: Remove once https://github.com/tadfisher/android-nixpkgs/issues/62 is resolved
|
||||||
|
permittedInsecurePackages = [
|
||||||
|
"python-2.7.18.6"
|
||||||
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
android = pkgs.androidenv.composeAndroidPackages {
|
# Everything to make Flutter happy
|
||||||
# TODO: Find a way to pin these
|
sdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
|
||||||
#toolsVersion = "26.1.1";
|
cmdline-tools-latest
|
||||||
#platformToolsVersion = "31.0.3";
|
build-tools-30-0-3
|
||||||
#buildToolsVersions = [ "31.0.0" ];
|
build-tools-33-0-2
|
||||||
#includeEmulator = true;
|
build-tools-34-0-0
|
||||||
#emulatorVersion = "30.6.3";
|
platform-tools
|
||||||
platformVersions = [ "28" ];
|
emulator
|
||||||
includeSources = false;
|
patcher-v4
|
||||||
includeSystemImages = true;
|
platforms-android-28
|
||||||
systemImageTypes = [ "default" ];
|
platforms-android-29
|
||||||
abiVersions = [ "x86_64" ];
|
platforms-android-30
|
||||||
includeNDK = false;
|
platforms-android-31
|
||||||
useGoogleAPIs = false;
|
platforms-android-33
|
||||||
useGoogleTVAddOns = false;
|
|
||||||
};
|
# For flutter_zxing
|
||||||
|
cmake-3-18-1
|
||||||
|
#ndk-21-4-7075529
|
||||||
|
(ndk-21-4-7075529.overrideAttrs (old: {
|
||||||
|
buildInputs = old.buildInputs ++ [ pkgs.python27 ];
|
||||||
|
}))
|
||||||
|
]);
|
||||||
pinnedJDK = pkgs.jdk17;
|
pinnedJDK = pkgs.jdk17;
|
||||||
|
|
||||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||||
@@ -38,13 +51,27 @@
|
|||||||
in {
|
in {
|
||||||
devShell = pkgs.mkShell {
|
devShell = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
flutter pinnedJDK android.platform-tools dart scrcpy # Flutter/Android
|
# Android
|
||||||
pythonEnv gnumake # Build scripts
|
pinnedJDK sdk
|
||||||
gitlint jq # Code hygiene
|
scrcpy
|
||||||
ripgrep # General utilities
|
|
||||||
|
# Flutter
|
||||||
|
flutter37
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
pythonEnv gnumake
|
||||||
|
|
||||||
|
# Code hygiene
|
||||||
|
gitlint jq
|
||||||
];
|
];
|
||||||
|
|
||||||
|
ANDROID_SDK_ROOT = "${sdk}/share/android-sdk";
|
||||||
|
ANDROID_HOME = "${sdk}/share/android-sdk";
|
||||||
JAVA_HOME = pinnedJDK;
|
JAVA_HOME = pinnedJDK;
|
||||||
|
|
||||||
|
# Fix an issue with Flutter using an older version of aapt2, which does not know
|
||||||
|
# an used parameter.
|
||||||
|
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/share/android-sdk/build-tools/34.0.0/aapt2";
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ files:
|
|||||||
roster:
|
roster:
|
||||||
type: List<RosterItem>?
|
type: List<RosterItem>?
|
||||||
deserialise: true
|
deserialise: true
|
||||||
stickers:
|
|
||||||
type: List<StickerPack>?
|
|
||||||
deserialise: true
|
|
||||||
# Triggered if a conversation has been added.
|
# Triggered if a conversation has been added.
|
||||||
# Also returned by [AddConversationCommand]
|
# Also returned by [AddConversationCommand]
|
||||||
- name: ConversationAddedEvent
|
- name: ConversationAddedEvent
|
||||||
@@ -208,7 +205,7 @@ files:
|
|||||||
attributes:
|
attributes:
|
||||||
conversationJid: String
|
conversationJid: String
|
||||||
title: String
|
title: String
|
||||||
avatarUrl: String
|
avatarPath: String
|
||||||
- name: StickerPackImportSuccessEvent
|
- name: StickerPackImportSuccessEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
@@ -274,6 +271,70 @@ files:
|
|||||||
reactions:
|
reactions:
|
||||||
type: List<ReactionGroup>
|
type: List<ReactionGroup>
|
||||||
deserialise: true
|
deserialise: true
|
||||||
|
# Triggered when the stream negotiations have been completed
|
||||||
|
- name: StreamNegotiationsCompletedEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
resumed: bool
|
||||||
|
- name: AvatarUpdatedEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
jid: String
|
||||||
|
path: String
|
||||||
|
# Returned when attempting to start a chat with a groupchat
|
||||||
|
- name: JidIsGroupchatEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
# Returned when an error occured
|
||||||
|
- name: ErrorEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
errorId: int
|
||||||
|
# Returned after a [GetStorageUsageCommand]
|
||||||
|
- name: GetStorageUsageEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
# The used storage in bytes for media files
|
||||||
|
mediaUsage: int
|
||||||
|
# The used storage in bytes for stickers
|
||||||
|
stickerUsage: int
|
||||||
|
# Returned after [DeleteOldMediaFilesCommand]
|
||||||
|
- name: DeleteOldMediaFilesDoneEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
# The used storage in bytes after the deletion operation is done
|
||||||
|
newUsage: int
|
||||||
|
# The new list of Conversations
|
||||||
|
conversations:
|
||||||
|
type: List<Conversation>
|
||||||
|
deserialize: true
|
||||||
|
- name: PagedStickerPackResult
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPacks:
|
||||||
|
type: List<StickerPack>
|
||||||
|
deserialise: true
|
||||||
|
- name: GetStickerPackByIdResult
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack?
|
||||||
|
deserialise: true
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
builder_name: "Event"
|
builder_name: "Event"
|
||||||
builder_baseclass: "BackgroundEvent"
|
builder_baseclass: "BackgroundEvent"
|
||||||
@@ -484,9 +545,8 @@ files:
|
|||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
conversationJid: String
|
id: int
|
||||||
sid: String
|
sendMarker: bool
|
||||||
newUnreadCounter: int
|
|
||||||
- name: AddReactionToMessageCommand
|
- name: AddReactionToMessageCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@@ -567,7 +627,7 @@ files:
|
|||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
conversationJid: String
|
conversationJid: String?
|
||||||
olderThan: bool
|
olderThan: bool
|
||||||
timestamp: int?
|
timestamp: int?
|
||||||
- name: GetReactionsForMessageCommand
|
- name: GetReactionsForMessageCommand
|
||||||
@@ -576,6 +636,46 @@ files:
|
|||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
messageId: int
|
messageId: int
|
||||||
|
- name: RequestAvatarForJidCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
jid: String
|
||||||
|
hash: String?
|
||||||
|
ownAvatar: bool
|
||||||
|
- name: GetStorageUsageCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
- name: DeleteOldMediaFilesCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
# Milliseconds from now in the past; The maximum age of a file to not
|
||||||
|
# get deleted.
|
||||||
|
timeOffset: int
|
||||||
|
- name: GetPagedStickerPackCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
olderThan: bool
|
||||||
|
timestamp: int?
|
||||||
|
includeStickers: bool
|
||||||
|
- name: GetStickerPackByIdCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
id: String
|
||||||
|
- name: DebugCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
id: int
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
# get${builder_Name}FromJson
|
# get${builder_Name}FromJson
|
||||||
builder_name: "Command"
|
builder_name: "Command"
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import 'package:moxxyv2/i18n/strings.g.dart';
|
|||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
|
|
||||||
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||||
@@ -26,6 +25,7 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
|||||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/startchat_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
@@ -35,7 +35,6 @@ import 'package:moxxyv2/ui/events.dart';
|
|||||||
import "package:moxxyv2/ui/pages/register/register.dart";
|
import "package:moxxyv2/ui/pages/register/register.dart";
|
||||||
import "package:moxxyv2/ui/pages/postregister/postregister.dart";
|
import "package:moxxyv2/ui/pages/postregister/postregister.dart";
|
||||||
*/
|
*/
|
||||||
import 'package:moxxyv2/ui/pages/addcontact.dart';
|
|
||||||
import 'package:moxxyv2/ui/pages/blocklist.dart';
|
import 'package:moxxyv2/ui/pages/blocklist.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
|
import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversations.dart';
|
import 'package:moxxyv2/ui/pages/conversations.dart';
|
||||||
@@ -57,14 +56,21 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
|
|||||||
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/settings/sticker_packs.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/settings/storage/shared_media.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/settings/storage/storage.dart';
|
||||||
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
||||||
//import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
//import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||||
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/startchat.dart';
|
||||||
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||||
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||||
|
import 'package:moxxyv2/ui/service/avatars.dart';
|
||||||
|
import 'package:moxxyv2/ui/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/ui/service/data.dart';
|
import 'package:moxxyv2/ui/service/data.dart';
|
||||||
import 'package:moxxyv2/ui/service/progress.dart';
|
import 'package:moxxyv2/ui/service/progress.dart';
|
||||||
|
import 'package:moxxyv2/ui/service/read.dart';
|
||||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||||
import 'package:moxxyv2/ui/theme.dart';
|
import 'package:moxxyv2/ui/theme.dart';
|
||||||
import 'package:page_transition/page_transition.dart';
|
import 'package:page_transition/page_transition.dart';
|
||||||
@@ -83,7 +89,13 @@ void setupLogging() {
|
|||||||
Future<void> setupUIServices() async {
|
Future<void> setupUIServices() async {
|
||||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||||
|
GetIt.I.registerSingleton<UIAvatarsService>(UIAvatarsService());
|
||||||
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||||
|
GetIt.I.registerSingleton<UIConnectivityService>(UIConnectivityService());
|
||||||
|
GetIt.I.registerSingleton<UIReadMarkerService>(UIReadMarkerService());
|
||||||
|
|
||||||
|
/// Initialize services
|
||||||
|
await GetIt.I.get<UIConnectivityService>().initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||||
@@ -95,7 +107,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
|||||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
GetIt.I.registerSingleton<StartChatBloc>(StartChatBloc());
|
||||||
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
||||||
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
||||||
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
||||||
@@ -147,8 +159,8 @@ void main() async {
|
|||||||
BlocProvider<PreferencesBloc>(
|
BlocProvider<PreferencesBloc>(
|
||||||
create: (_) => GetIt.I.get<PreferencesBloc>(),
|
create: (_) => GetIt.I.get<PreferencesBloc>(),
|
||||||
),
|
),
|
||||||
BlocProvider<AddContactBloc>(
|
BlocProvider<StartChatBloc>(
|
||||||
create: (_) => GetIt.I.get<AddContactBloc>(),
|
create: (_) => GetIt.I.get<StartChatBloc>(),
|
||||||
),
|
),
|
||||||
BlocProvider<CropBloc>(
|
BlocProvider<CropBloc>(
|
||||||
create: (_) => GetIt.I.get<CropBloc>(),
|
create: (_) => GetIt.I.get<CropBloc>(),
|
||||||
@@ -268,11 +280,13 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
case newConversationRoute:
|
case newConversationRoute:
|
||||||
return NewConversationPage.route;
|
return NewConversationPage.route;
|
||||||
case conversationRoute:
|
case conversationRoute:
|
||||||
|
final args = settings.arguments! as ConversationPageArguments;
|
||||||
return PageTransition<dynamic>(
|
return PageTransition<dynamic>(
|
||||||
type: PageTransitionType.rightToLeft,
|
type: PageTransitionType.rightToLeft,
|
||||||
settings: settings,
|
settings: settings,
|
||||||
child: ConversationPage(
|
child: ConversationPage(
|
||||||
conversationJid: settings.arguments! as String,
|
conversationJid: args.conversationJid,
|
||||||
|
initialText: args.initialText,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// case sharedMediaRoute:
|
// case sharedMediaRoute:
|
||||||
@@ -298,7 +312,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
case debuggingRoute:
|
case debuggingRoute:
|
||||||
return DebuggingPage.route;
|
return DebuggingPage.route;
|
||||||
case addContactRoute:
|
case addContactRoute:
|
||||||
return AddContactPage.route;
|
return StartChatPage.route;
|
||||||
case cropRoute:
|
case cropRoute:
|
||||||
return CropPage.route;
|
return CropPage.route;
|
||||||
case sendFilesRoute:
|
case sendFilesRoute:
|
||||||
@@ -323,8 +337,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
case stickersRoute:
|
case stickersRoute:
|
||||||
return StickersSettingsPage.route;
|
return StickersSettingsPage.route;
|
||||||
|
case stickerPacksRoute:
|
||||||
|
return StickerPacksSettingsPage.route;
|
||||||
case stickerPackRoute:
|
case stickerPackRoute:
|
||||||
return StickerPackPage.route;
|
return StickerPackPage.route;
|
||||||
|
case storageSettingsRoute:
|
||||||
|
return StorageSettingsPage.route;
|
||||||
|
case storageSharedMediaSettingsRoute:
|
||||||
|
return StorageSharedMediaPage.route;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:cryptography/cryptography.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
@@ -14,60 +12,100 @@ import 'package:moxxyv2/shared/avatar.dart';
|
|||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
|
||||||
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
|
||||||
/// avatar data. Returns the cleaned version.
|
|
||||||
String _cleanBase64String(String original) {
|
|
||||||
var ret = original;
|
|
||||||
for (final char in ['\n', ' ']) {
|
|
||||||
ret = ret.replaceAll(char, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AvatarData {
|
|
||||||
const _AvatarData(this.data, this.id);
|
|
||||||
final List<int> data;
|
|
||||||
final String id;
|
|
||||||
}
|
|
||||||
|
|
||||||
class AvatarService {
|
class AvatarService {
|
||||||
final Logger _log = Logger('AvatarService');
|
final Logger _log = Logger('AvatarService');
|
||||||
|
|
||||||
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
/// List of JIDs for which we have already requested the avatar in the current stream.
|
||||||
await updateAvatarForJid(
|
final List<JID> _requestedInStream = [];
|
||||||
event.jid,
|
|
||||||
event.hash,
|
void resetCache() {
|
||||||
base64Decode(_cleanBase64String(event.base64)),
|
_requestedInStream.clear();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateAvatarForJid(
|
Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
|
||||||
String jid,
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
|
final rawAvatar = await am.getUserAvatar(jid);
|
||||||
|
if (rawAvatar.isType<AvatarError>()) {
|
||||||
|
_log.warning('Failed to request avatar for $jid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final avatar = rawAvatar.get<UserAvatarData>();
|
||||||
|
await _updateAvatarForJid(
|
||||||
|
jid,
|
||||||
|
avatar.hash,
|
||||||
|
avatar.data,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requests the avatar for [jid]. [oldHash], if given, is the last SHA-1 hash of the known avatar.
|
||||||
|
/// If the avatar for [jid] has already been requested in this stream session, does nothing. Otherwise,
|
||||||
|
/// requests the XEP-0084 metadata and queries the new avatar only if the queried SHA-1 != [oldHash].
|
||||||
|
///
|
||||||
|
/// Returns true, if everything went okay. Returns false if an error occurred.
|
||||||
|
Future<bool> requestAvatar(JID jid, String? oldHash) async {
|
||||||
|
if (_requestedInStream.contains(jid)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_requestedInStream.add(jid);
|
||||||
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
|
final rawId = await am.getAvatarId(jid);
|
||||||
|
|
||||||
|
if (rawId.isType<AvatarError>()) {
|
||||||
|
_log.finest(
|
||||||
|
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final id = rawId.get<String>();
|
||||||
|
if (id == oldHash) {
|
||||||
|
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _fetchAvatarForJid(jid, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> handleAvatarUpdate(UserAvatarUpdatedEvent event) async {
|
||||||
|
if (event.metadata.isEmpty) return;
|
||||||
|
|
||||||
|
// TODO(Unknown): Maybe make a better decision?
|
||||||
|
await _fetchAvatarForJid(event.jid, event.metadata.first.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the avatar path and hash for the conversation and/or roster item with jid [JID].
|
||||||
|
/// [hash] is the new hash of the avatar. [data] is the raw avatar data.
|
||||||
|
Future<void> _updateAvatarForJid(
|
||||||
|
JID jid,
|
||||||
String hash,
|
String hash,
|
||||||
List<int> data,
|
List<int> data,
|
||||||
) async {
|
) async {
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final rs = GetIt.I.get<RosterService>();
|
final rs = GetIt.I.get<RosterService>();
|
||||||
final originalConversation = await cs.getConversationByJid(jid);
|
final originalConversation = await cs.getConversationByJid(jid.toString());
|
||||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
final originalRoster = await rs.getRosterItemByJid(jid.toString());
|
||||||
|
|
||||||
if (originalConversation == null && originalRoster == null) return;
|
if (originalConversation == null && originalRoster == null) return;
|
||||||
|
|
||||||
final avatarPath = await saveAvatarInCache(
|
final avatarPath = await saveAvatarInCache(
|
||||||
data,
|
data,
|
||||||
hash,
|
hash,
|
||||||
jid,
|
jid.toString(),
|
||||||
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
(originalConversation?.avatarPath ?? originalRoster?.avatarPath)!,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (originalConversation != null) {
|
if (originalConversation != null) {
|
||||||
final conversation = await cs.createOrUpdateConversation(
|
final conversation = await cs.createOrUpdateConversation(
|
||||||
jid,
|
jid.toString(),
|
||||||
update: (c) async {
|
update: (c) async {
|
||||||
return cs.updateConversation(
|
return cs.updateConversation(
|
||||||
jid,
|
jid.toString(),
|
||||||
avatarUrl: avatarPath,
|
avatarPath: avatarPath,
|
||||||
|
avatarHash: hash,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -81,88 +119,21 @@ class AvatarService {
|
|||||||
if (originalRoster != null) {
|
if (originalRoster != null) {
|
||||||
final roster = await rs.updateRosterItem(
|
final roster = await rs.updateRosterItem(
|
||||||
originalRoster.id,
|
originalRoster.id,
|
||||||
avatarUrl: avatarPath,
|
avatarPath: avatarPath,
|
||||||
avatarHash: hash,
|
avatarHash: hash,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(RosterDiffEvent(modified: [roster]));
|
sendEvent(RosterDiffEvent(modified: [roster]));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
|
sendEvent(
|
||||||
final am = GetIt.I
|
AvatarUpdatedEvent(
|
||||||
.get<XmppConnection>()
|
jid: jid.toString(),
|
||||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
path: avatarPath,
|
||||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
),
|
||||||
if (idResult.isType<AvatarError>()) {
|
|
||||||
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final id = idResult.get<String>();
|
|
||||||
if (id == oldHash) return null;
|
|
||||||
|
|
||||||
final avatarResult = await am.getUserAvatar(jid);
|
|
||||||
if (avatarResult.isType<AvatarError>()) {
|
|
||||||
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final avatar = avatarResult.get<UserAvatar>();
|
|
||||||
|
|
||||||
return _AvatarData(
|
|
||||||
base64Decode(_cleanBase64String(avatar.base64)),
|
|
||||||
avatar.hash,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
|
|
||||||
// Query the vCard
|
|
||||||
final vm = GetIt.I
|
|
||||||
.get<XmppConnection>()
|
|
||||||
.getManagerById<VCardManager>(vcardManager)!;
|
|
||||||
final vcardResult = await vm.requestVCard(jid);
|
|
||||||
if (vcardResult.isType<VCardError>()) return null;
|
|
||||||
|
|
||||||
final binval = vcardResult.get<VCard>().photo?.binval;
|
|
||||||
if (binval == null) return null;
|
|
||||||
|
|
||||||
final data = base64Decode(_cleanBase64String(binval));
|
|
||||||
final rawHash = await Sha1().hash(data);
|
|
||||||
final hash = HEX.encode(rawHash.bytes);
|
|
||||||
|
|
||||||
vm.setLastHash(jid, hash);
|
|
||||||
|
|
||||||
return _AvatarData(
|
|
||||||
data,
|
|
||||||
hash,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
|
||||||
_AvatarData? data;
|
|
||||||
data ??= await _handleUserAvatar(jid, oldHash);
|
|
||||||
data ??= await _handleVcardAvatar(jid, oldHash);
|
|
||||||
|
|
||||||
if (data != null) {
|
|
||||||
await updateAvatarForJid(jid, data.id, data.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> subscribeJid(String jid) async {
|
|
||||||
return (await GetIt.I
|
|
||||||
.get<XmppConnection>()
|
|
||||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
|
||||||
.subscribe(jid))
|
|
||||||
.isType<bool>();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> unsubscribeJid(String jid) async {
|
|
||||||
return (await GetIt.I
|
|
||||||
.get<XmppConnection>()
|
|
||||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
|
||||||
.unsubscribe(jid))
|
|
||||||
.isType<bool>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Publishes the data at [path] as an avatar with PubSub ID
|
/// Publishes the data at [path] as an avatar with PubSub ID
|
||||||
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
|
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
|
||||||
/// of the avatar data.
|
/// of the avatar data.
|
||||||
@@ -201,6 +172,7 @@ class AvatarService {
|
|||||||
imageSize.height.toInt(),
|
imageSize.height.toInt(),
|
||||||
// TODO(PapaTutuWawa): Maybe do a check here
|
// TODO(PapaTutuWawa): Maybe do a check here
|
||||||
'image/png',
|
'image/png',
|
||||||
|
null,
|
||||||
),
|
),
|
||||||
public,
|
public,
|
||||||
);
|
);
|
||||||
@@ -213,38 +185,44 @@ class AvatarService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
|
||||||
Future<void> requestOwnAvatar() async {
|
Future<void> requestOwnAvatar() async {
|
||||||
|
final xss = GetIt.I.get<XmppStateService>();
|
||||||
|
final state = await xss.getXmppState();
|
||||||
|
final jid = JID.fromString(state.jid!);
|
||||||
|
|
||||||
|
if (_requestedInStream.contains(jid)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_requestedInStream.add(jid);
|
||||||
|
|
||||||
final am = GetIt.I
|
final am = GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
final xss = GetIt.I.get<XmppStateService>();
|
final rawId = await am.getAvatarId(jid);
|
||||||
final state = await xss.getXmppState();
|
if (rawId.isType<AvatarError>()) {
|
||||||
final jid = state.jid!;
|
_log.finest(
|
||||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
|
||||||
if (idResult.isType<AvatarError>()) {
|
|
||||||
_log.info('Error while getting latest avatar id for own avatar');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final id = idResult.get<String>();
|
|
||||||
|
|
||||||
if (id == state.avatarHash) return;
|
|
||||||
|
|
||||||
_log.info(
|
|
||||||
'Mismatch between saved avatar data and server-side avatar data about ourself',
|
|
||||||
);
|
);
|
||||||
final avatarDataResult = await am.getUserAvatar(jid);
|
|
||||||
if (avatarDataResult.isType<AvatarError>()) {
|
|
||||||
_log.severe('Failed to fetch our avatar');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final avatarData = avatarDataResult.get<UserAvatar>();
|
final id = rawId.get<String>();
|
||||||
|
|
||||||
_log.info('Received data for our own avatar');
|
if (id == state.avatarHash) {
|
||||||
|
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawAvatar = await am.getUserAvatar(jid);
|
||||||
|
if (rawAvatar.isType<AvatarError>()) {
|
||||||
|
_log.warning('Failed to request avatar for $jid');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final avatarData = rawAvatar.get<UserAvatarData>();
|
||||||
final avatarPath = await saveAvatarInCache(
|
final avatarPath = await saveAvatarInCache(
|
||||||
base64Decode(_cleanBase64String(avatarData.base64)),
|
avatarData.data,
|
||||||
avatarData.hash,
|
avatarData.hash,
|
||||||
jid,
|
jid.toString(),
|
||||||
state.avatarUrl,
|
state.avatarUrl,
|
||||||
);
|
);
|
||||||
await xss.modifyXmppState(
|
await xss.modifyXmppState(
|
||||||
|
|||||||
@@ -41,19 +41,25 @@ class ContactsService {
|
|||||||
final Map<String, String?> _contactDisplayNames = {};
|
final Map<String, String?> _contactDisplayNames = {};
|
||||||
|
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (await _canUseContactIntegration()) {
|
await enable(shouldScan: false);
|
||||||
enableDatabaseListener();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable listening to contact database events
|
/// Enable listening to contact database events. If [shouldScan] is true, also
|
||||||
void enableDatabaseListener() {
|
/// performs a scan of the contacts database, if we're allowed.
|
||||||
|
Future<void> enable({bool shouldScan = true}) async {
|
||||||
FlutterContacts.addListener(_onContactsDatabaseUpdate);
|
FlutterContacts.addListener(_onContactsDatabaseUpdate);
|
||||||
|
|
||||||
|
if (shouldScan && await _canUseContactIntegration()) {
|
||||||
|
unawaited(scanContacts());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disable listening to contact database events
|
/// Disable listening to contact database events. Also removes all roster items
|
||||||
void disableDatabaseListener() {
|
/// that are pseudo roster items.
|
||||||
|
Future<void> disable() async {
|
||||||
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
|
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
|
||||||
|
|
||||||
|
await GetIt.I.get<RosterService>().removePseudoRosterItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onContactsDatabaseUpdate() async {
|
Future<void> _onContactsDatabaseUpdate() async {
|
||||||
@@ -123,7 +129,6 @@ class ContactsService {
|
|||||||
Future<Map<String, String>> _getContactIds() async {
|
Future<Map<String, String>> _getContactIds() async {
|
||||||
if (_contactIds != null) return _contactIds!;
|
if (_contactIds != null) return _contactIds!;
|
||||||
|
|
||||||
// TODO(Unknown): Can we just .cast<String, String>() here?
|
|
||||||
_contactIds = Map<String, String>.fromEntries(
|
_contactIds = Map<String, String>.fromEntries(
|
||||||
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
|
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
|
||||||
(item) => MapEntry(
|
(item) => MapEntry(
|
||||||
@@ -276,7 +281,8 @@ class ContactsService {
|
|||||||
return cs.updateConversation(
|
return cs.updateConversation(
|
||||||
contact.jid,
|
contact.jid,
|
||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
contactAvatarPath: contactAvatarPath,
|
contactAvatarPath:
|
||||||
|
contact.thumbnail != null ? contactAvatarPath : null,
|
||||||
contactDisplayName: contact.displayName,
|
contactDisplayName: contact.displayName,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ class ConversationService {
|
|||||||
tmp.add(
|
tmp.add(
|
||||||
Conversation.fromDatabaseJson(
|
Conversation.fromDatabaseJson(
|
||||||
c,
|
c,
|
||||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
rosterItem?.showAddToRosterButton ?? true,
|
||||||
rosterItem?.subscription ?? 'none',
|
|
||||||
lastMessage,
|
lastMessage,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -136,7 +135,8 @@ class ConversationService {
|
|||||||
Message? lastMessage,
|
Message? lastMessage,
|
||||||
bool? open,
|
bool? open,
|
||||||
int? unreadCounter,
|
int? unreadCounter,
|
||||||
String? avatarUrl,
|
String? avatarPath,
|
||||||
|
Object? avatarHash = notSpecified,
|
||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
bool? muted,
|
bool? muted,
|
||||||
bool? encrypted,
|
bool? encrypted,
|
||||||
@@ -160,8 +160,11 @@ class ConversationService {
|
|||||||
if (unreadCounter != null) {
|
if (unreadCounter != null) {
|
||||||
c['unreadCounter'] = unreadCounter;
|
c['unreadCounter'] = unreadCounter;
|
||||||
}
|
}
|
||||||
if (avatarUrl != null) {
|
if (avatarPath != null) {
|
||||||
c['avatarUrl'] = avatarUrl;
|
c['avatarPath'] = avatarPath;
|
||||||
|
}
|
||||||
|
if (avatarHash != notSpecified) {
|
||||||
|
c['avatarHash'] = avatarHash as String?;
|
||||||
}
|
}
|
||||||
if (muted != null) {
|
if (muted != null) {
|
||||||
c['muted'] = boolToInt(muted);
|
c['muted'] = boolToInt(muted);
|
||||||
@@ -191,8 +194,7 @@ class ConversationService {
|
|||||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||||
var newConversation = Conversation.fromDatabaseJson(
|
var newConversation = Conversation.fromDatabaseJson(
|
||||||
result,
|
result,
|
||||||
rosterItem != null,
|
rosterItem?.showAddToRosterButton ?? true,
|
||||||
rosterItem?.subscription ?? 'none',
|
|
||||||
lastMessage,
|
lastMessage,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -215,7 +217,7 @@ class ConversationService {
|
|||||||
String title,
|
String title,
|
||||||
Message? lastMessage,
|
Message? lastMessage,
|
||||||
ConversationType type,
|
ConversationType type,
|
||||||
String avatarUrl,
|
String avatarPath,
|
||||||
String jid,
|
String jid,
|
||||||
int unreadCounter,
|
int unreadCounter,
|
||||||
int lastChangeTimestamp,
|
int lastChangeTimestamp,
|
||||||
@@ -231,14 +233,14 @@ class ConversationService {
|
|||||||
final newConversation = Conversation(
|
final newConversation = Conversation(
|
||||||
title,
|
title,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
avatarUrl,
|
avatarPath,
|
||||||
|
null,
|
||||||
jid,
|
jid,
|
||||||
unreadCounter,
|
unreadCounter,
|
||||||
type,
|
type,
|
||||||
lastChangeTimestamp,
|
lastChangeTimestamp,
|
||||||
open,
|
open,
|
||||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
rosterItem?.showAddToRosterButton ?? true,
|
||||||
rosterItem?.subscription ?? 'none',
|
|
||||||
muted,
|
muted,
|
||||||
encrypted,
|
encrypted,
|
||||||
ChatState.gone,
|
ChatState.gone,
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ const messagesTable = 'Messages';
|
|||||||
const rosterTable = 'RosterItems';
|
const rosterTable = 'RosterItems';
|
||||||
const mediaTable = 'SharedMedia';
|
const mediaTable = 'SharedMedia';
|
||||||
const preferenceTable = 'Preferences';
|
const preferenceTable = 'Preferences';
|
||||||
const omemoDeviceTable = 'OmemoDevices';
|
|
||||||
const omemoDeviceListTable = 'OmemoDeviceList';
|
|
||||||
const omemoRatchetsTable = 'OmemoSessions';
|
|
||||||
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
|
||||||
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
|
||||||
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
|
||||||
const omemoFingerprintCache = 'OmemoFingerprintCache';
|
|
||||||
const xmppStateTable = 'XmppState';
|
const xmppStateTable = 'XmppState';
|
||||||
const contactsTable = 'Contacts';
|
const contactsTable = 'Contacts';
|
||||||
const stickersTable = 'Stickers';
|
const stickersTable = 'Stickers';
|
||||||
@@ -19,6 +12,10 @@ const subscriptionsTable = 'SubscriptionRequests';
|
|||||||
const fileMetadataTable = 'FileMetadata';
|
const fileMetadataTable = 'FileMetadata';
|
||||||
const fileMetadataHashesTable = 'FileMetadataHashes';
|
const fileMetadataHashesTable = 'FileMetadataHashes';
|
||||||
const reactionsTable = 'Reactions';
|
const reactionsTable = 'Reactions';
|
||||||
|
const omemoDevicesTable = 'OmemoDevices';
|
||||||
|
const omemoDeviceListTable = 'OmemoDeviceList';
|
||||||
|
const omemoRatchetsTable = 'OmemoRatchets';
|
||||||
|
const omemoTrustTable = 'OmemoTrustTable';
|
||||||
|
|
||||||
const typeString = 0;
|
const typeString = 0;
|
||||||
const typeInt = 1;
|
const typeInt = 1;
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
await db.execute('''
|
await db.execute(
|
||||||
|
'''
|
||||||
CREATE TABLE $messagesTable (
|
CREATE TABLE $messagesTable (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
sender TEXT NOT NULL,
|
sender TEXT NOT NULL,
|
||||||
@@ -46,13 +47,15 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
pseudoMessageData TEXT,
|
pseudoMessageData TEXT,
|
||||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||||
)''');
|
)''',
|
||||||
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reactions
|
// Reactions
|
||||||
await db.execute('''
|
await db.execute(
|
||||||
|
'''
|
||||||
CREATE TABLE $reactionsTable (
|
CREATE TABLE $reactionsTable (
|
||||||
senderJid TEXT NOT NULL,
|
senderJid TEXT NOT NULL,
|
||||||
emoji TEXT NOT NULL,
|
emoji TEXT NOT NULL,
|
||||||
@@ -60,13 +63,15 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||||
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
)''');
|
)''',
|
||||||
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
||||||
);
|
);
|
||||||
|
|
||||||
// File metadata
|
// File metadata
|
||||||
await db.execute('''
|
await db.execute(
|
||||||
|
'''
|
||||||
CREATE TABLE $fileMetadataTable (
|
CREATE TABLE $fileMetadataTable (
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
path TEXT,
|
path TEXT,
|
||||||
@@ -83,8 +88,10 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
cipherTextHashes TEXT,
|
cipherTextHashes TEXT,
|
||||||
filename TEXT NOT NULL,
|
filename TEXT NOT NULL,
|
||||||
size INTEGER
|
size INTEGER
|
||||||
)''');
|
)''',
|
||||||
await db.execute('''
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
CREATE TABLE $fileMetadataHashesTable (
|
CREATE TABLE $fileMetadataHashesTable (
|
||||||
algorithm TEXT NOT NULL,
|
algorithm TEXT NOT NULL,
|
||||||
value TEXT NOT NULL,
|
value TEXT NOT NULL,
|
||||||
@@ -92,7 +99,8 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
|
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
|
||||||
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
)''');
|
)''',
|
||||||
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
||||||
);
|
);
|
||||||
@@ -103,7 +111,8 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
CREATE TABLE $conversationsTable (
|
CREATE TABLE $conversationsTable (
|
||||||
jid TEXT NOT NULL PRIMARY KEY,
|
jid TEXT NOT NULL PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
avatarUrl TEXT NOT NULL,
|
avatarPath TEXT NOT NULL,
|
||||||
|
avatarHash TEXT,
|
||||||
type TEXT NOT NULL,
|
type TEXT NOT NULL,
|
||||||
lastChangeTimestamp INTEGER NOT NULL,
|
lastChangeTimestamp INTEGER NOT NULL,
|
||||||
unreadCounter INTEGER NOT NULL,
|
unreadCounter INTEGER NOT NULL,
|
||||||
@@ -124,11 +133,13 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Contacts
|
// Contacts
|
||||||
await db.execute('''
|
await db.execute(
|
||||||
|
'''
|
||||||
CREATE TABLE $contactsTable (
|
CREATE TABLE $contactsTable (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
jid TEXT NOT NULL
|
jid TEXT NOT NULL
|
||||||
)''');
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
// Roster
|
// Roster
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -137,7 +148,7 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
jid TEXT NOT NULL,
|
jid TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
avatarUrl TEXT NOT NULL,
|
avatarPath TEXT NOT NULL,
|
||||||
avatarHash TEXT NOT NULL,
|
avatarHash TEXT NOT NULL,
|
||||||
subscription TEXT NOT NULL,
|
subscription TEXT NOT NULL,
|
||||||
ask TEXT NOT NULL,
|
ask TEXT NOT NULL,
|
||||||
@@ -172,7 +183,8 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
description TEXT NOT NULL,
|
description TEXT NOT NULL,
|
||||||
hashAlgorithm TEXT NOT NULL,
|
hashAlgorithm TEXT NOT NULL,
|
||||||
hashValue TEXT NOT NULL,
|
hashValue TEXT NOT NULL,
|
||||||
restricted INTEGER NOT NULL
|
restricted INTEGER NOT NULL,
|
||||||
|
addedTimestamp INTEGER NOT NULL
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -185,81 +197,61 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
''',
|
''',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Subscription requests
|
|
||||||
await db.execute('''
|
|
||||||
CREATE TABLE $subscriptionsTable(
|
|
||||||
jid TEXT PRIMARY KEY
|
|
||||||
)''');
|
|
||||||
|
|
||||||
// OMEMO
|
// OMEMO
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
CREATE TABLE $omemoRatchetsTable (
|
CREATE TABLE $omemoDevicesTable (
|
||||||
|
jid TEXT NOT NULL PRIMARY KEY,
|
||||||
id INTEGER NOT NULL,
|
id INTEGER NOT NULL,
|
||||||
|
ikPub TEXT NOT NULL,
|
||||||
|
ik TEXT NOT NULL,
|
||||||
|
spkPub TEXT NOT NULL,
|
||||||
|
spk TEXT NOT NULL,
|
||||||
|
spkId INTEGER NOT NULL,
|
||||||
|
spkSig TEXT NOT NULL,
|
||||||
|
oldSpkPub TEXT,
|
||||||
|
oldSpk TEXT,
|
||||||
|
oldSpkId INTEGER,
|
||||||
|
opks TEXT NOT NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoDeviceListTable (
|
||||||
|
jid TEXT NOT NULL PRIMARY KEY,
|
||||||
|
devices TEXT NOT NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoRatchetsTable (
|
||||||
jid TEXT NOT NULL,
|
jid TEXT NOT NULL,
|
||||||
|
device INTEGER NOT NULL,
|
||||||
|
dhsPub TEXT NOT NULL,
|
||||||
dhs TEXT NOT NULL,
|
dhs TEXT NOT NULL,
|
||||||
dhs_pub TEXT NOT NULL,
|
dhrPub TEXT,
|
||||||
dhr TEXT,
|
|
||||||
rk TEXT NOT NULL,
|
rk TEXT NOT NULL,
|
||||||
cks TEXT,
|
cks TEXT,
|
||||||
ckr TEXT,
|
ckr TEXT,
|
||||||
ns INTEGER NOT NULL,
|
ns INTEGER NOT NULL,
|
||||||
nr INTEGER NOT NULL,
|
nr INTEGER NOT NULL,
|
||||||
pn INTEGER NOT NULL,
|
pn INTEGER NOT NULL,
|
||||||
ik_pub TEXT NOT NULL,
|
ik TEXT NOT NULL,
|
||||||
session_ad TEXT NOT NULL,
|
ad TEXT NOT NULL,
|
||||||
acknowledged INTEGER NOT NULL,
|
skipped TEXT NOT NULL,
|
||||||
mkskipped TEXT NOT NULL,
|
kex TEXT NOT NULL,
|
||||||
kex_timestamp INTEGER NOT NULL,
|
acked INTEGER NOT NULL,
|
||||||
kex TEXT,
|
PRIMARY KEY (jid, device)
|
||||||
PRIMARY KEY (jid, id)
|
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
CREATE TABLE $omemoTrustCacheTable (
|
CREATE TABLE $omemoTrustTable (
|
||||||
key TEXT PRIMARY KEY NOT NULL,
|
|
||||||
trust INTEGER NOT NULL
|
|
||||||
)''',
|
|
||||||
);
|
|
||||||
await db.execute(
|
|
||||||
'''
|
|
||||||
CREATE TABLE $omemoTrustDeviceListTable (
|
|
||||||
jid TEXT NOT NULL,
|
jid TEXT NOT NULL,
|
||||||
device INTEGER NOT NULL
|
device INTEGER NOT NULL,
|
||||||
)''',
|
trust INTEGER NOT NULL,
|
||||||
);
|
enabled INTEGER NOT NULL,
|
||||||
await db.execute(
|
PRIMARY KEY (jid, device)
|
||||||
'''
|
|
||||||
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)
|
|
||||||
)''',
|
|
||||||
);
|
|
||||||
await db.execute(
|
|
||||||
'''
|
|
||||||
CREATE TABLE $omemoFingerprintCache (
|
|
||||||
jid TEXT NOT NULL,
|
|
||||||
id INTEGER NOT NULL,
|
|
||||||
fingerprint TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (jid, id)
|
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ import 'package:moxxyv2/service/database/migrations/0002_reactions.dart';
|
|||||||
import 'package:moxxyv2/service/database/migrations/0002_reactions_2.dart';
|
import 'package:moxxyv2/service/database/migrations/0002_reactions_2.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0002_shared_media.dart';
|
import 'package:moxxyv2/service/database/migrations/0002_shared_media.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0002_sticker_metadata.dart';
|
import 'package:moxxyv2/service/database/migrations/0002_sticker_metadata.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0003_avatar_hashes.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0003_new_omemo.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0003_new_omemo_pseudo_messages.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0003_remove_subscriptions.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0003_sticker_pack_timestamp.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';
|
||||||
// ignore: implementation_imports
|
// ignore: implementation_imports
|
||||||
@@ -144,6 +149,11 @@ const List<DatabaseMigration<Database>> migrations = [
|
|||||||
DatabaseMigration(35, upgradeFromV34ToV35),
|
DatabaseMigration(35, upgradeFromV34ToV35),
|
||||||
DatabaseMigration(36, upgradeFromV35ToV36),
|
DatabaseMigration(36, upgradeFromV35ToV36),
|
||||||
DatabaseMigration(37, upgradeFromV36ToV37),
|
DatabaseMigration(37, upgradeFromV36ToV37),
|
||||||
|
DatabaseMigration(38, upgradeFromV37ToV38),
|
||||||
|
DatabaseMigration(39, upgradeFromV38ToV39),
|
||||||
|
DatabaseMigration(40, upgradeFromV39ToV40),
|
||||||
|
DatabaseMigration(41, upgradeFromV40ToV41),
|
||||||
|
DatabaseMigration(42, upgradeFromV41ToV42),
|
||||||
];
|
];
|
||||||
|
|
||||||
class DatabaseService {
|
class DatabaseService {
|
||||||
@@ -179,10 +189,23 @@ class DatabaseService {
|
|||||||
_log.finest('Key generation done...');
|
_log.finest('Key generation done...');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Just some sanity checks
|
||||||
|
final version = migrations.last.version;
|
||||||
|
assert(
|
||||||
|
migrations.every((migration) => migration.version <= version),
|
||||||
|
"Every migration's version must be smaller or equal to the last version",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
migrations
|
||||||
|
.sublist(0, migrations.length - 1)
|
||||||
|
.every((migration) => migration.version < version),
|
||||||
|
'The last migration must have the largest version',
|
||||||
|
);
|
||||||
|
|
||||||
database = await openDatabase(
|
database = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
password: key,
|
password: key,
|
||||||
version: 37,
|
version: version,
|
||||||
onCreate: createDatabase,
|
onCreate: createDatabase,
|
||||||
onConfigure: (db) async {
|
onConfigure: (db) async {
|
||||||
// In order to do schema changes during database upgrades, we disable foreign
|
// In order to do schema changes during database upgrades, we disable foreign
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import 'package:moxxyv2/service/database/constants.dart';
|
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
Future<void> upgradeFromV12ToV13(Database db) async {
|
Future<void> upgradeFromV12ToV13(Database db) async {
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
CREATE TABLE $omemoFingerprintCache (
|
CREATE TABLE OmemoFingerprintCache (
|
||||||
jid TEXT NOT NULL,
|
jid TEXT NOT NULL,
|
||||||
id INTEGER NOT NULL,
|
id INTEGER NOT NULL,
|
||||||
fingerprint TEXT NOT NULL,
|
fingerprint TEXT NOT NULL,
|
||||||
|
|||||||
13
lib/service/database/migrations/0003_avatar_hashes.dart
Normal file
13
lib/service/database/migrations/0003_avatar_hashes.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV37ToV38(Database db) async {
|
||||||
|
await db
|
||||||
|
.execute('ALTER TABLE $conversationsTable ADD COLUMN avatarHash TEXT');
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $conversationsTable RENAME COLUMN avatarUrl TO avatarPath',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $rosterTable RENAME COLUMN avatarUrl TO avatarPath',
|
||||||
|
);
|
||||||
|
}
|
||||||
72
lib/service/database/migrations/0003_new_omemo.dart
Normal file
72
lib/service/database/migrations/0003_new_omemo.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV39ToV40(Database db) async {
|
||||||
|
// Remove the old tables
|
||||||
|
await db.execute('DROP TABLE OmemoDevices');
|
||||||
|
await db.execute('DROP TABLE OmemoDeviceList');
|
||||||
|
await db.execute('DROP TABLE OmemoTrustCacheList');
|
||||||
|
await db.execute('DROP TABLE OmemoTrustDeviceList');
|
||||||
|
await db.execute('DROP TABLE OmemoTrustEnableList');
|
||||||
|
await db.execute('DROP TABLE OmemoFingerprintCache');
|
||||||
|
|
||||||
|
// Create the new tables
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoDevicesTable (
|
||||||
|
jid TEXT NOT NULL PRIMARY KEY,
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
ikPub TEXT NOT NULL,
|
||||||
|
ik TEXT NOT NULL,
|
||||||
|
spkPub TEXT NOT NULL,
|
||||||
|
spk TEXT NOT NULL,
|
||||||
|
spkId INTEGER NOT NULL,
|
||||||
|
spkSig TEXT NOT NULL,
|
||||||
|
oldSpkPub TEXT,
|
||||||
|
oldSpk TEXT,
|
||||||
|
oldSpkId INTEGER,
|
||||||
|
opks TEXT NOT NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoDeviceListTable (
|
||||||
|
jid TEXT NOT NULL PRIMARY KEY,
|
||||||
|
devices TEXT NOT NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoRatchetsTable (
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
device INTEGER NOT NULL,
|
||||||
|
dhsPub TEXT NOT NULL,
|
||||||
|
dhs TEXT NOT NULL,
|
||||||
|
dhrPub TEXT,
|
||||||
|
rk TEXT NOT NULL,
|
||||||
|
cks TEXT,
|
||||||
|
ckr TEXT,
|
||||||
|
ns INTEGER NOT NULL,
|
||||||
|
nr INTEGER NOT NULL,
|
||||||
|
pn INTEGER NOT NULL,
|
||||||
|
ik TEXT NOT NULL,
|
||||||
|
ad TEXT NOT NULL,
|
||||||
|
skipped TEXT NOT NULL,
|
||||||
|
kex TEXT NOT NULL,
|
||||||
|
acked INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (jid, device)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoTrustTable (
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
device INTEGER NOT NULL,
|
||||||
|
trust INTEGER NOT NULL,
|
||||||
|
enabled INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (jid, device)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV40ToV41(Database db) async {
|
||||||
|
final messages = await db.query(
|
||||||
|
messagesTable,
|
||||||
|
where: 'pseudoMessageType IS NOT NULL',
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final message in messages) {
|
||||||
|
await db.insert(
|
||||||
|
messagesTable,
|
||||||
|
{
|
||||||
|
...message,
|
||||||
|
'pseudoMessageData': jsonEncode({
|
||||||
|
'ratchetsAdded': 1,
|
||||||
|
'ratchetsReplaced': 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV38ToV39(Database db) async {
|
||||||
|
await db.execute('DROP TABLE $subscriptionsTable');
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV41ToV42(Database db) async {
|
||||||
|
/// Add the new column
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
ALTER TABLE $stickerPacksTable ADD COLUMN addedTimestamp INTEGER NOT NULL DEFAULT 0;
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Ensure that the sticker packs are sorted (albeit randomly)
|
||||||
|
final stickerPackIds = await db.query(
|
||||||
|
stickerPacksTable,
|
||||||
|
columns: ['id'],
|
||||||
|
);
|
||||||
|
|
||||||
|
var counter = 0;
|
||||||
|
for (final id in stickerPackIds) {
|
||||||
|
await db.update(
|
||||||
|
stickerPacksTable,
|
||||||
|
{
|
||||||
|
'addedTimestamp': counter,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
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';
|
||||||
@@ -10,6 +11,8 @@ import 'package:moxxyv2/service/blocking.dart';
|
|||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/contacts.dart';
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/service/helpers.dart';
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
@@ -25,10 +28,12 @@ import 'package:moxxyv2/service/reactions.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/stickers.dart';
|
import 'package:moxxyv2/service/stickers.dart';
|
||||||
import 'package:moxxyv2/service/subscription.dart';
|
import 'package:moxxyv2/service/storage.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
|
import 'package:moxxyv2/shared/debug.dart' as debug;
|
||||||
|
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';
|
||||||
@@ -100,6 +105,12 @@ void setupBackgroundEventHandler() {
|
|||||||
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
||||||
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
|
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
|
||||||
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
|
EventTypeMatcher<GetReactionsForMessageCommand>(performGetReactions),
|
||||||
|
EventTypeMatcher<RequestAvatarForJidCommand>(performRequestAvatarForJid),
|
||||||
|
EventTypeMatcher<GetStorageUsageCommand>(performGetStorageUsage),
|
||||||
|
EventTypeMatcher<DeleteOldMediaFilesCommand>(performOldMediaFileDeletion),
|
||||||
|
EventTypeMatcher<GetPagedStickerPackCommand>(performGetPagedStickerPacks),
|
||||||
|
EventTypeMatcher<GetStickerPackByIdCommand>(performGetStickerPackById),
|
||||||
|
EventTypeMatcher<DebugCommand>(performDebugCommand),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@@ -180,7 +191,6 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
|
|||||||
.where((c) => c.open)
|
.where((c) => c.open)
|
||||||
.toList(),
|
.toList(),
|
||||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||||
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +328,7 @@ Future<void> performSendMessage(
|
|||||||
command.editSid!,
|
command.editSid!,
|
||||||
command.recipients.first,
|
command.recipients.first,
|
||||||
command.chatState.isNotEmpty
|
command.chatState.isNotEmpty
|
||||||
? chatStateFromString(command.chatState)
|
? ChatState.fromName(command.chatState)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -328,7 +338,7 @@ Future<void> performSendMessage(
|
|||||||
body: command.body,
|
body: command.body,
|
||||||
recipients: command.recipients,
|
recipients: command.recipients,
|
||||||
chatState: command.chatState.isNotEmpty
|
chatState: command.chatState.isNotEmpty
|
||||||
? chatStateFromString(command.chatState)
|
? ChatState.fromName(command.chatState)
|
||||||
: null,
|
: null,
|
||||||
quotedMessage: command.quotedMessage,
|
quotedMessage: command.quotedMessage,
|
||||||
currentConversationJid: command.currentConversationJid,
|
currentConversationJid: command.currentConversationJid,
|
||||||
@@ -392,13 +402,13 @@ Future<void> performSetPreferences(
|
|||||||
final css = GetIt.I.get<ContactsService>();
|
final css = GetIt.I.get<ContactsService>();
|
||||||
if (command.preferences.enableContactIntegration) {
|
if (command.preferences.enableContactIntegration) {
|
||||||
if (!oldPrefs.enableContactIntegration) {
|
if (!oldPrefs.enableContactIntegration) {
|
||||||
css.enableDatabaseListener();
|
await css.enable();
|
||||||
}
|
}
|
||||||
|
|
||||||
unawaited(css.scanContacts());
|
unawaited(css.scanContacts());
|
||||||
} else {
|
} else {
|
||||||
if (oldPrefs.enableContactIntegration) {
|
if (oldPrefs.enableContactIntegration) {
|
||||||
css.disableDatabaseListener();
|
await css.disable();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +458,43 @@ Future<void> performSetPreferences(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempts to achieve a "both" subscription with [jid].
|
||||||
|
Future<void> _maybeAchieveBothSubscription(String jid) async {
|
||||||
|
final roster = GetIt.I.get<RosterService>();
|
||||||
|
final item = await roster.getRosterItemByJid(jid);
|
||||||
|
if (item != null) {
|
||||||
|
GetIt.I.get<Logger>().finest(
|
||||||
|
'Roster item for $jid has subscription "${item.subscription}" with ask "${item.ask}"',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nothing more to do
|
||||||
|
if (item.subscription == 'both') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final pm = GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<PresenceManager>(presenceManager)!;
|
||||||
|
switch (item.subscription) {
|
||||||
|
case 'both':
|
||||||
|
return;
|
||||||
|
case 'none':
|
||||||
|
case 'from':
|
||||||
|
if (item.ask != 'subscribe') {
|
||||||
|
// Try to move from "from"/"none" to "both", by going over "From + Pending Out"
|
||||||
|
await pm.requestSubscription(JID.fromString(item.jid));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'to':
|
||||||
|
// Move from "to" to "both"
|
||||||
|
await pm.acceptSubscriptionRequest(JID.fromString(item.jid));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await roster.addToRosterWrapper('', '', jid, jid.split('@')[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> performAddContact(
|
Future<void> performAddContact(
|
||||||
AddContactCommand command, {
|
AddContactCommand command, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
@@ -459,13 +506,76 @@ Future<void> performAddContact(
|
|||||||
final inRoster = await roster.isInRoster(jid);
|
final inRoster = await roster.isInRoster(jid);
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
|
||||||
|
final conversation = await cs.getConversationByJid(jid);
|
||||||
|
if (conversation != null) {
|
||||||
|
await cs.createOrUpdateConversation(
|
||||||
|
jid,
|
||||||
|
update: (c) async {
|
||||||
|
final newConversation = await cs.updateConversation(
|
||||||
|
jid,
|
||||||
|
open: true,
|
||||||
|
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
|
);
|
||||||
|
|
||||||
|
sendEvent(
|
||||||
|
AddContactResultEvent(
|
||||||
|
conversation: newConversation,
|
||||||
|
added: !inRoster,
|
||||||
|
),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return newConversation;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to roster, if needed
|
||||||
|
await _maybeAchieveBothSubscription(jid);
|
||||||
|
} else {
|
||||||
|
// We did not have a conversation with that JID.
|
||||||
|
final info = await GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getDiscoManager()!
|
||||||
|
.discoInfoQuery(JID.fromString(jid));
|
||||||
|
var isGroupchat = false;
|
||||||
|
if (info.isType<DiscoInfo>()) {
|
||||||
|
isGroupchat = info.get<DiscoInfo>().identities.firstWhereOrNull(
|
||||||
|
(identity) => identity.category == 'conference',
|
||||||
|
) !=
|
||||||
|
null;
|
||||||
|
} else if (info.isType<RemoteServerNotFoundError>()) {
|
||||||
|
sendEvent(
|
||||||
|
ErrorEvent(
|
||||||
|
errorId: ErrorType.remoteServerNotFound.value,
|
||||||
|
),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (info.isType<RemoteServerTimeoutError>()) {
|
||||||
|
sendEvent(
|
||||||
|
ErrorEvent(
|
||||||
|
errorId: ErrorType.remoteServerTimeout.value,
|
||||||
|
),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGroupchat) {
|
||||||
|
// The JID points to a groupchat. Handle that on the UI side
|
||||||
|
sendEvent(
|
||||||
|
JidIsGroupchatEvent(),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
await cs.createOrUpdateConversation(
|
await cs.createOrUpdateConversation(
|
||||||
jid,
|
jid,
|
||||||
create: () async {
|
create: () async {
|
||||||
// Create
|
// Create
|
||||||
final css = GetIt.I.get<ContactsService>();
|
final css = GetIt.I.get<ContactsService>();
|
||||||
final contactId = await css.getContactIdForJid(jid);
|
final contactId = await css.getContactIdForJid(jid);
|
||||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
final prefs =
|
||||||
|
await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
final newConversation = await cs.addConversationFromData(
|
final newConversation = await cs.addConversationFromData(
|
||||||
jid.split('@')[0],
|
jid.split('@')[0],
|
||||||
null,
|
null,
|
||||||
@@ -483,21 +593,10 @@ Future<void> performAddContact(
|
|||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
AddContactResultEvent(conversation: newConversation, added: !inRoster),
|
AddContactResultEvent(
|
||||||
id: id,
|
conversation: newConversation,
|
||||||
);
|
added: !inRoster,
|
||||||
|
),
|
||||||
return newConversation;
|
|
||||||
},
|
|
||||||
update: (c) async {
|
|
||||||
final newConversation = await cs.updateConversation(
|
|
||||||
jid,
|
|
||||||
open: true,
|
|
||||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
|
||||||
);
|
|
||||||
|
|
||||||
sendEvent(
|
|
||||||
AddContactResultEvent(conversation: newConversation, added: !inRoster),
|
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -505,30 +604,10 @@ Future<void> performAddContact(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Manage subscription requests
|
// Add to roster, if required
|
||||||
final srs = GetIt.I.get<SubscriptionRequestService>();
|
await _maybeAchieveBothSubscription(jid);
|
||||||
final hasSubscriptionRequest = await srs.hasPendingSubscriptionRequest(jid);
|
|
||||||
if (hasSubscriptionRequest) {
|
|
||||||
await srs.acceptSubscriptionRequest(jid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to roster, if needed
|
|
||||||
final item = await roster.getRosterItemByJid(jid);
|
|
||||||
if (item != null) {
|
|
||||||
if (item.subscription != 'from' && item.subscription != 'both') {
|
|
||||||
GetIt.I.get<Logger>().finest(
|
|
||||||
'Roster item already exists with no presence subscription from them. Sending subscription request',
|
|
||||||
);
|
|
||||||
srs.sendSubscriptionRequest(jid);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await roster.addToRosterWrapper('', '', jid, jid.split('@')[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to figure out an avatar
|
|
||||||
// TODO(Unknown): Don't do that here. Do it more intelligently.
|
|
||||||
await GetIt.I.get<AvatarService>().subscribeJid(jid);
|
|
||||||
await GetIt.I.get<AvatarService>().fetchAndUpdateAvatarForJid(jid, '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> performRemoveContact(
|
Future<void> performRemoveContact(
|
||||||
@@ -547,7 +626,7 @@ Future<void> performRemoveContact(
|
|||||||
sendEvent(
|
sendEvent(
|
||||||
ConversationUpdatedEvent(
|
ConversationUpdatedEvent(
|
||||||
conversation: conversation.copyWith(
|
conversation: conversation.copyWith(
|
||||||
inRoster: false,
|
showAddToRoster: true,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -594,6 +673,7 @@ Future<void> performRequestDownload(
|
|||||||
),
|
),
|
||||||
message.id,
|
message.id,
|
||||||
message.fileMetadata!.id,
|
message.fileMetadata!.id,
|
||||||
|
message.fileMetadata!.plaintextHashes?.isNotEmpty ?? false,
|
||||||
message.conversationJid,
|
message.conversationJid,
|
||||||
mimeGuess,
|
mimeGuess,
|
||||||
),
|
),
|
||||||
@@ -615,23 +695,32 @@ Future<void> performSetShareOnlineStatus(
|
|||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
final rs = GetIt.I.get<RosterService>();
|
final rs = GetIt.I.get<RosterService>();
|
||||||
final srs = GetIt.I.get<SubscriptionRequestService>();
|
|
||||||
final item = await rs.getRosterItemByJid(command.jid);
|
final item = await rs.getRosterItemByJid(command.jid);
|
||||||
|
|
||||||
// TODO(Unknown): Maybe log
|
// TODO(Unknown): Maybe log
|
||||||
if (item == null) return;
|
if (item == null) return;
|
||||||
|
|
||||||
|
final jid = JID.fromString(command.jid);
|
||||||
|
final pm = GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<PresenceManager>(presenceManager)!;
|
||||||
if (command.share) {
|
if (command.share) {
|
||||||
if (item.ask == 'subscribe') {
|
switch (item.subscription) {
|
||||||
await srs.acceptSubscriptionRequest(command.jid);
|
case 'to':
|
||||||
} else {
|
await pm.acceptSubscriptionRequest(jid);
|
||||||
srs.sendSubscriptionRequest(command.jid);
|
break;
|
||||||
|
case 'none':
|
||||||
|
case 'from':
|
||||||
|
await pm.requestSubscription(jid);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (item.ask == 'subscribe') {
|
switch (item.subscription) {
|
||||||
await srs.rejectSubscriptionRequest(command.jid);
|
case 'both':
|
||||||
} else {
|
case 'from':
|
||||||
srs.sendUnsubscriptionRequest(command.jid);
|
case 'to':
|
||||||
|
await pm.unsubscribe(jid);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -673,9 +762,9 @@ Future<void> performSendChatState(
|
|||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
|
||||||
if (command.jid != '') {
|
if (command.jid != '') {
|
||||||
conn
|
await conn
|
||||||
.getManagerById<ChatStateManager>(chatStateManager)!
|
.getManagerById<ChatStateManager>(chatStateManager)!
|
||||||
.sendChatState(chatStateFromString(command.state), command.jid);
|
.sendChatState(ChatState.fromName(command.state), command.jid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,7 +801,9 @@ Future<void> performSignOut(SignOutCommand command, {dynamic extra}) async {
|
|||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
final xss = GetIt.I.get<XmppStateService>();
|
final xss = GetIt.I.get<XmppStateService>();
|
||||||
unawaited(conn.disconnect());
|
unawaited(conn.disconnect());
|
||||||
await xss.modifyXmppState((state) => XmppState());
|
await xss.modifyXmppState(
|
||||||
|
(state) => XmppState(),
|
||||||
|
);
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
SignedOutEvent(),
|
SignedOutEvent(),
|
||||||
@@ -754,7 +845,7 @@ Future<void> performGetOmemoFingerprints(
|
|||||||
final omemo = GetIt.I.get<OmemoService>();
|
final omemo = GetIt.I.get<OmemoService>();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
GetConversationOmemoFingerprintsResult(
|
GetConversationOmemoFingerprintsResult(
|
||||||
fingerprints: await omemo.getOmemoKeysForJid(command.jid),
|
fingerprints: await omemo.getFingerprintsForJid(command.jid),
|
||||||
),
|
),
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
@@ -767,7 +858,7 @@ Future<void> performEnableOmemoKey(
|
|||||||
final id = extra as String;
|
final id = extra as String;
|
||||||
|
|
||||||
final omemo = GetIt.I.get<OmemoService>();
|
final omemo = GetIt.I.get<OmemoService>();
|
||||||
await omemo.setOmemoKeyEnabled(
|
await omemo.setDeviceEnablement(
|
||||||
command.jid,
|
command.jid,
|
||||||
command.deviceId,
|
command.deviceId,
|
||||||
command.enabled,
|
command.enabled,
|
||||||
@@ -783,10 +874,14 @@ Future<void> performRecreateSessions(
|
|||||||
RecreateSessionsCommand command, {
|
RecreateSessionsCommand command, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid);
|
// Remove all ratchets
|
||||||
|
await GetIt.I.get<OmemoService>().removeAllRatchets(command.jid);
|
||||||
|
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
// And force the creation of new ones
|
||||||
await conn.getManagerById<BaseOmemoManager>(omemoManager)!.sendOmemoHeartbeat(
|
await GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<OmemoManager>(omemoManager)!
|
||||||
|
.sendOmemoHeartbeat(
|
||||||
command.jid,
|
command.jid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -815,14 +910,14 @@ Future<void> performGetOwnOmemoFingerprints(
|
|||||||
final id = extra as String;
|
final id = extra as String;
|
||||||
final os = GetIt.I.get<OmemoService>();
|
final os = GetIt.I.get<OmemoService>();
|
||||||
final xs = GetIt.I.get<XmppService>();
|
final xs = GetIt.I.get<XmppService>();
|
||||||
await os.ensureInitialized();
|
|
||||||
|
|
||||||
final jid = (await xs.getConnectionSettings())!.jid;
|
final jid = (await xs.getConnectionSettings())!.jid;
|
||||||
|
final device = await os.getDevice();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
GetOwnOmemoFingerprintsResult(
|
GetOwnOmemoFingerprintsResult(
|
||||||
ownDeviceFingerprint: await os.getDeviceFingerprint(),
|
ownDeviceFingerprint: await device.getFingerprint(),
|
||||||
ownDeviceId: await os.getDeviceId(),
|
ownDeviceId: device.id,
|
||||||
fingerprints: await os.getOwnFingerprints(jid),
|
fingerprints: await os.getFingerprintsForJid(jid.toString()),
|
||||||
),
|
),
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
@@ -834,7 +929,7 @@ Future<void> performRemoveOwnDevice(
|
|||||||
}) async {
|
}) async {
|
||||||
await GetIt.I
|
await GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<BaseOmemoManager>(omemoManager)!
|
.getManagerById<OmemoManager>(omemoManager)!
|
||||||
.deleteDevice(command.deviceId);
|
.deleteDevice(command.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,9 +938,7 @@ Future<void> performRegenerateOwnDevice(
|
|||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
final id = extra as String;
|
final id = extra as String;
|
||||||
final jid =
|
final device = await GetIt.I.get<OmemoService>().regenerateDevice();
|
||||||
GetIt.I.get<XmppConnection>().connectionSettings.jid.toBare().toString();
|
|
||||||
final device = await GetIt.I.get<OmemoService>().regenerateDevice(jid);
|
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
RegenerateOwnDeviceResult(device: device),
|
RegenerateOwnDeviceResult(device: device),
|
||||||
@@ -864,17 +957,14 @@ Future<void> performMessageRetraction(
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
if (command.conversationJid != '') {
|
if (command.conversationJid != '') {
|
||||||
(GetIt.I
|
final manager = GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<MessageManager>(messageManager)!)
|
.getManagerById<MessageManager>(messageManager)!;
|
||||||
.sendMessage(
|
await manager.sendMessage(
|
||||||
MessageDetails(
|
JID.fromString(command.conversationJid),
|
||||||
to: command.conversationJid,
|
TypedMap<StanzaHandlerExtension>.fromList([
|
||||||
messageRetraction: MessageRetractionData(
|
MessageRetractionData(command.originId, t.messages.retractedFallback),
|
||||||
command.originId,
|
]),
|
||||||
t.messages.retractedFallback,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -899,9 +989,9 @@ Future<void> performMarkConversationAsRead(
|
|||||||
sendEvent(ConversationUpdatedEvent(conversation: conversation));
|
sendEvent(ConversationUpdatedEvent(conversation: conversation));
|
||||||
|
|
||||||
if (conversation.lastMessage != null) {
|
if (conversation.lastMessage != null) {
|
||||||
await GetIt.I.get<XmppService>().sendReadMarker(
|
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||||
command.conversationJid,
|
conversation.lastMessage!.id,
|
||||||
conversation.lastMessage!.sid,
|
conversation.type != ConversationType.note,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -916,26 +1006,10 @@ Future<void> performMarkMessageAsRead(
|
|||||||
MarkMessageAsReadCommand command, {
|
MarkMessageAsReadCommand command, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||||
|
command.id,
|
||||||
final conversation = await cs.createOrUpdateConversation(
|
command.sendMarker,
|
||||||
command.conversationJid,
|
|
||||||
update: (c) async {
|
|
||||||
return cs.updateConversation(
|
|
||||||
command.conversationJid,
|
|
||||||
unreadCounter: command.newUnreadCounter,
|
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (conversation != null) {
|
|
||||||
sendEvent(ConversationUpdatedEvent(conversation: conversation));
|
|
||||||
|
|
||||||
await GetIt.I.get<XmppService>().sendReadMarker(
|
|
||||||
command.conversationJid,
|
|
||||||
command.sid,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> performAddMessageReaction(
|
Future<void> performAddMessageReaction(
|
||||||
@@ -956,23 +1030,24 @@ Future<void> performAddMessageReaction(
|
|||||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||||
|
|
||||||
// Send the reaction
|
// Send the reaction
|
||||||
GetIt.I
|
final manager = GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<MessageManager>(messageManager)!
|
.getManagerById<MessageManager>(messageManager)!;
|
||||||
.sendMessage(
|
await manager.sendMessage(
|
||||||
MessageDetails(
|
JID.fromString(command.conversationJid),
|
||||||
to: command.conversationJid,
|
TypedMap<StanzaHandlerExtension>.fromList([
|
||||||
messageReactions: MessageReactions(
|
MessageReactionsData(
|
||||||
msg.originId ?? msg.sid,
|
msg.originId ?? msg.sid,
|
||||||
await rs.getReactionsForMessageByJid(
|
await rs.getReactionsForMessageByJid(
|
||||||
command.messageId,
|
command.messageId,
|
||||||
jid,
|
jid,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
requestChatMarkers: false,
|
const MarkableData(false),
|
||||||
messageProcessingHints:
|
MessageProcessingHintData([
|
||||||
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
|
if (!msg.containsNoStore) MessageProcessingHint.store,
|
||||||
),
|
]),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -995,23 +1070,24 @@ Future<void> performRemoveMessageReaction(
|
|||||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||||
|
|
||||||
// Send the reaction
|
// Send the reaction
|
||||||
GetIt.I
|
final manager = GetIt.I
|
||||||
.get<XmppConnection>()
|
.get<XmppConnection>()
|
||||||
.getManagerById<MessageManager>(messageManager)!
|
.getManagerById<MessageManager>(messageManager)!;
|
||||||
.sendMessage(
|
await manager.sendMessage(
|
||||||
MessageDetails(
|
JID.fromString(command.conversationJid),
|
||||||
to: command.conversationJid,
|
TypedMap<StanzaHandlerExtension>.fromList([
|
||||||
messageReactions: MessageReactions(
|
MessageReactionsData(
|
||||||
msg.originId ?? msg.sid,
|
msg.originId ?? msg.sid,
|
||||||
await rs.getReactionsForMessageByJid(
|
await rs.getReactionsForMessageByJid(
|
||||||
command.messageId,
|
command.messageId,
|
||||||
jid,
|
jid,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
requestChatMarkers: false,
|
const MarkableData(false),
|
||||||
messageProcessingHints:
|
MessageProcessingHintData([
|
||||||
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
|
if (!msg.containsNoStore) MessageProcessingHint.store,
|
||||||
),
|
]),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1020,9 +1096,9 @@ Future<void> performMarkDeviceVerified(
|
|||||||
MarkOmemoDeviceAsVerifiedCommand command, {
|
MarkOmemoDeviceAsVerifiedCommand command, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
await GetIt.I.get<OmemoService>().verifyDevice(
|
await GetIt.I.get<OmemoService>().setDeviceVerified(
|
||||||
command.deviceId,
|
|
||||||
command.jid,
|
command.jid,
|
||||||
|
command.deviceId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1129,6 +1205,8 @@ Future<void> performFetchStickerPack(
|
|||||||
stickerPack.hashValue,
|
stickerPack.hashValue,
|
||||||
stickerPack.restricted,
|
stickerPack.restricted,
|
||||||
false,
|
false,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
id: id,
|
id: id,
|
||||||
@@ -1248,3 +1326,125 @@ Future<void> performGetReactions(
|
|||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> performRequestAvatarForJid(
|
||||||
|
RequestAvatarForJidCommand command, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {
|
||||||
|
final as = GetIt.I.get<AvatarService>();
|
||||||
|
Future<void> future;
|
||||||
|
if (command.ownAvatar) {
|
||||||
|
future = as.requestOwnAvatar();
|
||||||
|
} else {
|
||||||
|
future = as.requestAvatar(
|
||||||
|
JID.fromString(command.jid),
|
||||||
|
command.hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(future);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performGetStorageUsage(
|
||||||
|
GetStorageUsageCommand command, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {
|
||||||
|
sendEvent(
|
||||||
|
GetStorageUsageEvent(
|
||||||
|
mediaUsage: await GetIt.I.get<StorageService>().computeUsedMediaStorage(),
|
||||||
|
stickerUsage:
|
||||||
|
await GetIt.I.get<StorageService>().computeUsedStickerStorage(),
|
||||||
|
),
|
||||||
|
id: extra as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performOldMediaFileDeletion(
|
||||||
|
DeleteOldMediaFilesCommand command, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {
|
||||||
|
await GetIt.I.get<StorageService>().deleteOldMediaFiles(command.timeOffset);
|
||||||
|
|
||||||
|
sendEvent(
|
||||||
|
DeleteOldMediaFilesDoneEvent(
|
||||||
|
newUsage: await GetIt.I.get<StorageService>().computeUsedMediaStorage(),
|
||||||
|
conversations:
|
||||||
|
(await GetIt.I.get<ConversationService>().loadConversations())
|
||||||
|
.where((c) => c.open)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
id: extra as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performGetPagedStickerPacks(
|
||||||
|
GetPagedStickerPackCommand command, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {
|
||||||
|
final result = await GetIt.I.get<StickersService>().getPaginatedStickerPacks(
|
||||||
|
command.olderThan,
|
||||||
|
command.timestamp,
|
||||||
|
command.includeStickers,
|
||||||
|
);
|
||||||
|
|
||||||
|
sendEvent(
|
||||||
|
PagedStickerPackResult(
|
||||||
|
stickerPacks: result,
|
||||||
|
),
|
||||||
|
id: extra as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performGetStickerPackById(
|
||||||
|
GetStickerPackByIdCommand command, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {
|
||||||
|
sendEvent(
|
||||||
|
GetStickerPackByIdResult(
|
||||||
|
stickerPack: await GetIt.I.get<StickersService>().getStickerPackById(
|
||||||
|
command.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
id: extra as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performDebugCommand(
|
||||||
|
DebugCommand command, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {
|
||||||
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
|
||||||
|
if (command.id == debug.DebugCommand.clearStreamResumption.id) {
|
||||||
|
// Disconnect
|
||||||
|
await conn.disconnect();
|
||||||
|
|
||||||
|
// Reset stream management
|
||||||
|
await conn.getManagerById<StreamManagementManager>(smManager)!.resetState();
|
||||||
|
|
||||||
|
// Reconnect
|
||||||
|
await conn.connect(
|
||||||
|
shouldReconnect: true,
|
||||||
|
waitForConnection: true,
|
||||||
|
);
|
||||||
|
} else if (command.id == debug.DebugCommand.requestRoster.id) {
|
||||||
|
await conn
|
||||||
|
.getManagerById<RosterManager>(rosterManager)!
|
||||||
|
.requestRoster(useRosterVersion: false);
|
||||||
|
} else if (command.id == debug.DebugCommand.logAvailableMediaFiles.id) {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final results = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
path,
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
$fileMetadataTable AS fmt
|
||||||
|
WHERE
|
||||||
|
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
|
||||||
|
AND path IS NOT NULL
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
Logger.root.finest(results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import 'package:moxxyv2/service/not_specified.dart';
|
|||||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:sqflite_common/sql.dart';
|
||||||
|
|
||||||
/// A class for returning whether a file metadata element was just created or retrieved.
|
/// A class for returning whether a file metadata element was just created or retrieved.
|
||||||
class FileMetadataWrapper {
|
class FileMetadataWrapper {
|
||||||
@@ -67,7 +68,8 @@ Future<String> computeCachedPathForFile(
|
|||||||
return path.join(
|
return path.join(
|
||||||
basePath,
|
basePath,
|
||||||
hash != null
|
hash != null
|
||||||
? '$hash.$ext'
|
// NOTE: [ext] already includes a leading "."
|
||||||
|
? '$hash$ext'
|
||||||
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
|
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -89,6 +91,10 @@ class FilesService {
|
|||||||
'value': hash.value,
|
'value': hash.value,
|
||||||
'id': metadataId,
|
'id': metadataId,
|
||||||
},
|
},
|
||||||
|
// TODO(Unknown): I would like to get rid of this. In events.dart, when processing
|
||||||
|
// a request to manually download a file, we should check if we already
|
||||||
|
// have hash pointers for a file metadata item.
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,11 +124,7 @@ String getUnrecoverableErrorString(NonRecoverableErrorEvent event) {
|
|||||||
/// This information is complemented either the srcUrl or – if unavailable –
|
/// This information is complemented either the srcUrl or – if unavailable –
|
||||||
/// by the body of the quoted message. For non-media messages, we always use
|
/// by the body of the quoted message. For non-media messages, we always use
|
||||||
/// the body as fallback.
|
/// the body as fallback.
|
||||||
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
|
String createFallbackBodyForQuotedMessage(Message quotedMessage) {
|
||||||
if (quotedMessage == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quotedMessage.isMedia) {
|
if (quotedMessage.isMedia) {
|
||||||
// Create formatted size string, if size is stored
|
// Create formatted size string, if size is stored
|
||||||
String quoteMessageSize;
|
String quoteMessageSize;
|
||||||
|
|||||||
@@ -137,7 +137,10 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
Future<void> _fileUploadFailed(
|
||||||
|
FileUploadJob job,
|
||||||
|
MessageErrorType error,
|
||||||
|
) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
|
||||||
@@ -190,7 +193,7 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
_log.warning('Encrypting ${job.path} failed: $ex');
|
_log.warning('Encrypting ${job.path} failed: $ex');
|
||||||
await _fileUploadFailed(job, messageFailedToEncryptFile);
|
await _fileUploadFailed(job, MessageErrorType.failedToEncryptFile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +212,7 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
if (slotResult.isType<HttpFileUploadError>()) {
|
if (slotResult.isType<HttpFileUploadError>()) {
|
||||||
_log.severe('Failed to request upload slot for ${job.path}!');
|
_log.severe('Failed to request upload slot for ${job.path}!');
|
||||||
await _fileUploadFailed(job, fileUploadFailedError);
|
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||||
@@ -236,7 +239,7 @@ class HttpFileTransferService {
|
|||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
if (!isRequestOkay(uploadStatusCode)) {
|
if (!isRequestOkay(uploadStatusCode)) {
|
||||||
_log.severe('Upload failed due to status code $uploadStatusCode');
|
_log.severe('Upload failed due to status code $uploadStatusCode');
|
||||||
await _fileUploadFailed(job, fileUploadFailedError);
|
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
_log.fine('Upload was successful');
|
_log.fine('Upload was successful');
|
||||||
@@ -324,7 +327,7 @@ class HttpFileTransferService {
|
|||||||
// Notify UI of upload completion
|
// Notify UI of upload completion
|
||||||
var msg = await ms.updateMessage(
|
var msg = await ms.updateMessage(
|
||||||
job.messageMap[recipient]!.id,
|
job.messageMap[recipient]!.id,
|
||||||
errorType: noError,
|
errorType: null,
|
||||||
isUploading: false,
|
isUploading: false,
|
||||||
fileMetadata: metadata,
|
fileMetadata: metadata,
|
||||||
);
|
);
|
||||||
@@ -338,14 +341,13 @@ class HttpFileTransferService {
|
|||||||
sendEvent(MessageUpdatedEvent(message: msg));
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
// Send the message to the recipient
|
// Send the message to the recipient
|
||||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||||
MessageDetails(
|
JID.fromString(recipient),
|
||||||
to: recipient,
|
TypedMap<StanzaHandlerExtension>.fromList([
|
||||||
body: slot.getUrl,
|
MessageBodyData(slot.getUrl),
|
||||||
requestDeliveryReceipt: true,
|
const MessageDeliveryReceiptData(true),
|
||||||
id: msg.sid,
|
StableIdData(msg.originId, null),
|
||||||
originId: msg.originId,
|
StatelessFileSharingData(
|
||||||
sfs: StatelessFileSharingData(
|
|
||||||
FileMetadataData(
|
FileMetadataData(
|
||||||
mediaType: job.mime,
|
mediaType: job.mime,
|
||||||
size: stat.size,
|
size: stat.size,
|
||||||
@@ -353,11 +355,12 @@ class HttpFileTransferService {
|
|||||||
thumbnails: job.thumbnails,
|
thumbnails: job.thumbnails,
|
||||||
hashes: plaintextHashes,
|
hashes: plaintextHashes,
|
||||||
),
|
),
|
||||||
<StatelessFileSharingSource>[source],
|
[source],
|
||||||
),
|
includeOOBFallback: true,
|
||||||
shouldEncrypt: job.encryptMap[recipient]!,
|
|
||||||
funReplacement: oldSid,
|
|
||||||
),
|
),
|
||||||
|
FileUploadNotificationReplacementData(oldSid),
|
||||||
|
MessageIdData(msg.sid),
|
||||||
|
]),
|
||||||
);
|
);
|
||||||
_log.finest(
|
_log.finest(
|
||||||
'Sent message with file upload for ${job.path} to $recipient',
|
'Sent message with file upload for ${job.path} to $recipient',
|
||||||
@@ -390,7 +393,10 @@ class HttpFileTransferService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fileDownloadFailed(FileDownloadJob job, int error) async {
|
Future<void> _fileDownloadFailed(
|
||||||
|
FileDownloadJob job,
|
||||||
|
MessageErrorType error,
|
||||||
|
) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
|
||||||
// Notify UI of download failure
|
// Notify UI of download failure
|
||||||
@@ -451,7 +457,7 @@ class HttpFileTransferService {
|
|||||||
_log.warning(
|
_log.warning(
|
||||||
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
||||||
);
|
);
|
||||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
await _fileDownloadFailed(job, MessageErrorType.fileDownloadFailed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,7 +485,7 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
if (!result.decryptionOkay) {
|
if (!result.decryptionOkay) {
|
||||||
_log.warning('Failed to decrypt $downloadPath');
|
_log.warning('Failed to decrypt $downloadPath');
|
||||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,7 +494,7 @@ class HttpFileTransferService {
|
|||||||
_log.warning(
|
_log.warning(
|
||||||
'Decryption of $downloadPath ($downloadedPath) failed: $ex',
|
'Decryption of $downloadPath ($downloadedPath) failed: $ex',
|
||||||
);
|
);
|
||||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,15 +577,13 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Only add the hash pointers if the file hashes match what was sent
|
// Only add the hash pointers if the file hashes match what was sent
|
||||||
if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
if ((job.location.plaintextHashes?.isNotEmpty ?? false) &&
|
||||||
if (integrityCheckPassed) {
|
integrityCheckPassed &&
|
||||||
|
job.createMetadataHashes) {
|
||||||
await fs.createMetadataHashEntries(
|
await fs.createMetadataHashEntries(
|
||||||
job.location.plaintextHashes!,
|
job.location.plaintextHashes!,
|
||||||
job.metadataId,
|
job.metadataId,
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
_log.warning('Integrity check failed for file');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
@@ -591,7 +595,7 @@ class HttpFileTransferService {
|
|||||||
warningType:
|
warningType:
|
||||||
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
||||||
errorType: conversation.encrypted && !decryptionKeysAvailable
|
errorType: conversation.encrypted && !decryptionKeysAvailable
|
||||||
? messageChatEncryptedButFileNot
|
? MessageErrorType.chatEncryptedButPlaintextFile
|
||||||
: null,
|
: null,
|
||||||
isDownloading: false,
|
isDownloading: false,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -55,15 +55,32 @@ class FileDownloadJob {
|
|||||||
this.location,
|
this.location,
|
||||||
this.mId,
|
this.mId,
|
||||||
this.metadataId,
|
this.metadataId,
|
||||||
|
this.createMetadataHashes,
|
||||||
this.conversationJid,
|
this.conversationJid,
|
||||||
this.mimeGuess, {
|
this.mimeGuess, {
|
||||||
this.shouldShowNotification = true,
|
this.shouldShowNotification = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// The location where the file can be found.
|
||||||
final MediaFileLocation location;
|
final MediaFileLocation location;
|
||||||
|
|
||||||
|
/// The id of the message associated with the download.
|
||||||
final int mId;
|
final int mId;
|
||||||
|
|
||||||
|
/// The id of the file metadata describing the file.
|
||||||
final String metadataId;
|
final String metadataId;
|
||||||
|
|
||||||
|
/// Flag indicating whether we should create hash pointers to the file metadata
|
||||||
|
/// object.
|
||||||
|
final bool createMetadataHashes;
|
||||||
|
|
||||||
|
/// The JID of the conversation this message was received in.
|
||||||
final String conversationJid;
|
final String conversationJid;
|
||||||
|
|
||||||
|
/// A guess to the files's MIME type.
|
||||||
final String? mimeGuess;
|
final String? mimeGuess;
|
||||||
|
|
||||||
|
/// Flag indicating whether a notification should be shown after successful download.
|
||||||
final bool shouldShowNotification;
|
final bool shouldShowNotification;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import 'package:moxxyv2/service/files.dart';
|
|||||||
import 'package:moxxyv2/service/not_specified.dart';
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/reactions.dart';
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/shared/cache.dart';
|
import 'package:moxxyv2/shared/cache.dart';
|
||||||
import 'package:moxxyv2/shared/constants.dart';
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||||
@@ -249,17 +251,19 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $m
|
|||||||
|
|
||||||
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
|
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
|
||||||
/// metadata attached. This method bypasses the cache and does not load the message's
|
/// metadata attached. This method bypasses the cache and does not load the message's
|
||||||
/// quoted message, if it exists.
|
/// quoted message, if it exists. If [jid] is set to null, then the media messages for
|
||||||
|
/// all conversations are queried.
|
||||||
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
|
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
|
||||||
String jid,
|
String? jid,
|
||||||
bool olderThan,
|
bool olderThan,
|
||||||
int? oldestTimestamp,
|
int? oldestTimestamp,
|
||||||
) async {
|
) async {
|
||||||
final db = GetIt.I.get<DatabaseService>().database;
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
final comparator = olderThan ? '<' : '>';
|
final comparator = olderThan ? '<' : '>';
|
||||||
|
final queryPrefix = jid != null ? 'conversationJid = ? AND' : '';
|
||||||
final query = oldestTimestamp != null
|
final query = oldestTimestamp != null
|
||||||
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
? 'file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||||
: 'conversationJid = ? AND file_metadata_id IS NOT NULL';
|
: 'file_metadata_id IS NOT NULL';
|
||||||
final rawMessages = await db.rawQuery(
|
final rawMessages = await db.rawQuery(
|
||||||
'''
|
'''
|
||||||
SELECT
|
SELECT
|
||||||
@@ -279,11 +283,26 @@ SELECT
|
|||||||
fm.cipherTextHashes as fm_cipherTextHashes,
|
fm.cipherTextHashes as fm_cipherTextHashes,
|
||||||
fm.filename as fm_filename,
|
fm.filename as fm_filename,
|
||||||
fm.size as fm_size
|
fm.size as fm_size
|
||||||
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg
|
FROM
|
||||||
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id;
|
(SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
$messagesTable
|
||||||
|
WHERE
|
||||||
|
$queryPrefix $query
|
||||||
|
ORDER BY timestamp
|
||||||
|
DESC LIMIT $sharedMediaPaginationSize
|
||||||
|
) AS msg
|
||||||
|
LEFT JOIN
|
||||||
|
$fileMetadataTable fm
|
||||||
|
ON
|
||||||
|
msg.file_metadata_id = fm.id
|
||||||
|
WHERE
|
||||||
|
fm_path IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT id FROM $stickersTable WHERE file_metadata_id = fm.id);
|
||||||
''',
|
''',
|
||||||
[
|
[
|
||||||
jid,
|
if (jid != null) jid,
|
||||||
if (oldestTimestamp != null) oldestTimestamp,
|
if (oldestTimestamp != null) oldestTimestamp,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -324,12 +343,12 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
|||||||
String? originId,
|
String? originId,
|
||||||
String? quoteId,
|
String? quoteId,
|
||||||
FileMetadata? fileMetadata,
|
FileMetadata? fileMetadata,
|
||||||
int? errorType,
|
MessageErrorType? errorType,
|
||||||
int? warningType,
|
int? warningType,
|
||||||
bool isDownloading = false,
|
bool isDownloading = false,
|
||||||
bool isUploading = false,
|
bool isUploading = false,
|
||||||
String? stickerPackId,
|
String? stickerPackId,
|
||||||
int? pseudoMessageType,
|
PseudoMessageType? pseudoMessageType,
|
||||||
Map<String, dynamic>? pseudoMessageData,
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
bool received = false,
|
bool received = false,
|
||||||
bool displayed = false,
|
bool displayed = false,
|
||||||
@@ -444,7 +463,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
|||||||
m['acked'] = boolToInt(acked);
|
m['acked'] = boolToInt(acked);
|
||||||
}
|
}
|
||||||
if (errorType != notSpecified) {
|
if (errorType != notSpecified) {
|
||||||
m['errorType'] = errorType as int?;
|
m['errorType'] = (errorType as MessageErrorType?)?.value;
|
||||||
}
|
}
|
||||||
if (warningType != notSpecified) {
|
if (warningType != notSpecified) {
|
||||||
m['warningType'] = warningType as int?;
|
m['warningType'] = warningType as int?;
|
||||||
@@ -626,4 +645,43 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks the message with the database id [id] as displayed and sends an
|
||||||
|
/// [MessageUpdatedEvent] to the UI. if [sendChatMarker] is true, then
|
||||||
|
/// a Chat Marker with <displayed /> is sent to the message's
|
||||||
|
/// conversationJid attribute.
|
||||||
|
Future<Message> markMessageAsRead(int id, bool sendChatMarker) async {
|
||||||
|
final newMessage = await updateMessage(
|
||||||
|
id,
|
||||||
|
displayed: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tell the UI
|
||||||
|
sendEvent(MessageUpdatedEvent(message: newMessage));
|
||||||
|
|
||||||
|
if (sendChatMarker) {
|
||||||
|
await GetIt.I.get<XmppService>().sendReadMarker(
|
||||||
|
// TODO(Unknown): This is wrong once groupchats are implemented
|
||||||
|
newMessage.conversationJid,
|
||||||
|
newMessage.originId ?? newMessage.sid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return newMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evicts all cached message pages for [jid], if any were cached, from the
|
||||||
|
/// cache.
|
||||||
|
Future<void> evictFromCache(String jid) async {
|
||||||
|
return _cacheLock.synchronized(() => _messageCache.remove(jid));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [evictFromCache], but for multiple JIDs [jids].
|
||||||
|
Future<void> evictMultipleFromCache(List<String> jids) async {
|
||||||
|
return _cacheLock.synchronized(() {
|
||||||
|
for (final jid in jids) {
|
||||||
|
_messageCache.remove(jid);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
|
||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
|
||||||
|
|
||||||
class MoxxyOmemoManager extends BaseOmemoManager {
|
|
||||||
MoxxyOmemoManager() : super();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<OmemoManager> getOmemoManager() async {
|
|
||||||
final os = GetIt.I.get<OmemoService>();
|
|
||||||
await os.ensureInitialized();
|
|
||||||
return os.omemoManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza) async {
|
|
||||||
// Never encrypt stanzas that contain PubSub elements
|
|
||||||
if (stanza.firstTag('pubsub', xmlns: pubsubXmlns) != null ||
|
|
||||||
stanza.firstTag('pubsub', xmlns: pubsubOwnerXmlns) != null ||
|
|
||||||
stanza.firstTagByXmlns(carbonsXmlns) != null ||
|
|
||||||
stanza.firstTagByXmlns(rosterXmlns) != 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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,32 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
|
||||||
|
/// Update the "showAddToRoster" state of the conversation with jid [jid] to
|
||||||
|
/// [showAddToRoster], if the conversation exists.
|
||||||
|
Future<void> updateConversation(String jid, bool showAddToRoster) async {
|
||||||
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
final newConversation = await cs.createOrUpdateConversation(
|
||||||
|
jid,
|
||||||
|
update: (conversation) async {
|
||||||
|
final c = conversation.copyWith(
|
||||||
|
showAddToRoster: showAddToRoster,
|
||||||
|
);
|
||||||
|
cs.setConversation(c);
|
||||||
|
return c;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (newConversation != null) {
|
||||||
|
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||||
@override
|
@override
|
||||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||||
@@ -45,6 +65,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
|||||||
// Remove stale items
|
// Remove stale items
|
||||||
for (final jid in removed) {
|
for (final jid in removed) {
|
||||||
await rs.removeRosterItemByJid(jid);
|
await rs.removeRosterItemByJid(jid);
|
||||||
|
await updateConversation(jid, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new roster items
|
// Create new roster items
|
||||||
@@ -54,8 +75,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
|||||||
// Skip adding items twice
|
// Skip adding items twice
|
||||||
if (exists) continue;
|
if (exists) continue;
|
||||||
|
|
||||||
rosterAdded.add(
|
final newRosterItem = await rs.addRosterItemFromData(
|
||||||
await rs.addRosterItemFromData(
|
|
||||||
'',
|
'',
|
||||||
'',
|
'',
|
||||||
item.jid,
|
item.jid,
|
||||||
@@ -67,8 +87,11 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
groups: item.groups,
|
groups: item.groups,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
rosterAdded.add(newRosterItem);
|
||||||
|
|
||||||
|
// Update the cached conversation item
|
||||||
|
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update modified items
|
// Update modified items
|
||||||
@@ -80,15 +103,17 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
rosterModified.add(
|
final newRosterItem = await rs.updateRosterItem(
|
||||||
await rs.updateRosterItem(
|
|
||||||
ritem.id,
|
ritem.id,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
subscription: item.subscription,
|
subscription: item.subscription,
|
||||||
ask: item.ask,
|
ask: item.ask,
|
||||||
groups: item.groups,
|
groups: item.groups,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
rosterModified.add(newRosterItem);
|
||||||
|
|
||||||
|
// Update the cached conversation item
|
||||||
|
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the UI
|
// Tell the UI
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import 'package:get_it/get_it.dart';
|
|||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/service/contacts.dart';
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/events.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
|
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
|
||||||
@@ -36,18 +35,15 @@ class NotificationsService {
|
|||||||
MessageNotificationTappedEvent(
|
MessageNotificationTappedEvent(
|
||||||
conversationJid: action.payload!['conversationJid']!,
|
conversationJid: action.payload!['conversationJid']!,
|
||||||
title: action.payload!['title']!,
|
title: action.payload!['title']!,
|
||||||
avatarUrl: action.payload!['avatarUrl']!,
|
avatarPath: action.payload!['avatarPath']!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
|
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
|
||||||
// TODO(Unknown): Maybe refactor this call such that we don't have to use
|
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||||
// a command.
|
int.parse(action.payload!['id']!),
|
||||||
await performMarkMessageAsRead(
|
// [XmppService.sendReadMarker] will check whether the *SHOULD* send
|
||||||
MarkMessageAsReadCommand(
|
// the marker, i.e. if the privacy settings allow it.
|
||||||
conversationJid: action.payload!['conversationJid']!,
|
true,
|
||||||
sid: action.payload!['sid']!,
|
|
||||||
newUnreadCounter: 0,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -110,8 +106,8 @@ class NotificationsService {
|
|||||||
final title =
|
final title =
|
||||||
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
|
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
|
||||||
final avatarPath = contactIntegrationEnabled
|
final avatarPath = contactIntegrationEnabled
|
||||||
? c.contactAvatarPath ?? c.avatarUrl
|
? c.contactAvatarPath ?? c.avatarPath
|
||||||
: c.avatarUrl;
|
: c.avatarPath;
|
||||||
|
|
||||||
await AwesomeNotifications().createNotification(
|
await AwesomeNotifications().createNotification(
|
||||||
content: NotificationContent(
|
content: NotificationContent(
|
||||||
@@ -131,7 +127,8 @@ class NotificationsService {
|
|||||||
'conversationJid': c.jid,
|
'conversationJid': c.jid,
|
||||||
'sid': m.sid,
|
'sid': m.sid,
|
||||||
'title': title,
|
'title': title,
|
||||||
'avatarUrl': avatarPath,
|
'avatarPath': avatarPath,
|
||||||
|
'messageId': m.id.toString(),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
|
|||||||
@@ -1,213 +1,43 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
import 'package:moxxyv2/service/database/constants.dart';
|
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
|
||||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||||
import 'package:moxxyv2/service/omemo/types.dart';
|
import 'package:moxxyv2/service/omemo/persistence.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class OmemoDoubleRatchetWrapper {
|
|
||||||
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
|
||||||
final OmemoDoubleRatchet ratchet;
|
|
||||||
final int id;
|
|
||||||
final String jid;
|
|
||||||
}
|
|
||||||
|
|
||||||
class OmemoService {
|
class OmemoService {
|
||||||
|
/// Logger.
|
||||||
final Logger _log = Logger('OmemoService');
|
final Logger _log = Logger('OmemoService');
|
||||||
|
|
||||||
|
/// Flag indicating whether we are initialized.
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
|
||||||
|
/// Flag indicating whether the initialization is currently running.
|
||||||
|
bool _running = false;
|
||||||
|
|
||||||
|
/// Lock guarding access to [_waitingForInitialization], [_running], and [_initialized].
|
||||||
final Lock _lock = Lock();
|
final Lock _lock = Lock();
|
||||||
|
|
||||||
|
/// Queue for code that is waiting on the service initialization.
|
||||||
final Queue<Completer<void>> _waitingForInitialization =
|
final Queue<Completer<void>> _waitingForInitialization =
|
||||||
Queue<Completer<void>>();
|
Queue<Completer<void>>();
|
||||||
final Map<String, Map<int, String>> _fingerprintCache = {};
|
|
||||||
|
|
||||||
late OmemoManager omemoManager;
|
/// The manager to use for OMEMO.
|
||||||
|
late OmemoManager _omemoManager;
|
||||||
|
|
||||||
Future<void> initializeIfNeeded(String jid) async {
|
/// Access the underlying [OmemoManager].
|
||||||
final done = await _lock.synchronized(() => _initialized);
|
Future<OmemoManager> getOmemoManager() async {
|
||||||
if (done) return;
|
await ensureInitialized();
|
||||||
|
return _omemoManager;
|
||||||
final device = await _loadOmemoDevice(jid);
|
|
||||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
|
||||||
final deviceList = <String, List<int>>{};
|
|
||||||
if (device == null) {
|
|
||||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
|
||||||
} else {
|
|
||||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
|
||||||
for (final ratchet in await _loadRatchets()) {
|
|
||||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
|
||||||
ratchetMap[key] = ratchet.ratchet;
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceList.addAll(await _loadOmemoDeviceList());
|
|
||||||
}
|
|
||||||
|
|
||||||
final om = GetIt.I
|
|
||||||
.get<moxxmpp.XmppConnection>()
|
|
||||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
|
||||||
omemoManager = OmemoManager(
|
|
||||||
device ?? await compute(generateNewIdentityImpl, jid),
|
|
||||||
await loadTrustManager(),
|
|
||||||
om.sendEmptyMessageImpl,
|
|
||||||
om.fetchDeviceList,
|
|
||||||
om.fetchDeviceBundle,
|
|
||||||
om.subscribeToDeviceListImpl,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (device == null) {
|
|
||||||
await commitDevice(await omemoManager.getDevice());
|
|
||||||
await commitDeviceMap(<String, List<int>>{});
|
|
||||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
|
||||||
}
|
|
||||||
|
|
||||||
omemoManager.initialize(
|
|
||||||
ratchetMap,
|
|
||||||
deviceList,
|
|
||||||
);
|
|
||||||
|
|
||||||
omemoManager.eventStream.listen((event) async {
|
|
||||||
if (event is RatchetModifiedEvent) {
|
|
||||||
await _saveRatchet(
|
|
||||||
OmemoDoubleRatchetWrapper(
|
|
||||||
event.ratchet,
|
|
||||||
event.deviceId,
|
|
||||||
event.jid,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (event.added) {
|
|
||||||
// Cache the fingerprint
|
|
||||||
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
|
||||||
await _addFingerprintsToCache([
|
|
||||||
OmemoCacheTriple(
|
|
||||||
event.jid,
|
|
||||||
event.deviceId,
|
|
||||||
fingerprint,
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (_fingerprintCache.containsKey(event.jid)) {
|
|
||||||
_fingerprintCache[event.jid]![event.deviceId] = fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
await addNewDeviceMessage(event.jid, event.deviceId);
|
|
||||||
}
|
|
||||||
} else if (event is DeviceListModifiedEvent) {
|
|
||||||
await commitDeviceMap(event.list);
|
|
||||||
} else if (event is DeviceModifiedEvent) {
|
|
||||||
await commitDevice(event.device);
|
|
||||||
|
|
||||||
// Publish it
|
|
||||||
await GetIt.I
|
|
||||||
.get<moxxmpp.XmppConnection>()
|
|
||||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
|
||||||
.publishBundle(await event.device.toBundle());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await _lock.synchronized(() {
|
|
||||||
_initialized = true;
|
|
||||||
|
|
||||||
for (final c in _waitingForInitialization) {
|
|
||||||
c.complete();
|
|
||||||
}
|
|
||||||
_waitingForInitialization.clear();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Adds a pseudo message saying that [jid] added a new device with id [deviceId].
|
|
||||||
/// If, however, [jid] is our own JID, then nothing is done.
|
|
||||||
Future<void> addNewDeviceMessage(String jid, int deviceId) async {
|
|
||||||
// Add a pseudo message if it is not about our own devices
|
|
||||||
final xmppState = await GetIt.I.get<XmppStateService>().getXmppState();
|
|
||||||
if (jid == xmppState.jid) return;
|
|
||||||
|
|
||||||
final ms = GetIt.I.get<MessageService>();
|
|
||||||
final message = await ms.addMessageFromData(
|
|
||||||
'',
|
|
||||||
DateTime.now().millisecondsSinceEpoch,
|
|
||||||
'',
|
|
||||||
jid,
|
|
||||||
'',
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
false,
|
|
||||||
pseudoMessageType: pseudoMessageTypeNewDevice,
|
|
||||||
pseudoMessageData: <String, dynamic>{
|
|
||||||
'deviceId': deviceId,
|
|
||||||
'jid': jid,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
sendEvent(
|
|
||||||
MessageAddedEvent(
|
|
||||||
message: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<model.OmemoDevice> regenerateDevice(String jid) async {
|
|
||||||
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
|
|
||||||
await _lock.synchronized(() {
|
|
||||||
_initialized = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
|
||||||
final oldId = await omemoManager.getDeviceId();
|
|
||||||
|
|
||||||
// Clear the database
|
|
||||||
await _emptyOmemoSessionTables();
|
|
||||||
|
|
||||||
// Regenerate the identity in the background
|
|
||||||
final device = await compute(generateNewIdentityImpl, jid);
|
|
||||||
await omemoManager.replaceDevice(device);
|
|
||||||
await commitDevice(device);
|
|
||||||
await commitDeviceMap(<String, List<int>>{});
|
|
||||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
|
||||||
|
|
||||||
// Remove the old device
|
|
||||||
final omemo = GetIt.I
|
|
||||||
.get<moxxmpp.XmppConnection>()
|
|
||||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
|
||||||
await omemo.deleteDevice(oldId);
|
|
||||||
|
|
||||||
// Publish the new one
|
|
||||||
await omemo.publishBundle(await omemoManager.getDeviceBundle());
|
|
||||||
|
|
||||||
// Allow access again
|
|
||||||
await _lock.synchronized(() {
|
|
||||||
_initialized = true;
|
|
||||||
|
|
||||||
for (final c in _waitingForInitialization) {
|
|
||||||
c.complete();
|
|
||||||
}
|
|
||||||
_waitingForInitialization.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Return the OmemoDevice
|
|
||||||
return model.OmemoDevice(
|
|
||||||
await getDeviceFingerprint(),
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
await getDeviceId(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ensures that the code following this *AWAITED* call can access every method
|
/// Ensures that the code following this *AWAITED* call can access every method
|
||||||
@@ -228,27 +58,79 @@ class OmemoService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
|
/// Creates or loads the [OmemoManager] for the JID [jid].
|
||||||
await _saveOmemoDeviceList(deviceMap);
|
Future<void> initializeIfNeeded(String jid) async {
|
||||||
|
final done = await _lock.synchronized(() {
|
||||||
|
// Do nothing if we're already initialized
|
||||||
|
if (_initialized) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitDevice(OmemoDevice device) async {
|
// Lock the execution if we're not yet running.
|
||||||
await _saveOmemoDevice(device);
|
if (_running) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
_running = true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
if (done) return;
|
||||||
|
|
||||||
|
final device = await loadOmemoDevice(jid);
|
||||||
|
if (device == null) {
|
||||||
|
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||||
|
} else {
|
||||||
|
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests our device list and checks if the current device is in it. If not, then
|
final om = GetIt.I
|
||||||
/// it will be published.
|
.get<moxxmpp.XmppConnection>()
|
||||||
Future<Object?> publishDeviceIfNeeded() async {
|
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
|
||||||
|
|
||||||
|
_omemoManager = OmemoManager(
|
||||||
|
device ?? await compute(generateNewIdentityImpl, jid),
|
||||||
|
BlindTrustBeforeVerificationTrustManager(
|
||||||
|
commit: commitTrust,
|
||||||
|
loadData: loadTrust,
|
||||||
|
removeTrust: removeTrust,
|
||||||
|
),
|
||||||
|
om.sendEmptyMessageImpl,
|
||||||
|
om.fetchDeviceList,
|
||||||
|
om.fetchDeviceBundle,
|
||||||
|
om.subscribeToDeviceListImpl,
|
||||||
|
om.publishDeviceImpl,
|
||||||
|
commitDevice: commitDevice,
|
||||||
|
commitRatchets: commitRatchets,
|
||||||
|
commitDeviceList: commitDeviceList,
|
||||||
|
removeRatchets: removeRatchets,
|
||||||
|
loadRatchets: loadRatchets,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (device == null) {
|
||||||
|
await commitDevice(await _omemoManager.getDevice());
|
||||||
|
}
|
||||||
|
|
||||||
|
await _lock.synchronized(() {
|
||||||
|
_running = false;
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
for (final c in _waitingForInitialization) {
|
||||||
|
c.complete();
|
||||||
|
}
|
||||||
|
_waitingForInitialization.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<moxxmpp.OmemoError?> publishDeviceIfNeeded() async {
|
||||||
_log.finest('publishDeviceIfNeeded: Waiting for initialization...');
|
_log.finest('publishDeviceIfNeeded: Waiting for initialization...');
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
_log.finest('publishDeviceIfNeeded: Done');
|
_log.finest('publishDeviceIfNeeded: Done');
|
||||||
|
|
||||||
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||||
final omemo =
|
final omemo =
|
||||||
conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
conn.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
|
||||||
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||||
final bareJid = conn.connectionSettings.jid.toBare();
|
final bareJid = conn.connectionSettings.jid.toBare();
|
||||||
final device = await omemoManager.getDevice();
|
final device = await _omemoManager.getDevice();
|
||||||
|
|
||||||
final bundlesRaw = await dm.discoItemsQuery(
|
final bundlesRaw = await dm.discoItemsQuery(
|
||||||
bareJid,
|
bareJid,
|
||||||
@@ -256,7 +138,7 @@ class OmemoService {
|
|||||||
);
|
);
|
||||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||||
await omemo.publishBundle(await device.toBundle());
|
await omemo.publishBundle(await device.toBundle());
|
||||||
return bundlesRaw.get<moxxmpp.DiscoError>();
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final bundleIds = bundlesRaw
|
final bundleIds = bundlesRaw
|
||||||
@@ -285,469 +167,114 @@ class OmemoService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
|
Future<void> onNewConnection() async {
|
||||||
final bareJid = jid.toBare().toString();
|
await ensureInitialized();
|
||||||
final allDevicesRaw = await GetIt.I
|
await _omemoManager.onNewConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<model.OmemoDevice>> getFingerprintsForJid(String jid) async {
|
||||||
|
await ensureInitialized();
|
||||||
|
final fingerprints = await _omemoManager.getFingerprintsForJid(jid) ?? [];
|
||||||
|
var trust = <int, BTBVTrustData>{};
|
||||||
|
|
||||||
|
await _omemoManager.withTrustManager(
|
||||||
|
jid,
|
||||||
|
(tm) async {
|
||||||
|
trust = await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||||
|
.getDevicesTrust(jid);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return fingerprints.map((fp) {
|
||||||
|
return model.OmemoDevice(
|
||||||
|
fp.fingerprint,
|
||||||
|
trust[fp.deviceId]?.trusted ?? false,
|
||||||
|
trust[fp.deviceId]?.state == BTBVTrustState.verified,
|
||||||
|
trust[fp.deviceId]?.enabled ?? false,
|
||||||
|
fp.deviceId,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setDeviceEnablement(String jid, int device, bool state) async {
|
||||||
|
await ensureInitialized();
|
||||||
|
await _omemoManager.withTrustManager(jid, (tm) async {
|
||||||
|
await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||||
|
.setEnabled(jid, device, state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setDeviceVerified(String jid, int device) async {
|
||||||
|
await ensureInitialized();
|
||||||
|
await _omemoManager.withTrustManager(jid, (tm) async {
|
||||||
|
await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||||
|
.setDeviceTrust(jid, device, BTBVTrustState.verified);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeAllRatchets(String jid) async {
|
||||||
|
await ensureInitialized();
|
||||||
|
await _omemoManager.removeAllRatchets(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<OmemoDevice> getDevice() async {
|
||||||
|
await ensureInitialized();
|
||||||
|
return _omemoManager.getDevice();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<model.OmemoDevice> regenerateDevice() async {
|
||||||
|
await ensureInitialized();
|
||||||
|
|
||||||
|
final oldDeviceId = (await getDevice()).id;
|
||||||
|
|
||||||
|
// Generate the new device
|
||||||
|
final newDevice = await _omemoManager.regenerateDevice();
|
||||||
|
|
||||||
|
// Remove the old device
|
||||||
|
unawaited(
|
||||||
|
GetIt.I
|
||||||
.get<moxxmpp.XmppConnection>()
|
.get<moxxmpp.XmppConnection>()
|
||||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!
|
||||||
.retrieveDeviceBundles(jid);
|
.deleteDevice(oldDeviceId),
|
||||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
|
||||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
|
||||||
final map = <int, String>{};
|
|
||||||
final items = List<OmemoCacheTriple>.empty(growable: true);
|
|
||||||
for (final device in allDevices) {
|
|
||||||
final curveIk = await device.ik.toCurve25519();
|
|
||||||
final fingerprint = HEX.encode(await curveIk.getBytes());
|
|
||||||
map[device.id] = fingerprint;
|
|
||||||
items.add(OmemoCacheTriple(bareJid, device.id, fingerprint));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache them in memory
|
|
||||||
_fingerprintCache[bareJid] = map;
|
|
||||||
|
|
||||||
// Cache them in the database
|
|
||||||
await _addFingerprintsToCache(items);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadOrFetchFingerprints(moxxmpp.JID jid) async {
|
|
||||||
final bareJid = jid.toBare().toString();
|
|
||||||
if (!_fingerprintCache.containsKey(bareJid)) {
|
|
||||||
// First try to load it from the database
|
|
||||||
final triples = await _getFingerprintsFromCache(bareJid);
|
|
||||||
if (triples.isEmpty) {
|
|
||||||
// We found no fingerprints in the database, so try to fetch them
|
|
||||||
await _fetchFingerprintsAndCache(jid);
|
|
||||||
} else {
|
|
||||||
// We have fetched fingerprints from the database
|
|
||||||
_fingerprintCache[bareJid] = Map<int, String>.fromEntries(
|
|
||||||
triples.map((triple) {
|
|
||||||
return MapEntry<int, String>(
|
|
||||||
triple.deviceId,
|
|
||||||
triple.fingerprint,
|
|
||||||
);
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<model.OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
return model.OmemoDevice(
|
||||||
await ensureInitialized();
|
await newDevice.getFingerprint(),
|
||||||
|
true,
|
||||||
// Get finger prints if we have to
|
true,
|
||||||
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
|
true,
|
||||||
|
newDevice.id,
|
||||||
final keys = List<model.OmemoDevice>.empty(growable: true);
|
|
||||||
final tm =
|
|
||||||
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
|
||||||
final trustMap = await tm.getDevicesTrust(jid);
|
|
||||||
|
|
||||||
if (!_fingerprintCache.containsKey(jid)) return [];
|
|
||||||
for (final deviceId in _fingerprintCache[jid]!.keys) {
|
|
||||||
keys.add(
|
|
||||||
model.OmemoDevice(
|
|
||||||
_fingerprintCache[jid]![deviceId]!,
|
|
||||||
await tm.isTrusted(jid, deviceId),
|
|
||||||
trustMap[deviceId] == BTBVTrustState.verified,
|
|
||||||
await tm.isEnabled(jid, deviceId),
|
|
||||||
deviceId,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return keys;
|
/// Adds a pseudo-message of type [type] to the chat with [conversationJid].
|
||||||
}
|
/// Also sends an event to the UI.
|
||||||
|
Future<void> addPseudoMessage(
|
||||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
String conversationJid,
|
||||||
await _saveTrustCache(
|
PseudoMessageType type,
|
||||||
json['trust']! as Map<String, int>,
|
int ratchetsAdded,
|
||||||
);
|
int ratchetsReplaced,
|
||||||
await _saveTrustEnablementList(
|
|
||||||
json['enable']! as Map<String, bool>,
|
|
||||||
);
|
|
||||||
await _saveTrustDeviceList(
|
|
||||||
json['devices']! as Map<String, List<int>>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
|
||||||
return MoxxyBTBVTrustManager(
|
|
||||||
await _loadTrustCache(),
|
|
||||||
await _loadTrustEnablementList(),
|
|
||||||
await _loadTrustDeviceList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setOmemoKeyEnabled(
|
|
||||||
String jid,
|
|
||||||
int deviceId,
|
|
||||||
bool enabled,
|
|
||||||
) async {
|
) async {
|
||||||
await ensureInitialized();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
final message = await ms.addMessageFromData(
|
||||||
}
|
'',
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
Future<void> removeAllSessions(String jid) async {
|
'',
|
||||||
await ensureInitialized();
|
conversationJid,
|
||||||
await omemoManager.removeAllRatchets(jid);
|
'',
|
||||||
}
|
false,
|
||||||
|
false,
|
||||||
Future<int> getDeviceId() async {
|
false,
|
||||||
await ensureInitialized();
|
pseudoMessageType: type,
|
||||||
return omemoManager.getDeviceId();
|
pseudoMessageData: {
|
||||||
}
|
'ratchetsAdded': ratchetsAdded,
|
||||||
|
'ratchetsReplaced': ratchetsReplaced,
|
||||||
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
|
},
|
||||||
|
|
||||||
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
|
||||||
/// published on [ownJid]'s devices PubSub node.
|
|
||||||
/// Note that the list is made so that the current device is excluded.
|
|
||||||
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
|
|
||||||
final ownId = await getDeviceId();
|
|
||||||
final keys = List<model.OmemoDevice>.from(
|
|
||||||
await getOmemoKeysForJid(ownJid.toString()),
|
|
||||||
);
|
);
|
||||||
final bareJid = ownJid.toBare().toString();
|
sendEvent(
|
||||||
|
MessageAddedEvent(
|
||||||
// Get fingerprints if we have to
|
message: message,
|
||||||
await _loadOrFetchFingerprints(ownJid);
|
|
||||||
|
|
||||||
final tm =
|
|
||||||
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
|
||||||
final trustMap = await tm.getDevicesTrust(bareJid);
|
|
||||||
|
|
||||||
for (final deviceId in _fingerprintCache[bareJid]!.keys) {
|
|
||||||
if (deviceId == ownId) continue;
|
|
||||||
if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
|
|
||||||
|
|
||||||
final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
|
|
||||||
keys.add(
|
|
||||||
model.OmemoDevice(
|
|
||||||
fingerprint,
|
|
||||||
await tm.isTrusted(bareJid, deviceId),
|
|
||||||
trustMap[deviceId] == BTBVTrustState.verified,
|
|
||||||
await tm.isEnabled(bareJid, deviceId),
|
|
||||||
deviceId,
|
|
||||||
hasSessionWith: false,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> verifyDevice(int deviceId, String jid) async {
|
|
||||||
final tm =
|
|
||||||
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
|
||||||
await tm.setDeviceTrust(
|
|
||||||
jid,
|
|
||||||
deviceId,
|
|
||||||
BTBVTrustState.verified,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
|
|
||||||
void onNewConnection() {
|
|
||||||
if (_initialized) {
|
|
||||||
omemoManager.onNewConnection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Database methods
|
|
||||||
|
|
||||||
Future<List<OmemoDoubleRatchetWrapper>> _loadRatchets() async {
|
|
||||||
final results =
|
|
||||||
await GetIt.I.get<DatabaseService>().database.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<void> _saveRatchet(OmemoDoubleRatchetWrapper ratchet) async {
|
|
||||||
final json = await ratchet.ratchet.toJson();
|
|
||||||
await GetIt.I.get<DatabaseService>().database.insert(
|
|
||||||
omemoRatchetsTable,
|
|
||||||
{
|
|
||||||
...json,
|
|
||||||
'mkskipped': jsonEncode(json['mkskipped']),
|
|
||||||
'acknowledged': boolToInt(json['acknowledged']! as bool),
|
|
||||||
'jid': ratchet.jid,
|
|
||||||
'id': ratchet.id,
|
|
||||||
},
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<RatchetMapKey, BTBVTrustState>> _loadTrustCache() async {
|
|
||||||
final entries = await GetIt.I
|
|
||||||
.get<DatabaseService>()
|
|
||||||
.database
|
|
||||||
.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 = GetIt.I.get<DatabaseService>().database.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 GetIt.I
|
|
||||||
.get<DatabaseService>()
|
|
||||||
.database
|
|
||||||
.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 = GetIt.I.get<DatabaseService>().database.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 GetIt.I
|
|
||||||
.get<DatabaseService>()
|
|
||||||
.database
|
|
||||||
.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 = GetIt.I.get<DatabaseService>().database.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(OmemoDevice device) async {
|
|
||||||
await GetIt.I.get<DatabaseService>().database.insert(
|
|
||||||
omemoDeviceTable,
|
|
||||||
{
|
|
||||||
'jid': device.jid,
|
|
||||||
'id': device.id,
|
|
||||||
'data': jsonEncode(await device.toJson()),
|
|
||||||
},
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<OmemoDevice?> _loadOmemoDevice(String jid) async {
|
|
||||||
final data = await GetIt.I.get<DatabaseService>().database.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 tmpOpk in opksIter) {
|
|
||||||
final opk = tmpOpk 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 OmemoDevice.fromJson(deviceJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, List<int>>> _loadOmemoDeviceList() async {
|
|
||||||
final list = await GetIt.I
|
|
||||||
.get<DatabaseService>()
|
|
||||||
.database
|
|
||||||
.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 = GetIt.I.get<DatabaseService>().database.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 = GetIt.I.get<DatabaseService>().database.batch();
|
|
||||||
|
|
||||||
// ignore: cascade_invocations
|
|
||||||
batch
|
|
||||||
..delete(omemoRatchetsTable)
|
|
||||||
..delete(omemoTrustCacheTable)
|
|
||||||
..delete(omemoTrustEnableListTable);
|
|
||||||
|
|
||||||
await batch.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _addFingerprintsToCache(List<OmemoCacheTriple> items) async {
|
|
||||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
|
||||||
for (final item in items) {
|
|
||||||
batch.insert(
|
|
||||||
omemoFingerprintCache,
|
|
||||||
<String, dynamic>{
|
|
||||||
'jid': item.jid,
|
|
||||||
'id': item.deviceId,
|
|
||||||
'fingerprint': item.fingerprint,
|
|
||||||
},
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await batch.commit();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<OmemoCacheTriple>> _getFingerprintsFromCache(String jid) async {
|
|
||||||
final rawItems = await GetIt.I.get<DatabaseService>().database.query(
|
|
||||||
omemoFingerprintCache,
|
|
||||||
where: 'jid = ?',
|
|
||||||
whereArgs: [jid],
|
|
||||||
);
|
|
||||||
|
|
||||||
return rawItems.map((item) {
|
|
||||||
return OmemoCacheTriple(
|
|
||||||
jid,
|
|
||||||
item['id']! as int,
|
|
||||||
item['fingerprint']! as String,
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
308
lib/service/omemo/persistence.dart
Normal file
308
lib/service/omemo/persistence.dart
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:cryptography/cryptography.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
import 'package:sqflite_common/sql.dart';
|
||||||
|
|
||||||
|
extension ByteListHelpers on List<int> {
|
||||||
|
String toBase64() {
|
||||||
|
return base64Encode(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
OmemoPublicKey toPublicKey(KeyPairType type) {
|
||||||
|
return OmemoPublicKey.fromBytes(this, type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> commitDevice(OmemoDevice device) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final serializedOpks = <String, Map<String, String>>{};
|
||||||
|
for (final entry in device.opks.entries) {
|
||||||
|
serializedOpks[entry.key.toString()] = {
|
||||||
|
'public': base64Encode(await entry.value.pk.getBytes()),
|
||||||
|
'private': base64Encode(await entry.value.sk.getBytes()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(
|
||||||
|
omemoDevicesTable,
|
||||||
|
{
|
||||||
|
'jid': device.jid,
|
||||||
|
'id': device.id,
|
||||||
|
'ikPub': base64Encode(await device.ik.pk.getBytes()),
|
||||||
|
'ik': base64Encode(await device.ik.sk.getBytes()),
|
||||||
|
'spkPub': base64Encode(await device.spk.pk.getBytes()),
|
||||||
|
'spk': base64Encode(await device.spk.sk.getBytes()),
|
||||||
|
'spkId': device.spkId,
|
||||||
|
'spkSig': base64Encode(device.spkSignature),
|
||||||
|
'oldSpkPub': (await device.oldSpk?.pk.getBytes())?.toBase64(),
|
||||||
|
'oldSpk': (await device.oldSpk?.sk.getBytes())?.toBase64(),
|
||||||
|
'oldSpkId': device.oldSpkId,
|
||||||
|
'opks': jsonEncode(serializedOpks),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<OmemoDevice?> loadOmemoDevice(String jid) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final rawDevice = await db.query(
|
||||||
|
omemoDevicesTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (rawDevice.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final deviceJson = rawDevice.first;
|
||||||
|
|
||||||
|
// Deserialize the OPKs first
|
||||||
|
final deserializedOpks = <int, OmemoKeyPair>{};
|
||||||
|
final opks =
|
||||||
|
(jsonDecode(rawDevice.first['opks']! as String) as Map<dynamic, dynamic>)
|
||||||
|
.cast<String, dynamic>();
|
||||||
|
for (final opk in opks.entries) {
|
||||||
|
final opkValue = (opk.value as Map<String, dynamic>).cast<String, String>();
|
||||||
|
deserializedOpks[int.parse(opk.key)] = OmemoKeyPair.fromBytes(
|
||||||
|
base64Decode(opkValue['public']!),
|
||||||
|
base64Decode(opkValue['private']!),
|
||||||
|
KeyPairType.x25519,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
OmemoKeyPair? oldSpk;
|
||||||
|
if (deviceJson['oldSpkPub'] != null && deviceJson['oldSpk'] != null) {
|
||||||
|
oldSpk = OmemoKeyPair.fromBytes(
|
||||||
|
base64Decode(deviceJson['oldSpkPub']! as String),
|
||||||
|
base64Decode(deviceJson['oldSpk']! as String),
|
||||||
|
KeyPairType.x25519,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OmemoDevice(
|
||||||
|
jid,
|
||||||
|
deviceJson['id']! as int,
|
||||||
|
OmemoKeyPair.fromBytes(
|
||||||
|
base64Decode(deviceJson['ikPub']! as String),
|
||||||
|
base64Decode(deviceJson['ik']! as String),
|
||||||
|
KeyPairType.ed25519,
|
||||||
|
),
|
||||||
|
OmemoKeyPair.fromBytes(
|
||||||
|
base64Decode(deviceJson['spkPub']! as String),
|
||||||
|
base64Decode(deviceJson['spk']! as String),
|
||||||
|
KeyPairType.x25519,
|
||||||
|
),
|
||||||
|
deviceJson['spkId']! as int,
|
||||||
|
base64Decode(deviceJson['spkSig']! as String),
|
||||||
|
oldSpk,
|
||||||
|
deviceJson['oldSpkId'] as int?,
|
||||||
|
deserializedOpks,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> commitRatchets(List<OmemoRatchetData> ratchets) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final batch = db.batch();
|
||||||
|
for (final ratchet in ratchets) {
|
||||||
|
// Serialize the skipped keys
|
||||||
|
final serializedSkippedKeys = <Map<String, Object>>[];
|
||||||
|
for (final sk in ratchet.ratchet.mkSkipped.entries) {
|
||||||
|
serializedSkippedKeys.add({
|
||||||
|
'dhPub': (await sk.key.dh.getBytes()).toBase64(),
|
||||||
|
'n': sk.key.n,
|
||||||
|
'mk': sk.value.toBase64(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize the KEX
|
||||||
|
final kex = {
|
||||||
|
'pkId': ratchet.ratchet.kex.pkId,
|
||||||
|
'spkId': ratchet.ratchet.kex.spkId,
|
||||||
|
'ek': (await ratchet.ratchet.kex.ek.getBytes()).toBase64(),
|
||||||
|
'ik': (await ratchet.ratchet.kex.ik.getBytes()).toBase64(),
|
||||||
|
};
|
||||||
|
|
||||||
|
batch.insert(
|
||||||
|
omemoRatchetsTable,
|
||||||
|
{
|
||||||
|
'jid': ratchet.jid,
|
||||||
|
'device': ratchet.id,
|
||||||
|
'dhsPub': base64Encode(await ratchet.ratchet.dhs.pk.getBytes()),
|
||||||
|
'dhs': base64Encode(await ratchet.ratchet.dhs.sk.getBytes()),
|
||||||
|
'dhrPub': (await ratchet.ratchet.dhr?.getBytes())?.toBase64(),
|
||||||
|
'rk': base64Encode(ratchet.ratchet.rk),
|
||||||
|
'cks': ratchet.ratchet.cks?.toBase64(),
|
||||||
|
'ckr': ratchet.ratchet.ckr?.toBase64(),
|
||||||
|
'ns': ratchet.ratchet.ns,
|
||||||
|
'nr': ratchet.ratchet.nr,
|
||||||
|
'pn': ratchet.ratchet.pn,
|
||||||
|
'ik': (await ratchet.ratchet.ik.getBytes()).toBase64(),
|
||||||
|
'ad': ratchet.ratchet.sessionAd.toBase64(),
|
||||||
|
'skipped': jsonEncode(serializedSkippedKeys),
|
||||||
|
'kex': jsonEncode(kex),
|
||||||
|
'acked': boolToInt(ratchet.ratchet.acknowledged),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> commitDeviceList(String jid, List<int> devices) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
await db.insert(
|
||||||
|
omemoDeviceListTable,
|
||||||
|
{
|
||||||
|
'jid': jid,
|
||||||
|
'devices': jsonEncode(devices),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeRatchets(List<RatchetMapKey> ratchets) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final batch = db.batch();
|
||||||
|
|
||||||
|
for (final key in ratchets) {
|
||||||
|
batch.delete(
|
||||||
|
omemoRatchetsTable,
|
||||||
|
where: 'jid = ? AND device = ?',
|
||||||
|
whereArgs: [key.jid, key.deviceId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<OmemoDataPackage?> loadRatchets(String jid) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final ratchetsRaw = await db.query(
|
||||||
|
omemoRatchetsTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
final deviceListRaw = await db.query(
|
||||||
|
omemoDeviceListTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (ratchetsRaw.isEmpty || deviceListRaw.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize the ratchets
|
||||||
|
final ratchets = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||||
|
for (final ratchetRaw in ratchetsRaw) {
|
||||||
|
final key = RatchetMapKey(
|
||||||
|
jid,
|
||||||
|
ratchetRaw['device']! as int,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deserialize skipped keys
|
||||||
|
final mkSkipped = <SkippedKey, List<int>>{};
|
||||||
|
final skippedKeysRaw =
|
||||||
|
(jsonDecode(ratchetRaw['skipped']! as String) as List<dynamic>)
|
||||||
|
.cast<Map<dynamic, dynamic>>();
|
||||||
|
for (final skippedRaw in skippedKeysRaw) {
|
||||||
|
final key = SkippedKey(
|
||||||
|
(skippedRaw['dhPub']! as String)
|
||||||
|
.fromBase64()
|
||||||
|
.toPublicKey(KeyPairType.x25519),
|
||||||
|
skippedRaw['n']! as int,
|
||||||
|
);
|
||||||
|
mkSkipped[key] = (skippedRaw['mk']! as String).fromBase64();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deserialize the KEX
|
||||||
|
final kexRaw =
|
||||||
|
(jsonDecode(ratchetRaw['kex']! as String) as Map<dynamic, dynamic>)
|
||||||
|
.cast<String, Object>();
|
||||||
|
final kex = KeyExchangeData(
|
||||||
|
kexRaw['pkId']! as int,
|
||||||
|
kexRaw['spkId']! as int,
|
||||||
|
(kexRaw['ek']! as String).fromBase64().toPublicKey(KeyPairType.x25519),
|
||||||
|
(kexRaw['ik']! as String).fromBase64().toPublicKey(KeyPairType.ed25519),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deserialize the entire ratchet
|
||||||
|
ratchets[key] = OmemoDoubleRatchet(
|
||||||
|
OmemoKeyPair.fromBytes(
|
||||||
|
base64Decode(ratchetRaw['dhsPub']! as String),
|
||||||
|
base64Decode(ratchetRaw['dhs']! as String),
|
||||||
|
KeyPairType.x25519,
|
||||||
|
),
|
||||||
|
(ratchetRaw['dhrPub'] as String?)
|
||||||
|
?.fromBase64()
|
||||||
|
.toPublicKey(KeyPairType.x25519),
|
||||||
|
base64Decode(ratchetRaw['rk']! as String),
|
||||||
|
(ratchetRaw['cks'] as String?)?.fromBase64(),
|
||||||
|
(ratchetRaw['ckr'] as String?)?.fromBase64(),
|
||||||
|
ratchetRaw['ns']! as int,
|
||||||
|
ratchetRaw['nr']! as int,
|
||||||
|
ratchetRaw['pn']! as int,
|
||||||
|
(ratchetRaw['ik']! as String)
|
||||||
|
.fromBase64()
|
||||||
|
.toPublicKey(KeyPairType.ed25519),
|
||||||
|
(ratchetRaw['ad']! as String).fromBase64(),
|
||||||
|
mkSkipped,
|
||||||
|
intToBool(ratchetRaw['acked']! as int),
|
||||||
|
kex,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OmemoDataPackage(
|
||||||
|
(jsonDecode(deviceListRaw.first['devices']! as String) as List<dynamic>)
|
||||||
|
.cast<int>(),
|
||||||
|
ratchets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> commitTrust(BTBVTrustData trust) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
await db.insert(
|
||||||
|
omemoTrustTable,
|
||||||
|
{
|
||||||
|
'jid': trust.jid,
|
||||||
|
'device': trust.device,
|
||||||
|
'trust': trust.state.value,
|
||||||
|
'enabled': boolToInt(trust.enabled),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<BTBVTrustData>> loadTrust(String jid) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final rawTrust = await db.query(
|
||||||
|
omemoTrustTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rawTrust.map((trust) {
|
||||||
|
return BTBVTrustData(
|
||||||
|
jid,
|
||||||
|
trust['device']! as int,
|
||||||
|
BTBVTrustState.fromInt(trust['trust']! as int),
|
||||||
|
intToBool(trust['enabled']! as int),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeTrust(String jid) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
await db.delete(
|
||||||
|
omemoTrustTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import 'package:moxxyv2/service/database/database.dart';
|
|||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/service/not_specified.dart';
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/subscription.dart';
|
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
|
||||||
@@ -32,7 +31,7 @@ class RosterService {
|
|||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||||
Future<RosterItem> addRosterItemFromData(
|
Future<RosterItem> addRosterItemFromData(
|
||||||
String avatarUrl,
|
String avatarPath,
|
||||||
String avatarHash,
|
String avatarHash,
|
||||||
String jid,
|
String jid,
|
||||||
String title,
|
String title,
|
||||||
@@ -47,7 +46,7 @@ class RosterService {
|
|||||||
// TODO(PapaTutuWawa): Handle groups
|
// TODO(PapaTutuWawa): Handle groups
|
||||||
final i = RosterItem(
|
final i = RosterItem(
|
||||||
-1,
|
-1,
|
||||||
avatarUrl,
|
avatarPath,
|
||||||
avatarHash,
|
avatarHash,
|
||||||
jid,
|
jid,
|
||||||
title,
|
title,
|
||||||
@@ -76,7 +75,7 @@ class RosterService {
|
|||||||
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
|
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
|
||||||
Future<RosterItem> updateRosterItem(
|
Future<RosterItem> updateRosterItem(
|
||||||
int id, {
|
int id, {
|
||||||
String? avatarUrl,
|
String? avatarPath,
|
||||||
String? avatarHash,
|
String? avatarHash,
|
||||||
String? title,
|
String? title,
|
||||||
String? subscription,
|
String? subscription,
|
||||||
@@ -89,8 +88,8 @@ class RosterService {
|
|||||||
}) async {
|
}) async {
|
||||||
final i = <String, dynamic>{};
|
final i = <String, dynamic>{};
|
||||||
|
|
||||||
if (avatarUrl != null) {
|
if (avatarPath != null) {
|
||||||
i['avatarUrl'] = avatarUrl;
|
i['avatarPath'] = avatarPath;
|
||||||
}
|
}
|
||||||
if (avatarHash != null) {
|
if (avatarHash != null) {
|
||||||
i['avatarHash'] = avatarHash;
|
i['avatarHash'] = avatarHash;
|
||||||
@@ -197,7 +196,7 @@ class RosterService {
|
|||||||
/// and, if it was successful, create the database entry. Returns the
|
/// and, if it was successful, create the database entry. Returns the
|
||||||
/// [RosterItem] model object.
|
/// [RosterItem] model object.
|
||||||
Future<RosterItem> addToRosterWrapper(
|
Future<RosterItem> addToRosterWrapper(
|
||||||
String avatarUrl,
|
String avatarPath,
|
||||||
String avatarHash,
|
String avatarHash,
|
||||||
String jid,
|
String jid,
|
||||||
String title,
|
String title,
|
||||||
@@ -205,7 +204,7 @@ class RosterService {
|
|||||||
final css = GetIt.I.get<ContactsService>();
|
final css = GetIt.I.get<ContactsService>();
|
||||||
final contactId = await css.getContactIdForJid(jid);
|
final contactId = await css.getContactIdForJid(jid);
|
||||||
final item = await addRosterItemFromData(
|
final item = await addRosterItemFromData(
|
||||||
avatarUrl,
|
avatarPath,
|
||||||
avatarHash,
|
avatarHash,
|
||||||
jid,
|
jid,
|
||||||
title,
|
title,
|
||||||
@@ -217,14 +216,19 @@ class RosterService {
|
|||||||
await css.getContactDisplayName(contactId),
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
|
|
||||||
final result = await GetIt.I
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
.get<XmppConnection>()
|
final result = await conn.getRosterManager()!.addToRoster(jid, title);
|
||||||
.getRosterManager()!
|
|
||||||
.addToRoster(jid, title);
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// TODO(Unknown): Signal error?
|
// TODO(Unknown): Signal error?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final to = JID.fromString(jid);
|
||||||
|
final preApproval =
|
||||||
|
await conn.getPresenceManager()!.preApproveSubscription(to);
|
||||||
|
if (!preApproval) {
|
||||||
|
await conn.getPresenceManager()!.requestSubscription(to);
|
||||||
|
}
|
||||||
|
|
||||||
sendEvent(RosterDiffEvent(added: [item]));
|
sendEvent(RosterDiffEvent(added: [item]));
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -236,14 +240,14 @@ class RosterService {
|
|||||||
String jid, {
|
String jid, {
|
||||||
bool unsubscribe = true,
|
bool unsubscribe = true,
|
||||||
}) async {
|
}) async {
|
||||||
final roster = GetIt.I.get<XmppConnection>().getRosterManager()!;
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
final roster = conn.getRosterManager()!;
|
||||||
|
final pm = conn.getManagerById<PresenceManager>(presenceManager)!;
|
||||||
final result = await roster.removeFromRoster(jid);
|
final result = await roster.removeFromRoster(jid);
|
||||||
if (result == RosterRemovalResult.okay ||
|
if (result == RosterRemovalResult.okay ||
|
||||||
result == RosterRemovalResult.itemNotFound) {
|
result == RosterRemovalResult.itemNotFound) {
|
||||||
if (unsubscribe) {
|
if (unsubscribe) {
|
||||||
GetIt.I
|
await pm.unsubscribe(JID.fromString(jid));
|
||||||
.get<SubscriptionRequestService>()
|
|
||||||
.sendUnsubscriptionRequest(jid);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.finest('Removing from roster maybe worked. Removing from database');
|
_log.finest('Removing from roster maybe worked. Removing from database');
|
||||||
@@ -253,4 +257,25 @@ class RosterService {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes all roster items that are pseudo roster items.
|
||||||
|
Future<void> removePseudoRosterItems() async {
|
||||||
|
final items = await getRoster();
|
||||||
|
final removed = List<String>.empty(growable: true);
|
||||||
|
for (final item in items) {
|
||||||
|
if (!item.pseudoRosterItem) continue;
|
||||||
|
|
||||||
|
assert(
|
||||||
|
item.contactId != null,
|
||||||
|
'Only pseudo roster items that are for the contact integration should ge removed',
|
||||||
|
);
|
||||||
|
|
||||||
|
removed.add(item.jid);
|
||||||
|
await removeRosterItem(item.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent(
|
||||||
|
RosterDiffEvent(removed: removed),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
|||||||
import 'package:moxxyv2/service/language.dart';
|
import 'package:moxxyv2/service/language.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/connectivity.dart';
|
import 'package:moxxyv2/service/moxxmpp/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
|
||||||
import 'package:moxxyv2/service/moxxmpp/roster.dart';
|
import 'package:moxxyv2/service/moxxmpp/roster.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/socket.dart';
|
import 'package:moxxyv2/service/moxxmpp/socket.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/stream.dart';
|
import 'package:moxxyv2/service/moxxmpp/stream.dart';
|
||||||
@@ -32,8 +31,9 @@ import 'package:moxxyv2/service/omemo/omemo.dart';
|
|||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/reactions.dart';
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
|
import 'package:moxxyv2/service/share.dart';
|
||||||
import 'package:moxxyv2/service/stickers.dart';
|
import 'package:moxxyv2/service/stickers.dart';
|
||||||
import 'package:moxxyv2/service/subscription.dart';
|
import 'package:moxxyv2/service/storage.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
@@ -175,11 +175,10 @@ Future<void> entrypoint() async {
|
|||||||
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||||
GetIt.I.registerSingleton<StickersService>(StickersService());
|
GetIt.I.registerSingleton<StickersService>(StickersService());
|
||||||
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
|
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
|
||||||
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
|
||||||
SubscriptionRequestService(),
|
|
||||||
);
|
|
||||||
GetIt.I.registerSingleton<FilesService>(FilesService());
|
GetIt.I.registerSingleton<FilesService>(FilesService());
|
||||||
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
||||||
|
GetIt.I.registerSingleton<StorageService>(StorageService());
|
||||||
|
GetIt.I.registerSingleton<ShareService>(ShareService());
|
||||||
final xmpp = XmppService();
|
final xmpp = XmppService();
|
||||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||||
|
|
||||||
@@ -211,10 +210,14 @@ Future<void> entrypoint() async {
|
|||||||
StreamManagementNegotiator(),
|
StreamManagementNegotiator(),
|
||||||
CSINegotiator(),
|
CSINegotiator(),
|
||||||
RosterFeatureNegotiator(),
|
RosterFeatureNegotiator(),
|
||||||
|
PresenceNegotiator(),
|
||||||
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
||||||
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
||||||
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
||||||
SaslPlainNegotiator(),
|
SaslPlainNegotiator(),
|
||||||
|
Sasl2Negotiator(),
|
||||||
|
Bind2Negotiator(),
|
||||||
|
FASTSaslNegotiator(),
|
||||||
]);
|
]);
|
||||||
await connection.registerManagers([
|
await connection.registerManagers([
|
||||||
MoxxyStreamManagementManager(),
|
MoxxyStreamManagementManager(),
|
||||||
@@ -222,7 +225,12 @@ Future<void> entrypoint() async {
|
|||||||
const Identity(category: 'client', type: 'phone', name: 'Moxxy'),
|
const Identity(category: 'client', type: 'phone', name: 'Moxxy'),
|
||||||
]),
|
]),
|
||||||
RosterManager(MoxxyRosterStateManager()),
|
RosterManager(MoxxyRosterStateManager()),
|
||||||
MoxxyOmemoManager(),
|
OmemoManager(
|
||||||
|
GetIt.I.get<OmemoService>().getOmemoManager,
|
||||||
|
(toJid, _) async => GetIt.I
|
||||||
|
.get<ConversationService>()
|
||||||
|
.shouldEncryptForConversation(toJid),
|
||||||
|
),
|
||||||
PingManager(const Duration(minutes: 3)),
|
PingManager(const Duration(minutes: 3)),
|
||||||
MessageManager(),
|
MessageManager(),
|
||||||
PresenceManager(),
|
PresenceManager(),
|
||||||
@@ -230,7 +238,6 @@ Future<void> entrypoint() async {
|
|||||||
CSIManager(),
|
CSIManager(),
|
||||||
CarbonsManager(),
|
CarbonsManager(),
|
||||||
PubSubManager(),
|
PubSubManager(),
|
||||||
VCardManager(),
|
|
||||||
UserAvatarManager(),
|
UserAvatarManager(),
|
||||||
StableIdManager(),
|
StableIdManager(),
|
||||||
MessageDeliveryReceiptManager(),
|
MessageDeliveryReceiptManager(),
|
||||||
@@ -249,6 +256,7 @@ Future<void> entrypoint() async {
|
|||||||
LastMessageCorrectionManager(),
|
LastMessageCorrectionManager(),
|
||||||
MessageReactionsManager(),
|
MessageReactionsManager(),
|
||||||
StickersManager(),
|
StickersManager(),
|
||||||
|
MessageProcessingHintManager(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<XmppConnection>(connection);
|
GetIt.I.registerSingleton<XmppConnection>(connection);
|
||||||
|
|||||||
53
lib/service/share.dart
Normal file
53
lib/service/share.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
|
|
||||||
|
/// The service responsible for handling the direct share feature.
|
||||||
|
class ShareService {
|
||||||
|
/// Logging.
|
||||||
|
final Logger _log = Logger('ShareService');
|
||||||
|
|
||||||
|
/// Updates the share shortcuts for [conversation]. If a message was received or
|
||||||
|
/// sent in [conversation], this method should be called.
|
||||||
|
Future<void> recordSentMessage(
|
||||||
|
Conversation conversation,
|
||||||
|
) async {
|
||||||
|
assert(
|
||||||
|
implies(!conversation.isSelfChat, conversation.jid.isNotEmpty),
|
||||||
|
'Only self-chats can have an empty JID',
|
||||||
|
);
|
||||||
|
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
|
|
||||||
|
// Use the correct title if we share to the note-to-self chat.
|
||||||
|
final conversationName = conversation.isSelfChat
|
||||||
|
? t.pages.conversations.speeddialAddNoteToSelf
|
||||||
|
: conversation.getTitleWithOptionalContact(
|
||||||
|
prefs.enableContactIntegration,
|
||||||
|
);
|
||||||
|
final conversationImageFilePath =
|
||||||
|
conversation.getAvatarPathWithOptionalContact(
|
||||||
|
prefs.enableContactIntegration,
|
||||||
|
);
|
||||||
|
// Prevent empty JIDs as that messes with share_handler
|
||||||
|
final conversationJid =
|
||||||
|
conversation.isSelfChat ? selfChatShareFakeJid : conversation.jid;
|
||||||
|
|
||||||
|
_log.finest(
|
||||||
|
'Creating direct share target "$conversationName" (jid=$conversationJid, avatarPath=$conversationImageFilePath)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tell the system to create a direct share shortcut
|
||||||
|
await MoxplatformPlugin.contacts.recordSentMessage(
|
||||||
|
conversationName,
|
||||||
|
conversationJid,
|
||||||
|
avatarPath: conversationImageFilePath.isEmpty ? null : conversationImageFilePath,
|
||||||
|
fallbackIcon: conversation.isSelfChat ? FallbackIconType.notes : FallbackIconType.person,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
|||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
|
import 'package:moxxyv2/shared/constants.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/file_metadata.dart';
|
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||||
@@ -24,12 +25,40 @@ import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
|||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
class StickersService {
|
class StickersService {
|
||||||
final Map<String, StickerPack> _stickerPacks = {};
|
|
||||||
final Logger _log = Logger('StickersService');
|
final Logger _log = Logger('StickersService');
|
||||||
|
|
||||||
Future<StickerPack?> getStickerPackById(String id) async {
|
/// Computes the total amount of storage occupied by the stickers in the sticker
|
||||||
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
|
/// pack identified by id [id].
|
||||||
|
/// NOTE that if a sticker does not indicate a file size, i.e. the "size" column is
|
||||||
|
/// NULL, then a size of 0 is assumed.
|
||||||
|
Future<int> getStickerPackSizeById(String id) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final result = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
SUM(size) AS size
|
||||||
|
FROM
|
||||||
|
$fileMetadataTable as fmt
|
||||||
|
WHERE
|
||||||
|
path IS NOT NULL AND
|
||||||
|
EXISTS (
|
||||||
|
SELECT
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
$stickersTable
|
||||||
|
WHERE
|
||||||
|
file_metadata_id = fmt.id AND
|
||||||
|
stickerPackId = ?
|
||||||
|
)
|
||||||
|
''',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.finest('Cumulative size for $id: $result');
|
||||||
|
return result.first['size'] as int? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StickerPack?> getStickerPackById(String id) async {
|
||||||
final db = GetIt.I.get<DatabaseService>().database;
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
final rawPack = await db.query(
|
final rawPack = await db.query(
|
||||||
stickerPacksTable,
|
stickerPacksTable,
|
||||||
@@ -59,13 +88,23 @@ SELECT
|
|||||||
fm.cipherTextHashes AS fm_cipherTextHashes,
|
fm.cipherTextHashes AS fm_cipherTextHashes,
|
||||||
fm.filename AS fm_filename,
|
fm.filename AS fm_filename,
|
||||||
fm.size AS fm_size
|
fm.size AS fm_size
|
||||||
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
FROM
|
||||||
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id;
|
(SELECT
|
||||||
|
*
|
||||||
|
FROM
|
||||||
|
$stickersTable
|
||||||
|
WHERE
|
||||||
|
stickerPackId = ?
|
||||||
|
) AS sticker
|
||||||
|
JOIN
|
||||||
|
$fileMetadataTable fm
|
||||||
|
ON
|
||||||
|
sticker.file_metadata_id = fm.id;
|
||||||
''',
|
''',
|
||||||
[id],
|
[id],
|
||||||
);
|
);
|
||||||
|
|
||||||
_stickerPacks[id] = StickerPack.fromDatabaseJson(
|
final stickerPack = StickerPack.fromDatabaseJson(
|
||||||
rawPack.first,
|
rawPack.first,
|
||||||
rawStickers.map((sticker) {
|
rawStickers.map((sticker) {
|
||||||
return Sticker.fromDatabaseJson(
|
return Sticker.fromDatabaseJson(
|
||||||
@@ -75,28 +114,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
|
).copyWith(
|
||||||
|
size: await getStickerPackSizeById(id),
|
||||||
);
|
);
|
||||||
|
|
||||||
return _stickerPacks[id]!;
|
return stickerPack;
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<StickerPack>> getStickerPacks() async {
|
|
||||||
if (_stickerPacks.isEmpty) {
|
|
||||||
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
|
|
||||||
stickerPacksTable,
|
|
||||||
columns: ['id'],
|
|
||||||
);
|
|
||||||
for (final rawPack in rawPackIds) {
|
|
||||||
final id = rawPack['id']! as String;
|
|
||||||
await getStickerPackById(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_log.finest('Got ${_stickerPacks.length} sticker packs');
|
|
||||||
return _stickerPacks.values.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeStickerPack(String id) async {
|
Future<void> removeStickerPack(String id) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
final pack = await getStickerPackById(id);
|
final pack = await getStickerPackById(id);
|
||||||
assert(pack != null, 'The sticker pack must exist');
|
assert(pack != null, 'The sticker pack must exist');
|
||||||
|
|
||||||
@@ -117,15 +143,17 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Remove from the database
|
// Remove from the database
|
||||||
await GetIt.I.get<DatabaseService>().database.delete(
|
await db.delete(
|
||||||
|
stickersTable,
|
||||||
|
where: 'stickerPackId = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
await db.delete(
|
||||||
stickerPacksTable,
|
stickerPacksTable,
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Remove from the cache
|
|
||||||
_stickerPacks.remove(id);
|
|
||||||
|
|
||||||
// Retract from PubSub
|
// Retract from PubSub
|
||||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||||
final result = await GetIt.I
|
final result = await GetIt.I
|
||||||
@@ -238,8 +266,8 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Get file metadata
|
// Get file metadata
|
||||||
final fileMetadataRaw =
|
final fs = GetIt.I.get<FilesService>();
|
||||||
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
|
||||||
MediaFileLocation(
|
MediaFileLocation(
|
||||||
sticker.fileMetadata.sourceUrls!,
|
sticker.fileMetadata.sourceUrls!,
|
||||||
p.basename(stickerPath),
|
p.basename(stickerPath),
|
||||||
@@ -265,7 +293,8 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
path: stickerPath,
|
path: stickerPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!fileMetadataRaw.retrieved) {
|
if (!fileMetadataRaw.retrieved &&
|
||||||
|
fileMetadataRaw.fileMetadata.path == null) {
|
||||||
final downloadStatusCode = await downloadFile(
|
final downloadStatusCode = await downloadFile(
|
||||||
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
|
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
|
||||||
stickerPath,
|
stickerPath,
|
||||||
@@ -279,13 +308,22 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var fm = fileMetadataRaw.fileMetadata;
|
||||||
|
if (fileMetadataRaw.fileMetadata.size == null) {
|
||||||
|
// Determine the file size of the sticker.
|
||||||
|
fm = await fs.updateFileMetadata(
|
||||||
|
fileMetadataRaw.fileMetadata.id,
|
||||||
|
size: File(stickerPath).lengthSync(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
stickers[i] = await _addStickerFromData(
|
stickers[i] = await _addStickerFromData(
|
||||||
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
|
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
|
||||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
remotePack.hashValue,
|
remotePack.hashValue,
|
||||||
sticker.desc,
|
sticker.desc,
|
||||||
sticker.suggests,
|
sticker.suggests,
|
||||||
fileMetadataRaw.fileMetadata,
|
fm,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,11 +425,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
pack.hashValue,
|
pack.hashValue,
|
||||||
pack.restricted,
|
pack.restricted,
|
||||||
true,
|
true,
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
await _addStickerPackFromData(stickerPack);
|
await _addStickerPackFromData(stickerPack);
|
||||||
|
|
||||||
// Add all stickers
|
// Add all stickers
|
||||||
|
var size = 0;
|
||||||
final stickers = List<Sticker>.empty(growable: true);
|
final stickers = List<Sticker>.empty(growable: true);
|
||||||
|
final fs = GetIt.I.get<FilesService>();
|
||||||
for (final sticker in pack.stickers) {
|
for (final sticker in pack.stickers) {
|
||||||
// Get the "path" to the sticker
|
// Get the "path" to the sticker
|
||||||
final stickerPath = await computeCachedPathForFile(
|
final stickerPath = await computeCachedPathForFile(
|
||||||
@@ -404,9 +446,7 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||||
.map((src) => src.url)
|
.map((src) => src.url)
|
||||||
.toList();
|
.toList();
|
||||||
final fileMetadataRaw = await GetIt.I
|
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
|
||||||
.get<FilesService>()
|
|
||||||
.createFileMetadataIfRequired(
|
|
||||||
MediaFileLocation(
|
MediaFileLocation(
|
||||||
urlSources,
|
urlSources,
|
||||||
p.basename(stickerPath),
|
p.basename(stickerPath),
|
||||||
@@ -432,11 +472,43 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Only copy the sticker to storage if we don't already have it
|
// Only copy the sticker to storage if we don't already have it
|
||||||
if (!fileMetadataRaw.retrieved) {
|
var fm = fileMetadataRaw.fileMetadata;
|
||||||
|
if (!fileMetadataRaw.retrieved ||
|
||||||
|
fileMetadataRaw.fileMetadata.path == null) {
|
||||||
|
_log.finest(
|
||||||
|
'Copying sticker ${sticker.metadata.name!} to media storage',
|
||||||
|
);
|
||||||
final stickerFile = archive.findFile(sticker.metadata.name!)!;
|
final stickerFile = archive.findFile(sticker.metadata.name!)!;
|
||||||
await File(stickerPath).writeAsBytes(
|
final file = File(stickerPath);
|
||||||
|
await file.writeAsBytes(
|
||||||
stickerFile.content as List<int>,
|
stickerFile.content as List<int>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Update the File Metadata entry
|
||||||
|
fm = await fs.updateFileMetadata(
|
||||||
|
fm.id,
|
||||||
|
size: file.lengthSync(),
|
||||||
|
path: stickerPath,
|
||||||
|
);
|
||||||
|
size += file.lengthSync();
|
||||||
|
} else {
|
||||||
|
_log.finest(
|
||||||
|
'Not copying sticker ${sticker.metadata.name!} as we already have it',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the sticker has size
|
||||||
|
if (fm.size == null) {
|
||||||
|
_log.finest(
|
||||||
|
'Sticker ${sticker.metadata.name!} has no size. Calculating it',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the File Metadata entry
|
||||||
|
fm = await fs.updateFileMetadata(
|
||||||
|
fm.id,
|
||||||
|
size: File(stickerPath).lengthSync(),
|
||||||
|
);
|
||||||
|
size += fm.size!;
|
||||||
}
|
}
|
||||||
|
|
||||||
stickers.add(
|
stickers.add(
|
||||||
@@ -446,18 +518,16 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
pack.hashValue,
|
pack.hashValue,
|
||||||
sticker.metadata.desc!,
|
sticker.metadata.desc!,
|
||||||
sticker.suggests,
|
sticker.suggests,
|
||||||
fileMetadataRaw.fileMetadata,
|
fm,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final stickerPackWithStickers = stickerPack.copyWith(
|
final stickerPackWithStickers = stickerPack.copyWith(
|
||||||
stickers: stickers,
|
stickers: stickers,
|
||||||
|
size: size,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add it to the cache
|
|
||||||
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
|
|
||||||
|
|
||||||
_log.info(
|
_log.info(
|
||||||
'Sticker pack ${stickerPack.id} successfully added to the database',
|
'Sticker pack ${stickerPack.id} successfully added to the database',
|
||||||
);
|
);
|
||||||
@@ -466,4 +536,110 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
|||||||
unawaited(_publishStickerPack(pack));
|
unawaited(_publishStickerPack(pack));
|
||||||
return stickerPackWithStickers;
|
return stickerPackWithStickers;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns a paginated list of sticker packs.
|
||||||
|
/// [includeStickers] controls whether the stickers for a given sticker pack are
|
||||||
|
/// fetched from the database. Setting this to false, i.e. not loading the stickers,
|
||||||
|
/// can be useful, for example, when we're only interested in listing the sticker
|
||||||
|
/// packs without the stickers being visible.
|
||||||
|
Future<List<StickerPack>> getPaginatedStickerPacks(
|
||||||
|
bool olderThan,
|
||||||
|
int? timestamp,
|
||||||
|
bool includeStickers,
|
||||||
|
) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final comparator = olderThan ? '<' : '>';
|
||||||
|
final query = timestamp != null ? 'addedTimestamp $comparator ?' : null;
|
||||||
|
|
||||||
|
final stickerPacksRaw = await db.query(
|
||||||
|
stickerPacksTable,
|
||||||
|
where: query,
|
||||||
|
orderBy: 'addedTimestamp DESC',
|
||||||
|
limit: stickerPackPaginationSize,
|
||||||
|
);
|
||||||
|
|
||||||
|
final stickerPacks = List<StickerPack>.empty(growable: true);
|
||||||
|
for (final pack in stickerPacksRaw) {
|
||||||
|
// Query the stickers
|
||||||
|
List<Map<String, Object?>> stickersRaw;
|
||||||
|
if (includeStickers) {
|
||||||
|
stickersRaw = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
st.*,
|
||||||
|
fm.id AS fm_id,
|
||||||
|
fm.path AS fm_path,
|
||||||
|
fm.sourceUrls AS fm_sourceUrls,
|
||||||
|
fm.mimeType AS fm_mimeType,
|
||||||
|
fm.thumbnailType AS fm_thumbnailType,
|
||||||
|
fm.thumbnailData AS fm_thumbnailData,
|
||||||
|
fm.width AS fm_width,
|
||||||
|
fm.height AS fm_height,
|
||||||
|
fm.plaintextHashes AS fm_plaintextHashes,
|
||||||
|
fm.encryptionKey AS fm_encryptionKey,
|
||||||
|
fm.encryptionIv AS fm_encryptionIv,
|
||||||
|
fm.encryptionScheme AS fm_encryptionScheme,
|
||||||
|
fm.cipherTextHashes AS fm_cipherTextHashes,
|
||||||
|
fm.filename AS fm_filename,
|
||||||
|
fm.size AS fm_size
|
||||||
|
FROM
|
||||||
|
$stickersTable AS st,
|
||||||
|
$fileMetadataTable AS fm
|
||||||
|
WHERE
|
||||||
|
st.stickerPackId = ? AND
|
||||||
|
st.file_metadata_id = fm.id
|
||||||
|
''',
|
||||||
|
[
|
||||||
|
pack['id']! as String,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
stickersRaw = List<Map<String, Object?>>.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
final stickerPack = StickerPack.fromDatabaseJson(
|
||||||
|
pack,
|
||||||
|
stickersRaw.map((sticker) {
|
||||||
|
return Sticker.fromDatabaseJson(
|
||||||
|
sticker,
|
||||||
|
FileMetadata.fromDatabaseJson(
|
||||||
|
getPrefixedSubMap(sticker, 'fm_'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// If stickers were not requested, we still have to get the size of the
|
||||||
|
/// sticker pack anyway.
|
||||||
|
int size;
|
||||||
|
if (includeStickers && stickerPack.stickers.isNotEmpty) {
|
||||||
|
size = stickerPack.stickers
|
||||||
|
.map((sticker) => sticker.fileMetadata.size ?? 0)
|
||||||
|
.reduce((value, element) => value + element);
|
||||||
|
} else {
|
||||||
|
final sizeResult = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
SUM(fm.size) as size
|
||||||
|
FROM
|
||||||
|
$fileMetadataTable as fm,
|
||||||
|
$stickersTable as st
|
||||||
|
WHERE
|
||||||
|
st.stickerPackId = ? AND
|
||||||
|
st.file_metadata_id = fm.id
|
||||||
|
''',
|
||||||
|
[pack['id']! as String],
|
||||||
|
);
|
||||||
|
size = sizeResult.first['size'] as int? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
stickerPacks.add(
|
||||||
|
stickerPack.copyWith(
|
||||||
|
size: size,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stickerPacks;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
lib/service/storage.dart
Normal file
114
lib/service/storage.dart
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/files.dart';
|
||||||
|
import 'package:moxxyv2/service/message.dart';
|
||||||
|
|
||||||
|
/// Service responsible for handling storage related queries, like how much storage
|
||||||
|
/// are we currently using.
|
||||||
|
class StorageService {
|
||||||
|
/// Logger.
|
||||||
|
final Logger _log = Logger('StorageService');
|
||||||
|
|
||||||
|
/// Compute the amount of storage all FileMetadata objects take, that both have
|
||||||
|
/// their file size and path set to something other than null.
|
||||||
|
/// Note that this usage does not include file metadata items that are stickers.
|
||||||
|
Future<int> computeUsedMediaStorage() async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final result = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT SUM(size) AS size FROM $fileMetadataTable AS fmt
|
||||||
|
WHERE path IS NOT NULL
|
||||||
|
AND size IS NOT NULL
|
||||||
|
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.finest('computeUsedMediaStorage: SQL:: $result');
|
||||||
|
return result.first['size'] as int? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> computeUsedStickerStorage() async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final result = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT SUM(size) AS size FROM $fileMetadataTable as fmt
|
||||||
|
WHERE path IS NOT NULL
|
||||||
|
AND size IS NOT NULL
|
||||||
|
AND EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
|
||||||
|
_log.finest('computeUsedStickerStorage: SQL:: $result');
|
||||||
|
return result.first['size'] as int? ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes shared media files for which the age of the newest attached message
|
||||||
|
/// is at least [timeOffsetMilliseconds] milliseconds in the past from the moment
|
||||||
|
/// of calling.
|
||||||
|
Future<void> deleteOldMediaFiles(int timeOffsetMilliseconds) async {
|
||||||
|
// The timestamp of the newest message referencing this
|
||||||
|
final maxAge =
|
||||||
|
DateTime.now().millisecondsSinceEpoch - timeOffsetMilliseconds;
|
||||||
|
// The database
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
|
||||||
|
// The query is pretty complicated because:
|
||||||
|
// - We deduplicate media files, meaning that there may be > 1 messages that use a given
|
||||||
|
// file metadata entry. To prevent deleting too many files, we have to find the newest
|
||||||
|
// message that references the file metadata item and check if that message's timestamp
|
||||||
|
// puts it in deletion range.
|
||||||
|
// - We don't want to delete files that belong to a sticker pack because the storage of those
|
||||||
|
// is managed differently.
|
||||||
|
// - In case we have file metadata items that are dangling, we also remove those.
|
||||||
|
// TODO(Unknown): It might be nice to merge the two subqueries
|
||||||
|
final results = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
path,
|
||||||
|
id
|
||||||
|
FROM
|
||||||
|
$fileMetadataTable AS fmt
|
||||||
|
WHERE (
|
||||||
|
(SELECT MAX(timestamp) FROM $messagesTable WHERE file_metadata_id = fmt.id) <= $maxAge
|
||||||
|
OR NOT EXISTS (SELECT id FROM $messagesTable WHERE file_metadata_id = fmt.id)
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
|
||||||
|
AND path IS NOT NULL
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
_log.finest('Found ${results.length} matching files for deletion');
|
||||||
|
|
||||||
|
for (final result in results) {
|
||||||
|
// Update the entry
|
||||||
|
await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||||
|
result['id']! as String,
|
||||||
|
path: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final file = File(result['path']! as String);
|
||||||
|
if (file.existsSync()) await file.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty the message caches for conversations where we just removed the file
|
||||||
|
final resultIdPlaceholders =
|
||||||
|
List<String>.filled(results.length, '?').join(', ');
|
||||||
|
final conversations = (await db.query(
|
||||||
|
messagesTable,
|
||||||
|
where: 'file_metadata_id IN ($resultIdPlaceholders)',
|
||||||
|
whereArgs: results.map((result) => result['id']! as String).toList(),
|
||||||
|
columns: ['conversationJid'],
|
||||||
|
distinct: true,
|
||||||
|
))
|
||||||
|
.map((item) => item['conversationJid']! as String);
|
||||||
|
|
||||||
|
// Evict the affected message pages from cache
|
||||||
|
_log.finest('Evicting conversations from cache: $conversations');
|
||||||
|
await GetIt.I
|
||||||
|
.get<MessageService>()
|
||||||
|
.evictMultipleFromCache(conversations.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
|
||||||
import 'package:moxxyv2/service/database/constants.dart';
|
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
|
||||||
import 'package:synchronized/synchronized.dart';
|
|
||||||
|
|
||||||
class SubscriptionRequestService {
|
|
||||||
List<String>? _subscriptionRequests;
|
|
||||||
|
|
||||||
final Lock _lock = Lock();
|
|
||||||
|
|
||||||
/// Only load data from the database into
|
|
||||||
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
|
|
||||||
/// been loaded.
|
|
||||||
Future<void> _loadSubscriptionRequestsIfNeeded() async {
|
|
||||||
await _lock.synchronized(() async {
|
|
||||||
_subscriptionRequests ??= List<String>.from(
|
|
||||||
(await GetIt.I
|
|
||||||
.get<DatabaseService>()
|
|
||||||
.database
|
|
||||||
.query(subscriptionsTable))
|
|
||||||
.map((m) => m['jid']! as String)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<List<String>> getSubscriptionRequests() async {
|
|
||||||
await _loadSubscriptionRequestsIfNeeded();
|
|
||||||
return _subscriptionRequests!;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addSubscriptionRequest(String jid) async {
|
|
||||||
await _loadSubscriptionRequestsIfNeeded();
|
|
||||||
|
|
||||||
await _lock.synchronized(() async {
|
|
||||||
if (!_subscriptionRequests!.contains(jid)) {
|
|
||||||
_subscriptionRequests!.add(jid);
|
|
||||||
|
|
||||||
await GetIt.I.get<DatabaseService>().database.insert(
|
|
||||||
subscriptionsTable,
|
|
||||||
{
|
|
||||||
'jid': jid,
|
|
||||||
},
|
|
||||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeSubscriptionRequest(String jid) async {
|
|
||||||
await _loadSubscriptionRequestsIfNeeded();
|
|
||||||
|
|
||||||
await _lock.synchronized(() async {
|
|
||||||
if (_subscriptionRequests!.contains(jid)) {
|
|
||||||
_subscriptionRequests!.remove(jid);
|
|
||||||
await GetIt.I.get<DatabaseService>().database.delete(
|
|
||||||
subscriptionsTable,
|
|
||||||
where: 'jid = ?',
|
|
||||||
whereArgs: [jid],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> hasPendingSubscriptionRequest(String jid) async {
|
|
||||||
return (await getSubscriptionRequests()).contains(jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
PresenceManager get _presence =>
|
|
||||||
GetIt.I.get<XmppConnection>().getPresenceManager()!;
|
|
||||||
|
|
||||||
/// Accept a subscription request from [jid].
|
|
||||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
|
||||||
_presence.sendSubscriptionRequestApproval(jid);
|
|
||||||
await removeSubscriptionRequest(jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reject a subscription request from [jid].
|
|
||||||
Future<void> rejectSubscriptionRequest(String jid) async {
|
|
||||||
_presence.sendSubscriptionRequestRejection(jid);
|
|
||||||
await removeSubscriptionRequest(jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Send a subscription request to [jid].
|
|
||||||
void sendSubscriptionRequest(String jid) {
|
|
||||||
_presence.sendSubscriptionRequest(jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove a presence subscription with [jid].
|
|
||||||
void sendUnsubscriptionRequest(String jid) {
|
|
||||||
_presence.sendUnsubscriptionRequest(jid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,78 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.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/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
extension UserAgentJson on UserAgent {
|
||||||
|
Map<String, String?> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'software': software,
|
||||||
|
'device': device,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const _userAgentKey = 'userAgent';
|
||||||
|
|
||||||
class XmppStateService {
|
class XmppStateService {
|
||||||
/// Persistent state around the connection, like the SM token, etc.
|
/// Persistent state around the connection, like the SM token, etc.
|
||||||
XmppState? _state;
|
XmppState? _state;
|
||||||
|
|
||||||
|
/// Cache the user agent
|
||||||
|
UserAgent? _userAgent;
|
||||||
|
final Lock _userAgentLock = Lock();
|
||||||
|
|
||||||
|
/// The user agent used for SASL2 authentication. If cached, returns from cache.
|
||||||
|
/// If not cached, loads from the database. If not in the database, creates a
|
||||||
|
/// user agent and writes it to the database.
|
||||||
|
Future<UserAgent> get userAgent async {
|
||||||
|
return _userAgentLock.synchronized(() async {
|
||||||
|
if (_userAgent != null) return _userAgent!;
|
||||||
|
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final rowsRaw = await db.database.query(
|
||||||
|
xmppStateTable,
|
||||||
|
where: 'key = ?',
|
||||||
|
whereArgs: [_userAgentKey],
|
||||||
|
);
|
||||||
|
if (rowsRaw.isEmpty) {
|
||||||
|
// Generate a new user agent
|
||||||
|
_userAgent = UserAgent(
|
||||||
|
software: 'Moxxy',
|
||||||
|
id: const Uuid().v4(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write it to the database
|
||||||
|
await db.insert(
|
||||||
|
xmppStateTable,
|
||||||
|
{
|
||||||
|
'key': _userAgentKey,
|
||||||
|
'value': jsonEncode(_userAgent!.toJson()),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _userAgent!;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(rowsRaw.length == 1, 'Only one row must exist');
|
||||||
|
|
||||||
|
final data = rowsRaw.first['value']! as String;
|
||||||
|
final json =
|
||||||
|
(jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String?>();
|
||||||
|
final userAgent = UserAgent(
|
||||||
|
device: json['device'],
|
||||||
|
software: json['software'],
|
||||||
|
id: json['id'],
|
||||||
|
);
|
||||||
|
_userAgent = userAgent;
|
||||||
|
return _userAgent!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<XmppState> getXmppState() async {
|
Future<XmppState> getXmppState() async {
|
||||||
if (_state != null) return _state!;
|
if (_state != null) return _state!;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:moxlib/awaitabledatasender.dart';
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
|||||||
@@ -14,3 +14,13 @@ const int maxSharedMediaPages = 3;
|
|||||||
|
|
||||||
/// The amount of conversations for which we cache the first page.
|
/// The amount of conversations for which we cache the first page.
|
||||||
const int conversationMessagePageCacheSize = 4;
|
const int conversationMessagePageCacheSize = 4;
|
||||||
|
|
||||||
|
/// The amount of sticker packs we fetch per paginated request
|
||||||
|
const stickerPackPaginationSize = 10;
|
||||||
|
|
||||||
|
/// The amount of sticker packs we can cache in memory.
|
||||||
|
const maxStickerPackPages = 2;
|
||||||
|
|
||||||
|
/// An "invalid" fake JID to make share_handler happy when adding the self-chat
|
||||||
|
/// to the direct share list.
|
||||||
|
const String selfChatShareFakeJid = '{{ self-chat }}';
|
||||||
|
|||||||
11
lib/shared/debug.dart
Normal file
11
lib/shared/debug.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
enum DebugCommand {
|
||||||
|
/// Clear the stream resumption state so that the next connection is fresh.
|
||||||
|
clearStreamResumption(0),
|
||||||
|
requestRoster(1),
|
||||||
|
logAvailableMediaFiles(2);
|
||||||
|
|
||||||
|
const DebugCommand(this.id);
|
||||||
|
|
||||||
|
/// The id of the command
|
||||||
|
final int id;
|
||||||
|
}
|
||||||
@@ -1,86 +1,199 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
|
||||||
const unspecifiedError = -1;
|
enum ErrorType {
|
||||||
const noError = 0;
|
unknown(-1),
|
||||||
const fileUploadFailedError = 1;
|
remoteServerNotFound(0),
|
||||||
const messageNotEncryptedForDevice = 2;
|
remoteServerTimeout(1);
|
||||||
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;
|
|
||||||
const messageServiceUnavailable = 13;
|
|
||||||
const messageRemoteServerTimeout = 14;
|
|
||||||
const messageRemoteServerNotFound = 15;
|
|
||||||
|
|
||||||
int errorTypeFromException(dynamic exception) {
|
const ErrorType(this.value);
|
||||||
|
|
||||||
|
factory ErrorType.fromValue(int value) {
|
||||||
|
switch (value) {
|
||||||
|
case 0:
|
||||||
|
return ErrorType.remoteServerNotFound;
|
||||||
|
case 1:
|
||||||
|
return ErrorType.remoteServerTimeout;
|
||||||
|
default:
|
||||||
|
return ErrorType.unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The identifier value of this error type.
|
||||||
|
final int value;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageErrorType {
|
||||||
|
unspecified(-1),
|
||||||
|
// TODO(Unknown): Maybe remove
|
||||||
|
noError(0),
|
||||||
|
|
||||||
|
/// The file upload failed.
|
||||||
|
fileUploadFailed(1),
|
||||||
|
|
||||||
|
/// The received message was not encrypted for this device.
|
||||||
|
notEncryptedForDevice(2),
|
||||||
|
|
||||||
|
/// The HMAC of the encrypted message is wrong.
|
||||||
|
invalidHMAC(3),
|
||||||
|
|
||||||
|
/// We have no key available to decrypt the message.
|
||||||
|
noDecryptionKey(4),
|
||||||
|
|
||||||
|
/// The sanity-checks on the included affix elements failed.
|
||||||
|
invalidAffixElements(5),
|
||||||
|
|
||||||
|
/// The encryption of the message somehow failed.
|
||||||
|
failedToEncrypt(7),
|
||||||
|
|
||||||
|
/// The decryption of the file failed.
|
||||||
|
failedToDecryptFile(8),
|
||||||
|
|
||||||
|
/// The contact does not support OMEMO:2.
|
||||||
|
omemoNotSupported(9),
|
||||||
|
|
||||||
|
/// The chat is set to use OMEMO, but the received file was sent in plaintext.
|
||||||
|
chatEncryptedButPlaintextFile(10),
|
||||||
|
|
||||||
|
/// The encryption of the file somehow failed.
|
||||||
|
failedToEncryptFile(11),
|
||||||
|
|
||||||
|
/// We were unable to download the file.
|
||||||
|
fileDownloadFailed(12),
|
||||||
|
|
||||||
|
/// The message was bounced with a <service-unavailable />.
|
||||||
|
serviceUnavailable(13),
|
||||||
|
|
||||||
|
/// The message was bounced with a <remote-server-timeout />.
|
||||||
|
remoteServerTimeout(14),
|
||||||
|
|
||||||
|
/// The message was bounced with a <remote-server-not-found />.
|
||||||
|
remoteServerNotFound(15);
|
||||||
|
|
||||||
|
const MessageErrorType(this.value);
|
||||||
|
|
||||||
|
static MessageErrorType? fromInt(int? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value == MessageErrorType.unspecified.value) {
|
||||||
|
return MessageErrorType.unspecified;
|
||||||
|
} else if (value == MessageErrorType.noError.value) {
|
||||||
|
return MessageErrorType.noError;
|
||||||
|
} else if (value == MessageErrorType.fileUploadFailed.value) {
|
||||||
|
return MessageErrorType.fileUploadFailed;
|
||||||
|
} else if (value == MessageErrorType.notEncryptedForDevice.value) {
|
||||||
|
return MessageErrorType.notEncryptedForDevice;
|
||||||
|
} else if (value == MessageErrorType.invalidHMAC.value) {
|
||||||
|
return MessageErrorType.invalidHMAC;
|
||||||
|
} else if (value == MessageErrorType.noDecryptionKey.value) {
|
||||||
|
return MessageErrorType.noDecryptionKey;
|
||||||
|
} else if (value == MessageErrorType.invalidAffixElements.value) {
|
||||||
|
return MessageErrorType.invalidAffixElements;
|
||||||
|
} else if (value == MessageErrorType.failedToEncrypt.value) {
|
||||||
|
return MessageErrorType.failedToEncrypt;
|
||||||
|
} else if (value == MessageErrorType.failedToDecryptFile.value) {
|
||||||
|
return MessageErrorType.failedToDecryptFile;
|
||||||
|
} else if (value == MessageErrorType.omemoNotSupported.value) {
|
||||||
|
return MessageErrorType.omemoNotSupported;
|
||||||
|
} else if (value == MessageErrorType.chatEncryptedButPlaintextFile.value) {
|
||||||
|
return MessageErrorType.chatEncryptedButPlaintextFile;
|
||||||
|
} else if (value == MessageErrorType.chatEncryptedButPlaintextFile.value) {
|
||||||
|
return MessageErrorType.chatEncryptedButPlaintextFile;
|
||||||
|
} else if (value == MessageErrorType.failedToEncryptFile.value) {
|
||||||
|
return MessageErrorType.failedToEncryptFile;
|
||||||
|
} else if (value == MessageErrorType.fileDownloadFailed.value) {
|
||||||
|
return MessageErrorType.fileDownloadFailed;
|
||||||
|
} else if (value == MessageErrorType.serviceUnavailable.value) {
|
||||||
|
return MessageErrorType.serviceUnavailable;
|
||||||
|
} else if (value == MessageErrorType.remoteServerTimeout.value) {
|
||||||
|
return MessageErrorType.remoteServerTimeout;
|
||||||
|
} else if (value == MessageErrorType.remoteServerNotFound.value) {
|
||||||
|
return MessageErrorType.remoteServerNotFound;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static MessageErrorType? fromException(dynamic exception) {
|
||||||
if (exception == null) {
|
if (exception == null) {
|
||||||
return noError;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (exception is NoDecryptionKeyException) {
|
if (exception is InvalidMessageHMACError) {
|
||||||
return messageNoDecryptionKey;
|
return MessageErrorType.invalidHMAC;
|
||||||
} else if (exception is InvalidMessageHMACException) {
|
} else if (exception is NotEncryptedForDeviceError) {
|
||||||
return messageInvalidHMAC;
|
return MessageErrorType.noDecryptionKey;
|
||||||
} else if (exception is NotEncryptedForDeviceException) {
|
|
||||||
return messageNoDecryptionKey;
|
|
||||||
} else if (exception is InvalidAffixElementsException) {
|
} else if (exception is InvalidAffixElementsException) {
|
||||||
return messageInvalidAffixElements;
|
return MessageErrorType.invalidAffixElements;
|
||||||
} else if (exception is EncryptionFailedException) {
|
} else if (exception is EncryptionFailedException) {
|
||||||
return messageFailedToEncrypt;
|
return MessageErrorType.failedToEncrypt;
|
||||||
} else if (exception is OmemoNotSupportedForContactException) {
|
} else if (exception is OmemoNotSupportedForContactException) {
|
||||||
return messageContactDoesNotSupportOmemo;
|
return MessageErrorType.omemoNotSupported;
|
||||||
}
|
}
|
||||||
|
|
||||||
return unspecifiedError;
|
return MessageErrorType.unspecified;
|
||||||
}
|
}
|
||||||
|
|
||||||
String errorToTranslatableString(int error) {
|
/// The identifier representing the error.
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
String get translatableString {
|
||||||
assert(
|
assert(
|
||||||
error != noError,
|
this != MessageErrorType.noError,
|
||||||
'Calling errorToTranslatableString with noError makes no sense',
|
'Calling errorToTranslatableString with noError makes no sense',
|
||||||
);
|
);
|
||||||
|
|
||||||
switch (error) {
|
switch (this) {
|
||||||
case messageNotEncryptedForDevice:
|
case MessageErrorType.notEncryptedForDevice:
|
||||||
return t.errors.omemo.notEncryptedForDevice;
|
return t.errors.omemo.notEncryptedForDevice;
|
||||||
case messageInvalidHMAC:
|
case MessageErrorType.invalidHMAC:
|
||||||
return t.errors.omemo.invalidHmac;
|
return t.errors.omemo.invalidHmac;
|
||||||
case messageNoDecryptionKey:
|
case MessageErrorType.noDecryptionKey:
|
||||||
return t.errors.omemo.noDecryptionKey;
|
return t.errors.omemo.noDecryptionKey;
|
||||||
case messageInvalidAffixElements:
|
case MessageErrorType.invalidAffixElements:
|
||||||
return t.errors.omemo.messageInvalidAfixElement;
|
return t.errors.omemo.messageInvalidAfixElement;
|
||||||
case fileUploadFailedError:
|
case MessageErrorType.fileUploadFailed:
|
||||||
return t.errors.message.fileUploadFailed;
|
return t.errors.message.fileUploadFailed;
|
||||||
case messageContactDoesNotSupportOmemo:
|
case MessageErrorType.omemoNotSupported:
|
||||||
return t.errors.message.contactDoesntSupportOmemo;
|
return t.errors.message.contactDoesntSupportOmemo;
|
||||||
case fileDownloadFailedError:
|
case MessageErrorType.fileDownloadFailed:
|
||||||
return t.errors.message.fileDownloadFailed;
|
return t.errors.message.fileDownloadFailed;
|
||||||
case messageServiceUnavailable:
|
case MessageErrorType.serviceUnavailable:
|
||||||
return t.errors.message.serviceUnavailable;
|
return t.errors.message.serviceUnavailable;
|
||||||
case messageRemoteServerTimeout:
|
case MessageErrorType.remoteServerTimeout:
|
||||||
return t.errors.message.remoteServerTimeout;
|
return t.errors.message.remoteServerTimeout;
|
||||||
case messageRemoteServerNotFound:
|
case MessageErrorType.remoteServerNotFound:
|
||||||
return t.errors.message.remoteServerNotFound;
|
return t.errors.message.remoteServerNotFound;
|
||||||
case messageFailedToEncrypt:
|
case MessageErrorType.failedToEncrypt:
|
||||||
return t.errors.message.failedToEncrypt;
|
return t.errors.message.failedToEncrypt;
|
||||||
case messageFailedToDecryptFile:
|
case MessageErrorType.failedToDecryptFile:
|
||||||
return t.errors.message.failedToDecryptFile;
|
return t.errors.message.failedToDecryptFile;
|
||||||
case messageChatEncryptedButFileNot:
|
case MessageErrorType.chatEncryptedButPlaintextFile:
|
||||||
return t.errors.message.fileNotEncrypted;
|
return t.errors.message.fileNotEncrypted;
|
||||||
case messageFailedToEncryptFile:
|
case MessageErrorType.failedToEncryptFile:
|
||||||
return t.errors.message.failedToEncryptFile;
|
return t.errors.message.failedToEncryptFile;
|
||||||
case unspecifiedError:
|
// NOTE: This fallthrough is just here to make Dart happy
|
||||||
|
case MessageErrorType.noError:
|
||||||
|
case MessageErrorType.unspecified:
|
||||||
return t.errors.message.unspecified;
|
return t.errors.message.unspecified;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
assert(false, 'Invalid error code $error used');
|
}
|
||||||
return '';
|
|
||||||
|
/// A converter for converting between [MessageErrorType] and [int].
|
||||||
|
class MessageErrorTypeConverter
|
||||||
|
implements JsonConverter<MessageErrorType, int> {
|
||||||
|
const MessageErrorTypeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
MessageErrorType fromJson(int json) {
|
||||||
|
return MessageErrorType.fromInt(json)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int toJson(MessageErrorType data) => data.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:moxlib/awaitabledatasender.dart';
|
import 'package:moxlib/moxlib.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';
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:core';
|
import 'dart:core';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
@@ -461,3 +462,15 @@ List<T> clampedListPrependAll<T>(List<T> list, List<T> items, int maxSize) {
|
|||||||
...list,
|
...list,
|
||||||
].sublist(0, maxSize);
|
].sublist(0, maxSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension StringJsonHelper on String {
|
||||||
|
/// Converts the Map into a JSON-encoded String. Helper function for working with nullable maps.
|
||||||
|
Map<String, dynamic> fromJson() {
|
||||||
|
return (jsonDecode(this) as Map<dynamic, dynamic>).cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension MapJsonHelper on Map<String, dynamic> {
|
||||||
|
/// Converts the map into a String. Helper function for working with nullable Strings.
|
||||||
|
String toJson() => jsonEncode(this);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,11 +14,11 @@ class ConversationChatStateConverter
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ChatState fromJson(Map<String, dynamic> json) =>
|
ChatState fromJson(Map<String, dynamic> json) =>
|
||||||
chatStateFromString(json['chatState'] as String);
|
ChatState.fromName(json['chatState'] as String);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson(ChatState state) => <String, String>{
|
Map<String, dynamic> toJson(ChatState state) => <String, String>{
|
||||||
'chatState': chatStateToString(state),
|
'chatState': state.toName(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,39 +40,90 @@ class ConversationMessageConverter
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum ConversationType {
|
enum ConversationType {
|
||||||
@JsonValue('chat')
|
chat('chat'),
|
||||||
chat,
|
note('note');
|
||||||
@JsonValue('note')
|
|
||||||
note
|
const ConversationType(this.value);
|
||||||
|
|
||||||
|
/// The identifier of the enum value.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
static ConversationType? fromInt(String value) {
|
||||||
|
switch (value) {
|
||||||
|
case 'chat':
|
||||||
|
return ConversationType.chat;
|
||||||
|
case 'note':
|
||||||
|
return ConversationType.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConversationTypeConverter
|
||||||
|
extends JsonConverter<ConversationType, String> {
|
||||||
|
const ConversationTypeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConversationType fromJson(String json) {
|
||||||
|
return ConversationType.fromInt(json)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toJson(ConversationType object) {
|
||||||
|
return object.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class Conversation with _$Conversation {
|
class Conversation with _$Conversation {
|
||||||
factory Conversation(
|
factory Conversation(
|
||||||
|
/// The title of the chat.
|
||||||
String title,
|
String title,
|
||||||
|
|
||||||
|
// The newest message in the chat.
|
||||||
@ConversationMessageConverter() Message? lastMessage,
|
@ConversationMessageConverter() Message? lastMessage,
|
||||||
String avatarUrl,
|
|
||||||
|
// The path to the avatar.
|
||||||
|
String avatarPath,
|
||||||
|
|
||||||
|
// The hash of the avatar.
|
||||||
|
String? avatarHash,
|
||||||
|
|
||||||
|
// The JID of the entity we're having a chat with...
|
||||||
String jid,
|
String jid,
|
||||||
|
|
||||||
|
// The number of unread messages.
|
||||||
int unreadCounter,
|
int unreadCounter,
|
||||||
ConversationType type,
|
|
||||||
|
// The kind of chat this conversation is representing.
|
||||||
|
@ConversationTypeConverter() ConversationType type,
|
||||||
|
|
||||||
|
// The timestamp the conversation was last changed.
|
||||||
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
|
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
|
||||||
int lastChangeTimestamp,
|
int lastChangeTimestamp,
|
||||||
// Indicates if the conversation should be shown on the homescreen
|
|
||||||
|
// Indicates if the conversation should be shown on the homescreen.
|
||||||
bool open,
|
bool open,
|
||||||
// Indicates, if [jid] is a regular user, if the user is in the roster.
|
|
||||||
bool inRoster,
|
/// Flag indicating whether the "add to roster" button should be shown.
|
||||||
// The subscription state of the roster item
|
bool showAddToRoster,
|
||||||
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)
|
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
|
|
||||||
// The current chat state
|
// The current chat state
|
||||||
@ConversationChatStateConverter() ChatState chatState, {
|
@ConversationChatStateConverter() ChatState chatState, {
|
||||||
|
|
||||||
// The id of the contact in the device's phonebook if it exists
|
// The id of the contact in the device's phonebook if it exists
|
||||||
String? contactId,
|
String? contactId,
|
||||||
|
|
||||||
// The path to the contact avatar, if available
|
// The path to the contact avatar, if available
|
||||||
String? contactAvatarPath,
|
String? contactAvatarPath,
|
||||||
|
|
||||||
// The contact's display name, if it exists
|
// The contact's display name, if it exists
|
||||||
String? contactDisplayName,
|
String? contactDisplayName,
|
||||||
}) = _Conversation;
|
}) = _Conversation;
|
||||||
@@ -85,16 +136,14 @@ class Conversation with _$Conversation {
|
|||||||
|
|
||||||
factory Conversation.fromDatabaseJson(
|
factory Conversation.fromDatabaseJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
bool inRoster,
|
bool showAddToRoster,
|
||||||
String subscription,
|
|
||||||
Message? lastMessage,
|
Message? lastMessage,
|
||||||
) {
|
) {
|
||||||
return Conversation.fromJson({
|
return Conversation.fromJson({
|
||||||
...json,
|
...json,
|
||||||
'muted': intToBool(json['muted']! as int),
|
'muted': intToBool(json['muted']! as int),
|
||||||
'open': intToBool(json['open']! as int),
|
'open': intToBool(json['open']! as int),
|
||||||
'inRoster': inRoster,
|
'showAddToRoster': showAddToRoster,
|
||||||
'subscription': subscription,
|
|
||||||
'encrypted': intToBool(json['encrypted']! as int),
|
'encrypted': intToBool(json['encrypted']! as int),
|
||||||
'chatState':
|
'chatState':
|
||||||
const ConversationChatStateConverter().toJson(ChatState.gone),
|
const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||||
@@ -107,8 +156,7 @@ class Conversation with _$Conversation {
|
|||||||
final map = toJson()
|
final map = toJson()
|
||||||
..remove('id')
|
..remove('id')
|
||||||
..remove('chatState')
|
..remove('chatState')
|
||||||
..remove('inRoster')
|
..remove('showAddToRoster')
|
||||||
..remove('subscription')
|
|
||||||
..remove('lastMessage');
|
..remove('lastMessage');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -123,30 +171,47 @@ class Conversation with _$Conversation {
|
|||||||
/// True, when the chat state of the conversation indicates typing. False, if not.
|
/// True, when the chat state of the conversation indicates typing. False, if not.
|
||||||
bool get isTyping => chatState == ChatState.composing;
|
bool get isTyping => chatState == ChatState.composing;
|
||||||
|
|
||||||
/// The path to the avatar. This returns, if enabled, first the contact's avatar
|
/// The path to the avatar. This returns, if [contactIntegration] is true, first the contact's avatar
|
||||||
/// path, then the XMPP avatar's path. If not enabled, just returns the regular
|
/// path, then the XMPP avatar's path. If [contactIntegration] is false, just returns the regular
|
||||||
/// XMPP avatar's path.
|
/// XMPP avatar's path.
|
||||||
String? get avatarPathWithOptionalContact {
|
String getAvatarPathWithOptionalContact(bool contactIntegration) {
|
||||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
if (contactIntegration) {
|
||||||
return contactAvatarPath ?? avatarUrl;
|
return contactAvatarPath ?? avatarPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
return avatarUrl;
|
return avatarPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The title of the chat. This returns, if enabled, first the contact's display
|
/// This getter is a short-hand for [getAvatarPathWithOptionalContact] with the
|
||||||
/// name, then the XMPP chat title. If not enabled, just returns the XMPP chat
|
/// contact integration enablement status extracted from the [PreferencesBloc].
|
||||||
|
/// NOTE: This method only works in the UI.
|
||||||
|
String? get avatarPathWithOptionalContact => getAvatarPathWithOptionalContact(
|
||||||
|
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The title of the chat. This returns, if [contactIntegration] is true, first the contact's display
|
||||||
|
/// name, then the XMPP chat title. If [contactIntegration] is false, just returns the XMPP chat
|
||||||
/// title.
|
/// title.
|
||||||
String get titleWithOptionalContact {
|
String getTitleWithOptionalContact(bool contactIntegration) {
|
||||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
if (contactIntegration) {
|
||||||
return contactDisplayName ?? title;
|
return contactDisplayName ?? title;
|
||||||
}
|
}
|
||||||
|
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
|
||||||
|
/// contact integration enablement status extracted from the [PreferencesBloc].
|
||||||
|
/// NOTE: This method only works in the UI.
|
||||||
|
String get titleWithOptionalContact => getTitleWithOptionalContact(
|
||||||
|
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
|
||||||
|
);
|
||||||
|
|
||||||
/// The amount of items that are shown in the context menu.
|
/// The amount of items that are shown in the context menu.
|
||||||
int get numberContextMenuOptions => 1 + (unreadCounter != 0 ? 1 : 0);
|
int get numberContextMenuOptions => 1 + (unreadCounter != 0 ? 1 : 0);
|
||||||
|
|
||||||
|
/// True, if the conversation is a self-chat. False, if not.
|
||||||
|
bool get isSelfChat => type == ConversationType.note;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sorts conversations in descending order by their last change timestamp.
|
/// Sorts conversations in descending order by their last change timestamp.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'dart:convert';
|
|
||||||
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/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
@@ -9,19 +9,43 @@ import 'package:moxxyv2/shared/warning_types.dart';
|
|||||||
part 'message.freezed.dart';
|
part 'message.freezed.dart';
|
||||||
part 'message.g.dart';
|
part 'message.g.dart';
|
||||||
|
|
||||||
const pseudoMessageTypeNewDevice = 1;
|
enum PseudoMessageType {
|
||||||
|
/// Indicates that a new device was created in the chat.
|
||||||
|
newDevice(1),
|
||||||
|
|
||||||
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
|
/// Indicates that an existing device has been replaced.
|
||||||
if (data == null) return <String, dynamic>{};
|
changedDevice(2);
|
||||||
|
|
||||||
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
|
const PseudoMessageType(this.value);
|
||||||
|
|
||||||
|
/// The identifier for the type of pseudo message.
|
||||||
|
final int value;
|
||||||
|
|
||||||
|
static PseudoMessageType? fromInt(int value) {
|
||||||
|
switch (value) {
|
||||||
|
case 1:
|
||||||
|
return PseudoMessageType.newDevice;
|
||||||
|
case 2:
|
||||||
|
return PseudoMessageType.changedDevice;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
return null;
|
||||||
if (data == null) return null;
|
}
|
||||||
if (data.isEmpty) return null;
|
}
|
||||||
|
|
||||||
return jsonEncode(data);
|
/// A converter for converting between [PseudoMessageType] and [int].
|
||||||
|
class PseudoMessageTypeConverter extends JsonConverter<PseudoMessageType, int> {
|
||||||
|
const PseudoMessageTypeConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
PseudoMessageType fromJson(int json) {
|
||||||
|
return PseudoMessageType.fromInt(json)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int toJson(PseudoMessageType object) {
|
||||||
|
return object.value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
@@ -38,21 +62,31 @@ class Message with _$Message {
|
|||||||
bool encrypted,
|
bool encrypted,
|
||||||
// True if the message contains a <no-store> Message Processing Hint. False if not
|
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||||
bool containsNoStore, {
|
bool containsNoStore, {
|
||||||
int? errorType,
|
@MessageErrorTypeConverter() MessageErrorType? errorType,
|
||||||
int? warningType,
|
int? warningType,
|
||||||
FileMetadata? fileMetadata,
|
FileMetadata? fileMetadata,
|
||||||
@Default(false) bool isDownloading,
|
@Default(false) bool isDownloading,
|
||||||
@Default(false) bool isUploading,
|
@Default(false) bool isUploading,
|
||||||
@Default(false) bool received,
|
@Default(false) bool received,
|
||||||
|
|
||||||
|
/// If the message was sent by us, this means that the recipient has displayed the message.
|
||||||
|
/// If we received the message, then this means that we sent a read marker for that message.
|
||||||
@Default(false) bool displayed,
|
@Default(false) bool displayed,
|
||||||
|
|
||||||
|
/// Specified whether the message has been acked using stream management, i.e. it was successfully sent to
|
||||||
|
/// the server.
|
||||||
@Default(false) bool acked,
|
@Default(false) bool acked,
|
||||||
|
|
||||||
|
/// Indicates whether the message has been retracted.
|
||||||
@Default(false) bool isRetracted,
|
@Default(false) bool isRetracted,
|
||||||
|
|
||||||
|
/// Indicates whether the message has been edited.
|
||||||
@Default(false) bool isEdited,
|
@Default(false) bool isEdited,
|
||||||
String? originId,
|
String? originId,
|
||||||
Message? quotes,
|
Message? quotes,
|
||||||
@Default([]) List<String> reactionsPreview,
|
@Default([]) List<String> reactionsPreview,
|
||||||
String? stickerPackId,
|
String? stickerPackId,
|
||||||
int? pseudoMessageType,
|
@PseudoMessageTypeConverter() PseudoMessageType? pseudoMessageType,
|
||||||
Map<String, dynamic>? pseudoMessageData,
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
}) = _Message;
|
}) = _Message;
|
||||||
|
|
||||||
@@ -82,8 +116,7 @@ class Message with _$Message {
|
|||||||
'isEdited': intToBool(json['isEdited']! as int),
|
'isEdited': intToBool(json['isEdited']! as int),
|
||||||
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||||
'reactionsPreview': reactionsPreview,
|
'reactionsPreview': reactionsPreview,
|
||||||
'pseudoMessageData':
|
'pseudoMessageData': (json['pseudoMessageData'] as String?)?.fromJson(),
|
||||||
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
|
||||||
}).copyWith(
|
}).copyWith(
|
||||||
quotes: quotes,
|
quotes: quotes,
|
||||||
fileMetadata: fileMetadata,
|
fileMetadata: fileMetadata,
|
||||||
@@ -113,12 +146,25 @@ class Message with _$Message {
|
|||||||
'isRetracted': boolToInt(isRetracted),
|
'isRetracted': boolToInt(isRetracted),
|
||||||
'isEdited': boolToInt(isEdited),
|
'isEdited': boolToInt(isEdited),
|
||||||
'containsNoStore': boolToInt(containsNoStore),
|
'containsNoStore': boolToInt(containsNoStore),
|
||||||
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
|
'pseudoMessageData': pseudoMessageData?.toJson(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the [errorType] describes an error related to OMEMO.
|
||||||
|
bool get isOmemoError => [
|
||||||
|
MessageErrorType.notEncryptedForDevice,
|
||||||
|
MessageErrorType.invalidHMAC,
|
||||||
|
MessageErrorType.noDecryptionKey,
|
||||||
|
MessageErrorType.invalidAffixElements,
|
||||||
|
MessageErrorType.failedToEncrypt,
|
||||||
|
MessageErrorType.failedToDecryptFile,
|
||||||
|
MessageErrorType.omemoNotSupported,
|
||||||
|
MessageErrorType.failedToEncryptFile,
|
||||||
|
].contains(errorType);
|
||||||
|
|
||||||
/// Returns true if the message is an error. If not, then returns false.
|
/// Returns true if the message is an error. If not, then returns false.
|
||||||
bool get hasError => errorType != null && errorType != noError;
|
bool get hasError =>
|
||||||
|
errorType != null && errorType != MessageErrorType.noError;
|
||||||
|
|
||||||
/// Returns true if the message is a warning. If not, then returns false.
|
/// Returns true if the message is a warning. If not, then returns false.
|
||||||
bool get hasWarning => warningType != null && warningType != noWarning;
|
bool get hasWarning => warningType != null && warningType != noWarning;
|
||||||
@@ -181,11 +227,7 @@ class Message with _$Message {
|
|||||||
|
|
||||||
/// Returns true if the menu item to show the error should be shown in the
|
/// Returns true if the menu item to show the error should be shown in the
|
||||||
/// longpress menu.
|
/// longpress menu.
|
||||||
bool get errorMenuVisible {
|
bool get errorMenuVisible => hasError && !isOmemoError;
|
||||||
return hasError &&
|
|
||||||
(errorType! < messageNotEncryptedForDevice ||
|
|
||||||
errorType! > messageInvalidAffixElements);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
||||||
/// images.
|
/// images.
|
||||||
@@ -201,9 +243,12 @@ class Message with _$Message {
|
|||||||
/// Returns true if the message can be copied to the clipboard.
|
/// Returns true if the message can be copied to the clipboard.
|
||||||
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
||||||
|
|
||||||
/// Returns true if the message is a sticker
|
/// Returns true if the message is a sticker.
|
||||||
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
|
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
|
||||||
|
|
||||||
/// True if the message is a media message
|
/// True if the message is a media message.
|
||||||
bool get isMedia => fileMetadata != null;
|
bool get isMedia => fileMetadata != null;
|
||||||
|
|
||||||
|
/// The JID of the sender in moxxmpp's format.
|
||||||
|
JID get senderJid => JID.fromString(sender);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ class OmemoDevice with _$OmemoDevice {
|
|||||||
bool trusted,
|
bool trusted,
|
||||||
bool verified,
|
bool verified,
|
||||||
bool enabled,
|
bool enabled,
|
||||||
int deviceId, {
|
int deviceId,
|
||||||
@Default(true) bool hasSessionWith,
|
) = _OmemoDevice;
|
||||||
}) = _OmemoDevice;
|
|
||||||
|
|
||||||
/// JSON
|
/// JSON
|
||||||
factory OmemoDevice.fromJson(Map<String, dynamic> json) =>
|
factory OmemoDevice.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ part 'roster.g.dart';
|
|||||||
class RosterItem with _$RosterItem {
|
class RosterItem with _$RosterItem {
|
||||||
factory RosterItem(
|
factory RosterItem(
|
||||||
int id,
|
int id,
|
||||||
String avatarUrl,
|
String avatarPath,
|
||||||
String avatarHash,
|
String avatarHash,
|
||||||
String jid,
|
String jid,
|
||||||
String title,
|
String title,
|
||||||
@@ -53,4 +53,24 @@ class RosterItem with _$RosterItem {
|
|||||||
'pseudoRosterItem': boolToInt(pseudoRosterItem),
|
'pseudoRosterItem': boolToInt(pseudoRosterItem),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a conversation with this roster item should display the "Add to roster" button.
|
||||||
|
bool get showAddToRosterButton {
|
||||||
|
// Those chats are not dealt with on the roster
|
||||||
|
if (pseudoRosterItem) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A full presence subscription is already achieved. Nothing to do
|
||||||
|
if (subscription == 'both') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are not yet waiting for a response to the presence request
|
||||||
|
if (ask == 'subscribe' && ['none', 'from', 'to'].contains(subscription)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ class StickerPack with _$StickerPack {
|
|||||||
String hashValue,
|
String hashValue,
|
||||||
bool restricted,
|
bool restricted,
|
||||||
bool local,
|
bool local,
|
||||||
|
|
||||||
|
/// The timestamp (milliseconds since epoch) when the sticker pack was added
|
||||||
|
int addedTimestamp,
|
||||||
|
|
||||||
|
/// The size in bytes
|
||||||
|
int size,
|
||||||
) = _StickerPack;
|
) = _StickerPack;
|
||||||
|
|
||||||
const StickerPack._();
|
const StickerPack._();
|
||||||
@@ -34,6 +40,8 @@ class StickerPack with _$StickerPack {
|
|||||||
pack.hashValue,
|
pack.hashValue,
|
||||||
pack.restricted,
|
pack.restricted,
|
||||||
local,
|
local,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// JSON
|
/// JSON
|
||||||
@@ -49,6 +57,7 @@ class StickerPack with _$StickerPack {
|
|||||||
'local': true,
|
'local': true,
|
||||||
'restricted': intToBool(json['restricted']! as int),
|
'restricted': intToBool(json['restricted']! as int),
|
||||||
'stickers': <Sticker>[],
|
'stickers': <Sticker>[],
|
||||||
|
'size': 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
return pack.copyWith(stickers: stickers);
|
return pack.copyWith(stickers: stickers);
|
||||||
@@ -57,7 +66,8 @@ class StickerPack with _$StickerPack {
|
|||||||
Map<String, dynamic> toDatabaseJson() {
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
final json = toJson()
|
final json = toJson()
|
||||||
..remove('local')
|
..remove('local')
|
||||||
..remove('stickers');
|
..remove('stickers')
|
||||||
|
..remove('size');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...json,
|
...json,
|
||||||
|
|||||||
@@ -5,13 +5,27 @@ import 'package:moxxmpp/moxxmpp.dart';
|
|||||||
part 'xmpp_state.freezed.dart';
|
part 'xmpp_state.freezed.dart';
|
||||||
part 'xmpp_state.g.dart';
|
part 'xmpp_state.g.dart';
|
||||||
|
|
||||||
|
extension StreamManagementStateToJson on StreamManagementState {
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'c2s': c2s,
|
||||||
|
's2c': s2c,
|
||||||
|
'streamResumptionLocation': streamResumptionLocation,
|
||||||
|
'streamResumptionId': streamResumptionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
class StreamManagementStateConverter
|
class StreamManagementStateConverter
|
||||||
implements JsonConverter<StreamManagementState, Map<String, dynamic>> {
|
implements JsonConverter<StreamManagementState, Map<String, dynamic>> {
|
||||||
const StreamManagementStateConverter();
|
const StreamManagementStateConverter();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
StreamManagementState fromJson(Map<String, dynamic> json) =>
|
StreamManagementState fromJson(Map<String, dynamic> json) =>
|
||||||
StreamManagementState.fromJson(json);
|
StreamManagementState(
|
||||||
|
json['c2s']! as int,
|
||||||
|
json['s2c']! as int,
|
||||||
|
streamResumptionLocation: json['streamResumptionLocation'] as String?,
|
||||||
|
streamResumptionId: json['streamResumptionId'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson(StreamManagementState state) => state.toJson();
|
Map<String, dynamic> toJson(StreamManagementState state) => state.toJson();
|
||||||
@@ -27,6 +41,7 @@ class XmppState with _$XmppState {
|
|||||||
String? displayName,
|
String? displayName,
|
||||||
String? password,
|
String? password,
|
||||||
String? lastRosterVersion,
|
String? lastRosterVersion,
|
||||||
|
String? fastToken,
|
||||||
@Default('') String avatarUrl,
|
@Default('') String avatarUrl,
|
||||||
@Default('') String avatarHash,
|
@Default('') String avatarHash,
|
||||||
@Default(false) bool askedStoragePermission,
|
@Default(false) bool askedStoragePermission,
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
part of 'addcontact_bloc.dart';
|
|
||||||
|
|
||||||
@freezed
|
|
||||||
class AddContactState with _$AddContactState {
|
|
||||||
factory AddContactState({
|
|
||||||
@Default('') String jid,
|
|
||||||
@Default(null) String? jidError,
|
|
||||||
@Default(false) bool isWorking,
|
|
||||||
}) = _AddContactState;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:collection/collection.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:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
@@ -11,6 +11,7 @@ import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
|||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
|
||||||
|
|
||||||
part 'conversation_bloc.freezed.dart';
|
part 'conversation_bloc.freezed.dart';
|
||||||
part 'conversation_event.dart';
|
part 'conversation_event.dart';
|
||||||
@@ -47,8 +48,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
) async {
|
) async {
|
||||||
final cb = GetIt.I.get<ConversationsBloc>();
|
final cb = GetIt.I.get<ConversationsBloc>();
|
||||||
await cb.waitUntilInitialized();
|
await cb.waitUntilInitialized();
|
||||||
final conversation = firstWhereOrNull(
|
final conversation = cb.state.conversations.firstWhereOrNull(
|
||||||
cb.state.conversations,
|
|
||||||
(Conversation c) => c.jid == event.jid,
|
(Conversation c) => c.jid == event.jid,
|
||||||
)!;
|
)!;
|
||||||
emit(
|
emit(
|
||||||
@@ -60,18 +60,22 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final arguments = ConversationPageArguments(
|
||||||
|
event.jid,
|
||||||
|
event.initialText,
|
||||||
|
);
|
||||||
final navEvent = event.removeUntilConversations
|
final navEvent = event.removeUntilConversations
|
||||||
? (PushedNamedAndRemoveUntilEvent(
|
? (PushedNamedAndRemoveUntilEvent(
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
conversationRoute,
|
conversationRoute,
|
||||||
arguments: event.jid,
|
arguments: arguments,
|
||||||
),
|
),
|
||||||
ModalRoute.withName(conversationsRoute),
|
ModalRoute.withName(conversationsRoute),
|
||||||
))
|
))
|
||||||
: (PushedNamedEvent(
|
: (PushedNamedEvent(
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
conversationRoute,
|
conversationRoute,
|
||||||
arguments: event.jid,
|
arguments: arguments,
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -102,7 +106,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
conversation: state.conversation!.copyWith(
|
conversation: state.conversation!.copyWith(
|
||||||
inRoster: true,
|
showAddToRoster: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,12 +22,17 @@ class RequestedConversationEvent extends ConversationEvent {
|
|||||||
this.title,
|
this.title,
|
||||||
this.avatarUrl, {
|
this.avatarUrl, {
|
||||||
this.removeUntilConversations = false,
|
this.removeUntilConversations = false,
|
||||||
|
this.initialText,
|
||||||
});
|
});
|
||||||
// These are placeholders in case we have to wait a bit longer
|
|
||||||
|
/// These are placeholders in case we have to wait a bit longer
|
||||||
final String jid;
|
final String jid;
|
||||||
final String title;
|
final String title;
|
||||||
final String avatarUrl;
|
final String avatarUrl;
|
||||||
final bool removeUntilConversations;
|
final bool removeUntilConversations;
|
||||||
|
|
||||||
|
/// Initial value to put in the input field.
|
||||||
|
final String? initialText;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Triggered by the UI when a user should be blocked
|
/// Triggered by the UI when a user should be blocked
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
|||||||
on<AvatarChangedEvent>(_onAvatarChanged);
|
on<AvatarChangedEvent>(_onAvatarChanged);
|
||||||
on<ConversationClosedEvent>(_onConversationClosed);
|
on<ConversationClosedEvent>(_onConversationClosed);
|
||||||
on<ConversationMarkedAsReadEvent>(_onConversationMarkedAsRead);
|
on<ConversationMarkedAsReadEvent>(_onConversationMarkedAsRead);
|
||||||
|
on<ConversationsSetEvent>(_onConversationsSet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(Unknown): This pattern is used so often that it should become its own thing in moxlib
|
// TODO(Unknown): This pattern is used so often that it should become its own thing in moxlib
|
||||||
@@ -53,7 +54,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
|||||||
state.copyWith(
|
state.copyWith(
|
||||||
displayName: event.displayName,
|
displayName: event.displayName,
|
||||||
jid: event.jid,
|
jid: event.jid,
|
||||||
avatarUrl: event.avatarUrl ?? '',
|
avatarPath: event.avatarUrl ?? '',
|
||||||
conversations: event.conversations..sort(compareConversation),
|
conversations: event.conversations..sort(compareConversation),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -118,7 +119,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
|||||||
) async {
|
) async {
|
||||||
return emit(
|
return emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
avatarUrl: event.path,
|
avatarPath: event.path,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -154,4 +155,15 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
|||||||
Conversation? getConversationByJid(String jid) {
|
Conversation? getConversationByJid(String jid) {
|
||||||
return state.conversations.firstWhereOrNull((c) => c.jid == jid);
|
return state.conversations.firstWhereOrNull((c) => c.jid == jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onConversationsSet(
|
||||||
|
ConversationsSetEvent event,
|
||||||
|
Emitter<ConversationsState> emit,
|
||||||
|
) async {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
conversations: event.conversations,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,3 +46,10 @@ class ConversationMarkedAsReadEvent extends ConversationsEvent {
|
|||||||
ConversationMarkedAsReadEvent(this.jid);
|
ConversationMarkedAsReadEvent(this.jid);
|
||||||
final String jid;
|
final String jid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Triggered by the UI when we received a fresh list of conversations, for example
|
||||||
|
/// after removing old media files.
|
||||||
|
class ConversationsSetEvent extends ConversationsEvent {
|
||||||
|
ConversationsSetEvent(this.conversations);
|
||||||
|
final List<Conversation> conversations;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class ConversationsState with _$ConversationsState {
|
|||||||
factory ConversationsState({
|
factory ConversationsState({
|
||||||
@Default(<Conversation>[]) List<Conversation> conversations,
|
@Default(<Conversation>[]) List<Conversation> conversations,
|
||||||
@Default('') String displayName,
|
@Default('') String displayName,
|
||||||
@Default('') String avatarUrl,
|
@Default('') String avatarPath,
|
||||||
@Default('') String jid,
|
@Default('') String jid,
|
||||||
}) = _ConversationsState;
|
}) = _ConversationsState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:collection/collection.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/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
@@ -43,10 +43,11 @@ class NewConversationBloc
|
|||||||
final conversations = GetIt.I.get<ConversationsBloc>();
|
final conversations = GetIt.I.get<ConversationsBloc>();
|
||||||
|
|
||||||
// Guard against an unneccessary roundtrip
|
// Guard against an unneccessary roundtrip
|
||||||
if (listContains(
|
final listContains = conversations.state.conversations.firstWhereOrNull(
|
||||||
conversations.state.conversations,
|
|
||||||
(Conversation c) => c.jid == event.jid,
|
(Conversation c) => c.jid == event.jid,
|
||||||
)) {
|
) !=
|
||||||
|
null;
|
||||||
|
if (listContains) {
|
||||||
GetIt.I.get<conversation.ConversationBloc>().add(
|
GetIt.I.get<conversation.ConversationBloc>().add(
|
||||||
conversation.RequestedConversationEvent(
|
conversation.RequestedConversationEvent(
|
||||||
event.jid,
|
event.jid,
|
||||||
@@ -120,8 +121,7 @@ class NewConversationBloc
|
|||||||
if (event.removed.contains(item.jid)) continue;
|
if (event.removed.contains(item.jid)) continue;
|
||||||
|
|
||||||
// Handle modified items
|
// Handle modified items
|
||||||
final modified = firstWhereOrNull(
|
final modified = event.modified.firstWhereOrNull(
|
||||||
event.modified,
|
|
||||||
(RosterItem i) => i.id == item.id,
|
(RosterItem i) => i.id == item.id,
|
||||||
);
|
);
|
||||||
if (modified != null) {
|
if (modified != null) {
|
||||||
|
|||||||
@@ -87,17 +87,6 @@ class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
|
|||||||
RecreateSessionsCommand(jid: GetIt.I.get<UIDataService>().ownJid!),
|
RecreateSessionsCommand(jid: GetIt.I.get<UIDataService>().ownJid!),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
);
|
);
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
keys: List.from(
|
|
||||||
state.keys.map(
|
|
||||||
(key) => key.copyWith(
|
|
||||||
hasSessionWith: false,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,16 +90,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
|||||||
SetSubscriptionStateEvent event,
|
SetSubscriptionStateEvent event,
|
||||||
Emitter<ProfileState> emit,
|
Emitter<ProfileState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
conversation: state.conversation!.copyWith(
|
|
||||||
// NOTE: This is wrong, but we just keep it like this until the real result comes
|
|
||||||
// in.
|
|
||||||
subscription: event.shareStatus ? 'to' : 'from',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
SetShareOnlineStatusCommand(jid: event.jid, share: event.shareStatus),
|
SetShareOnlineStatusCommand(jid: event.jid, share: event.shareStatus),
|
||||||
awaitable: false,
|
awaitable: false,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:moxplatform/moxplatform.dart';
|
|||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
|
|
||||||
part 'sendfiles_bloc.freezed.dart';
|
part 'sendfiles_bloc.freezed.dart';
|
||||||
part 'sendfiles_event.dart';
|
part 'sendfiles_event.dart';
|
||||||
@@ -24,10 +25,9 @@ class SendFilesBloc extends Bloc<SendFilesEvent, SendFilesState> {
|
|||||||
/// Pick files. Returns either a list of paths to attach or null if the process has
|
/// Pick files. Returns either a list of paths to attach or null if the process has
|
||||||
/// been cancelled.
|
/// been cancelled.
|
||||||
Future<List<String>?> _pickFiles(SendFilesType type) async {
|
Future<List<String>?> _pickFiles(SendFilesType type) async {
|
||||||
final fileType =
|
final result = await safePickFiles(
|
||||||
type == SendFilesType.image ? FileType.image : FileType.any;
|
type == SendFilesType.image ? FileType.image : FileType.any,
|
||||||
final result = await FilePicker.platform
|
);
|
||||||
.pickFiles(type: fileType, allowMultiple: true);
|
|
||||||
|
|
||||||
if (result == null) return null;
|
if (result == null) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -28,9 +28,11 @@ enum ShareSelectionType {
|
|||||||
class ShareListItem {
|
class ShareListItem {
|
||||||
const ShareListItem(
|
const ShareListItem(
|
||||||
this.avatarPath,
|
this.avatarPath,
|
||||||
|
this.avatarHash,
|
||||||
this.jid,
|
this.jid,
|
||||||
this.title,
|
this.title,
|
||||||
this.isConversation,
|
this.isConversation,
|
||||||
|
this.conversationType,
|
||||||
this.isEncrypted,
|
this.isEncrypted,
|
||||||
this.pseudoRosterItem,
|
this.pseudoRosterItem,
|
||||||
this.contactId,
|
this.contactId,
|
||||||
@@ -38,9 +40,11 @@ class ShareListItem {
|
|||||||
this.contactDisplayName,
|
this.contactDisplayName,
|
||||||
);
|
);
|
||||||
final String avatarPath;
|
final String avatarPath;
|
||||||
|
final String? avatarHash;
|
||||||
final String jid;
|
final String jid;
|
||||||
final String title;
|
final String title;
|
||||||
final bool isConversation;
|
final bool isConversation;
|
||||||
|
final ConversationType? conversationType;
|
||||||
final bool isEncrypted;
|
final bool isEncrypted;
|
||||||
final bool pseudoRosterItem;
|
final bool pseudoRosterItem;
|
||||||
final String? contactId;
|
final String? contactId;
|
||||||
@@ -79,10 +83,12 @@ class ShareSelectionBloc
|
|||||||
final items = List<ShareListItem>.from(
|
final items = List<ShareListItem>.from(
|
||||||
conversations.map((c) {
|
conversations.map((c) {
|
||||||
return ShareListItem(
|
return ShareListItem(
|
||||||
c.avatarUrl,
|
c.avatarPath,
|
||||||
|
c.avatarHash,
|
||||||
c.jid,
|
c.jid,
|
||||||
c.title,
|
c.title,
|
||||||
true,
|
true,
|
||||||
|
c.type,
|
||||||
c.encrypted,
|
c.encrypted,
|
||||||
false,
|
false,
|
||||||
c.contactId,
|
c.contactId,
|
||||||
@@ -100,10 +106,12 @@ class ShareSelectionBloc
|
|||||||
if (index == -1) {
|
if (index == -1) {
|
||||||
items.add(
|
items.add(
|
||||||
ShareListItem(
|
ShareListItem(
|
||||||
rosterItem.avatarUrl,
|
rosterItem.avatarPath,
|
||||||
|
rosterItem.avatarHash,
|
||||||
rosterItem.jid,
|
rosterItem.jid,
|
||||||
rosterItem.title,
|
rosterItem.title,
|
||||||
false,
|
false,
|
||||||
|
null,
|
||||||
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
||||||
rosterItem.pseudoRosterItem,
|
rosterItem.pseudoRosterItem,
|
||||||
rosterItem.contactId,
|
rosterItem.contactId,
|
||||||
@@ -113,10 +121,12 @@ class ShareSelectionBloc
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
items[index] = ShareListItem(
|
items[index] = ShareListItem(
|
||||||
rosterItem.avatarUrl,
|
rosterItem.avatarPath,
|
||||||
|
rosterItem.avatarHash,
|
||||||
rosterItem.jid,
|
rosterItem.jid,
|
||||||
rosterItem.title,
|
rosterItem.title,
|
||||||
false,
|
false,
|
||||||
|
null,
|
||||||
items[index].isEncrypted,
|
items[index].isEncrypted,
|
||||||
items[index].pseudoRosterItem,
|
items[index].pseudoRosterItem,
|
||||||
items[index].contactId,
|
items[index].contactId,
|
||||||
@@ -187,7 +197,7 @@ class ShareSelectionBloc
|
|||||||
SendMessageCommand(
|
SendMessageCommand(
|
||||||
recipients: _getRecipients(),
|
recipients: _getRecipients(),
|
||||||
body: state.text!,
|
body: state.text!,
|
||||||
chatState: chatStateToString(ChatState.gone),
|
chatState: ChatState.gone.toName(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ 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:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.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';
|
||||||
|
|
||||||
part 'addcontact_bloc.freezed.dart';
|
part 'startchat_bloc.freezed.dart';
|
||||||
part 'addcontact_event.dart';
|
part 'startchat_event.dart';
|
||||||
part 'addcontact_state.dart';
|
part 'startchat_state.dart';
|
||||||
|
|
||||||
class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
class StartChatBloc extends Bloc<StartChatEvent, StartChatState> {
|
||||||
AddContactBloc() : super(AddContactState()) {
|
StartChatBloc() : super(StartChatState()) {
|
||||||
on<AddedContactEvent>(_onContactAdded);
|
on<AddedContactEvent>(_onContactAdded);
|
||||||
on<JidChangedEvent>(_onJidChanged);
|
on<JidChangedEvent>(_onJidChanged);
|
||||||
on<PageResetEvent>(_onPageReset);
|
on<PageResetEvent>(_onPageReset);
|
||||||
@@ -21,7 +23,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
|
|
||||||
Future<void> _onContactAdded(
|
Future<void> _onContactAdded(
|
||||||
AddedContactEvent event,
|
AddedContactEvent event,
|
||||||
Emitter<AddContactState> emit,
|
Emitter<StartChatState> emit,
|
||||||
) async {
|
) async {
|
||||||
final validation = validateJidString(state.jid);
|
final validation = validateJidString(state.jid);
|
||||||
if (validation != null) {
|
if (validation != null) {
|
||||||
@@ -41,31 +43,54 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
AddContactCommand(
|
AddContactCommand(
|
||||||
jid: state.jid,
|
jid: state.jid,
|
||||||
),
|
),
|
||||||
) as AddContactResultEvent;
|
);
|
||||||
|
|
||||||
|
if (result is ErrorEvent) {
|
||||||
|
final error = result.errorId == ErrorType.remoteServerNotFound.value ||
|
||||||
|
result.errorId == ErrorType.remoteServerTimeout.value
|
||||||
|
? t.errors.newChat.remoteServerError
|
||||||
|
: t.errors.newChat.unknown;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
jidError: error,
|
||||||
|
isWorking: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else if (result is JidIsGroupchatEvent) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
jidError: t.errors.newChat.groupchatUnsupported,
|
||||||
|
isWorking: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _onPageReset(PageResetEvent(), emit);
|
await _onPageReset(PageResetEvent(), emit);
|
||||||
|
|
||||||
if (result.conversation != null) {
|
final addResult = result! as AddContactResultEvent;
|
||||||
if (result.added) {
|
if (addResult.conversation != null) {
|
||||||
|
if (addResult.added) {
|
||||||
GetIt.I.get<ConversationsBloc>().add(
|
GetIt.I.get<ConversationsBloc>().add(
|
||||||
ConversationsAddedEvent(result.conversation!),
|
ConversationsAddedEvent(addResult.conversation!),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
GetIt.I.get<ConversationsBloc>().add(
|
GetIt.I.get<ConversationsBloc>().add(
|
||||||
ConversationsUpdatedEvent(result.conversation!),
|
ConversationsUpdatedEvent(addResult.conversation!),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
result.conversation != null,
|
addResult.conversation != null,
|
||||||
'RequestedConversationEvent must contain a not null conversation',
|
'RequestedConversationEvent must contain a not null conversation',
|
||||||
);
|
);
|
||||||
GetIt.I.get<ConversationBloc>().add(
|
GetIt.I.get<ConversationBloc>().add(
|
||||||
RequestedConversationEvent(
|
RequestedConversationEvent(
|
||||||
result.conversation!.jid,
|
addResult.conversation!.jid,
|
||||||
result.conversation!.title,
|
addResult.conversation!.title,
|
||||||
result.conversation!.avatarUrl,
|
addResult.conversation!.avatarPath,
|
||||||
removeUntilConversations: true,
|
removeUntilConversations: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -73,7 +98,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
|
|
||||||
Future<void> _onJidChanged(
|
Future<void> _onJidChanged(
|
||||||
JidChangedEvent event,
|
JidChangedEvent event,
|
||||||
Emitter<AddContactState> emit,
|
Emitter<StartChatState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -84,7 +109,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
|
|
||||||
Future<void> _onPageReset(
|
Future<void> _onPageReset(
|
||||||
PageResetEvent event,
|
PageResetEvent event,
|
||||||
Emitter<AddContactState> emit,
|
Emitter<StartChatState> emit,
|
||||||
) async {
|
) async {
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
part of 'addcontact_bloc.dart';
|
part of 'startchat_bloc.dart';
|
||||||
|
|
||||||
abstract class AddContactEvent {}
|
abstract class StartChatEvent {}
|
||||||
|
|
||||||
/// Triggered when a new contact has been added by the UI
|
/// Triggered when a new contact has been added by the UI
|
||||||
class AddedContactEvent extends AddContactEvent {}
|
class AddedContactEvent extends StartChatEvent {}
|
||||||
|
|
||||||
/// Triggered by the UI when the JID input field is changed
|
/// Triggered by the UI when the JID input field is changed
|
||||||
class JidChangedEvent extends AddContactEvent {
|
class JidChangedEvent extends StartChatEvent {
|
||||||
JidChangedEvent(this.jid);
|
JidChangedEvent(this.jid);
|
||||||
final String jid;
|
final String jid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Triggered when the UI wants to reset its state
|
/// Triggered when the UI wants to reset its state
|
||||||
class PageResetEvent extends AddContactEvent {}
|
class PageResetEvent extends StartChatEvent {}
|
||||||
10
lib/ui/bloc/startchat_state.dart
Normal file
10
lib/ui/bloc/startchat_state.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
part of 'startchat_bloc.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class StartChatState with _$StartChatState {
|
||||||
|
factory StartChatState({
|
||||||
|
@Default('') String jid,
|
||||||
|
@Default(null) String? jidError,
|
||||||
|
@Default(false) bool isWorking,
|
||||||
|
}) = _StartChatState;
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import 'package:bloc/bloc.dart';
|
|||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
@@ -44,15 +43,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Apply
|
// Apply
|
||||||
final stickerPack = firstWhereOrNull(
|
final stickerPackResult =
|
||||||
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
|
// ignore: cast_nullable_to_non_nullable
|
||||||
(StickerPack pack) => pack.id == event.stickerPackId,
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
GetStickerPackByIdCommand(
|
||||||
|
id: event.stickerPackId,
|
||||||
|
),
|
||||||
|
) as GetStickerPackByIdResult;
|
||||||
|
assert(
|
||||||
|
stickerPackResult.stickerPack != null,
|
||||||
|
'The sticker pack must be found',
|
||||||
);
|
);
|
||||||
assert(stickerPack != null, 'The sticker pack must be found');
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
isWorking: false,
|
isWorking: false,
|
||||||
stickerPack: stickerPack,
|
stickerPack: stickerPackResult.stickerPack,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -149,21 +155,13 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result is StickerPackInstallSuccessEvent) {
|
|
||||||
GetIt.I.get<stickers.StickersBloc>().add(
|
|
||||||
stickers.StickerPackAddedEvent(result.stickerPack),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Leave the page
|
|
||||||
GetIt.I.get<NavigationBloc>().add(
|
|
||||||
PoppedRouteEvent(),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Leave the page
|
// Leave the page
|
||||||
GetIt.I.get<NavigationBloc>().add(
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
PoppedRouteEvent(),
|
PoppedRouteEvent(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Notify on failure
|
||||||
|
if (result is! StickerPackInstallSuccessEvent) {
|
||||||
await Fluttertoast.showToast(
|
await Fluttertoast.showToast(
|
||||||
msg: t.pages.stickerPack.fetchingFailure,
|
msg: t.pages.stickerPack.fetchingFailure,
|
||||||
gravity: ToastGravity.SNACKBAR,
|
gravity: ToastGravity.SNACKBAR,
|
||||||
@@ -176,13 +174,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
|||||||
StickerPackRequested event,
|
StickerPackRequested event,
|
||||||
Emitter<StickerPackState> emit,
|
Emitter<StickerPackState> emit,
|
||||||
) async {
|
) async {
|
||||||
// Find out if the sticker pack is locally available or not
|
emit(
|
||||||
final stickerPack = firstWhereOrNull(
|
state.copyWith(
|
||||||
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
|
isWorking: true,
|
||||||
(StickerPack pack) => pack.id == event.stickerPackId,
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (stickerPack == null) {
|
final stickerPackResult =
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
GetStickerPackByIdCommand(
|
||||||
|
id: event.stickerPackId,
|
||||||
|
),
|
||||||
|
) as GetStickerPackByIdResult;
|
||||||
|
|
||||||
|
// Find out if the sticker pack is locally available or not
|
||||||
|
if (stickerPackResult.stickerPack == null) {
|
||||||
await _onRemoteStickerPackRequested(
|
await _onRemoteStickerPackRequested(
|
||||||
RemoteStickerPackRequested(
|
RemoteStickerPackRequested(
|
||||||
event.stickerPackId,
|
event.stickerPackId,
|
||||||
@@ -191,9 +198,11 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
|||||||
emit,
|
emit,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await _onLocalStickerPackRequested(
|
emit(
|
||||||
LocallyAvailableStickerPackRequested(event.stickerPackId),
|
state.copyWith(
|
||||||
emit,
|
isWorking: false,
|
||||||
|
stickerPack: stickerPackResult.stickerPack,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/painting.dart';
|
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
import 'package:moxxyv2/ui/controller/sticker_pack_controller.dart';
|
||||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
|
|
||||||
part 'stickers_bloc.freezed.dart';
|
part 'stickers_bloc.freezed.dart';
|
||||||
part 'stickers_event.dart';
|
part 'stickers_event.dart';
|
||||||
@@ -19,60 +16,20 @@ part 'stickers_state.dart';
|
|||||||
|
|
||||||
class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||||
StickersBloc() : super(StickersState()) {
|
StickersBloc() : super(StickersState()) {
|
||||||
on<StickersSetEvent>(_onStickersSet);
|
|
||||||
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
|
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
|
||||||
on<StickerPackImportedEvent>(_onStickerPackImported);
|
on<StickerPackImportedEvent>(_onStickerPackImported);
|
||||||
on<StickerPackAddedEvent>(_onStickerPackAdded);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onStickersSet(
|
|
||||||
StickersSetEvent event,
|
|
||||||
Emitter<StickersState> emit,
|
|
||||||
) async {
|
|
||||||
// Also store a mapping of (pack Id, sticker Id) -> Sticker to allow fast lookup
|
|
||||||
// of the sticker in the UI.
|
|
||||||
final map = <StickerKey, Sticker>{};
|
|
||||||
for (final pack in event.stickerPacks) {
|
|
||||||
for (final sticker in pack.stickers) {
|
|
||||||
if (!sticker.isImage) continue;
|
|
||||||
|
|
||||||
map[StickerKey(pack.id, sticker.id)] = sticker;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
stickerPacks: event.stickerPacks,
|
|
||||||
stickerMap: map,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStickerPackRemoved(
|
Future<void> _onStickerPackRemoved(
|
||||||
StickerPackRemovedEvent event,
|
StickerPackRemovedEvent event,
|
||||||
Emitter<StickersState> emit,
|
Emitter<StickersState> emit,
|
||||||
) async {
|
) async {
|
||||||
final stickerPack = firstWhereOrNull(
|
// Remove from the UI
|
||||||
state.stickerPacks,
|
BidirectionalStickerPackController.instance?.removeItem(
|
||||||
(StickerPack sp) => sp.id == event.stickerPackId,
|
(stickerPack) => stickerPack.id == event.stickerPackId,
|
||||||
)!;
|
|
||||||
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
|
||||||
for (final sticker in stickerPack.stickers) {
|
|
||||||
sm.remove(StickerKey(stickerPack.id, sticker.id));
|
|
||||||
|
|
||||||
// Evict stickers from the cache
|
|
||||||
unawaited(FileImage(File(sticker.fileMetadata.path!)).evict());
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
stickerPacks: List.from(
|
|
||||||
state.stickerPacks.where((sp) => sp.id != event.stickerPackId),
|
|
||||||
),
|
|
||||||
stickerMap: sm,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Notify the backend
|
||||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
RemoveStickerPackCommand(
|
RemoveStickerPackCommand(
|
||||||
stickerPackId: event.stickerPackId,
|
stickerPackId: event.stickerPackId,
|
||||||
@@ -85,8 +42,11 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
|||||||
StickerPackImportedEvent event,
|
StickerPackImportedEvent event,
|
||||||
Emitter<StickersState> emit,
|
Emitter<StickersState> emit,
|
||||||
) async {
|
) async {
|
||||||
final file = await FilePicker.platform.pickFiles();
|
final pickerResult = await safePickFiles(
|
||||||
if (file == null) return;
|
FileType.any,
|
||||||
|
allowMultiple: false,
|
||||||
|
);
|
||||||
|
if (pickerResult == null) return;
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -96,40 +56,23 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
|||||||
|
|
||||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
ImportStickerPackCommand(
|
ImportStickerPackCommand(
|
||||||
path: file.files.single.path!,
|
path: pickerResult.files.single.path!,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result is StickerPackImportSuccessEvent) {
|
|
||||||
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
|
||||||
for (final sticker in result.stickerPack.stickers) {
|
|
||||||
if (!sticker.isImage) continue;
|
|
||||||
|
|
||||||
sm[StickerKey(result.stickerPack.id, sticker.id)] = sticker;
|
|
||||||
}
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
stickerPacks: List<StickerPack>.from([
|
|
||||||
...state.stickerPacks,
|
|
||||||
result.stickerPack,
|
|
||||||
]),
|
|
||||||
stickerMap: sm,
|
|
||||||
isImportRunning: false,
|
isImportRunning: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (result is StickerPackImportSuccessEvent) {
|
||||||
await Fluttertoast.showToast(
|
await Fluttertoast.showToast(
|
||||||
msg: t.pages.settings.stickers.importSuccess,
|
msg: t.pages.settings.stickers.importSuccess,
|
||||||
gravity: ToastGravity.SNACKBAR,
|
gravity: ToastGravity.SNACKBAR,
|
||||||
toastLength: Toast.LENGTH_SHORT,
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
isImportRunning: false,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await Fluttertoast.showToast(
|
await Fluttertoast.showToast(
|
||||||
msg: t.pages.settings.stickers.importFailure,
|
msg: t.pages.settings.stickers.importFailure,
|
||||||
gravity: ToastGravity.SNACKBAR,
|
gravity: ToastGravity.SNACKBAR,
|
||||||
@@ -137,26 +80,4 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStickerPackAdded(
|
|
||||||
StickerPackAddedEvent event,
|
|
||||||
Emitter<StickersState> emit,
|
|
||||||
) async {
|
|
||||||
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
|
||||||
for (final sticker in event.stickerPack.stickers) {
|
|
||||||
if (!sticker.isImage) continue;
|
|
||||||
|
|
||||||
sm[StickerKey(event.stickerPack.id, sticker.id)] = sticker;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(
|
|
||||||
state.copyWith(
|
|
||||||
stickerPacks: List<StickerPack>.from([
|
|
||||||
...state.stickerPacks,
|
|
||||||
event.stickerPack,
|
|
||||||
]),
|
|
||||||
stickerMap: sm,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,6 @@ part of 'stickers_bloc.dart';
|
|||||||
|
|
||||||
abstract class StickersEvent {}
|
abstract class StickersEvent {}
|
||||||
|
|
||||||
class StickersSetEvent extends StickersEvent {
|
|
||||||
StickersSetEvent(
|
|
||||||
this.stickerPacks,
|
|
||||||
);
|
|
||||||
final List<StickerPack> stickerPacks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Triggered by the UI when a sticker pack has been removed
|
/// Triggered by the UI when a sticker pack has been removed
|
||||||
class StickerPackRemovedEvent extends StickersEvent {
|
class StickerPackRemovedEvent extends StickersEvent {
|
||||||
StickerPackRemovedEvent(this.stickerPackId);
|
StickerPackRemovedEvent(this.stickerPackId);
|
||||||
@@ -17,9 +10,3 @@ class StickerPackRemovedEvent extends StickersEvent {
|
|||||||
|
|
||||||
/// Triggered by the UI when a sticker pack has been imported
|
/// Triggered by the UI when a sticker pack has been imported
|
||||||
class StickerPackImportedEvent extends StickersEvent {}
|
class StickerPackImportedEvent extends StickersEvent {}
|
||||||
|
|
||||||
/// Triggered by the UI when a sticker pack has been imported
|
|
||||||
class StickerPackAddedEvent extends StickersEvent {
|
|
||||||
StickerPackAddedEvent(this.stickerPack);
|
|
||||||
final StickerPack stickerPack;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ class StickerKey {
|
|||||||
@freezed
|
@freezed
|
||||||
class StickersState with _$StickersState {
|
class StickersState with _$StickersState {
|
||||||
factory StickersState({
|
factory StickersState({
|
||||||
@Default([]) List<StickerPack> stickerPacks,
|
|
||||||
@Default({}) Map<StickerKey, Sticker> stickerMap,
|
|
||||||
@Default(false) bool isImportRunning,
|
@Default(false) bool isImportRunning,
|
||||||
}) = _StickersState;
|
}) = _StickersState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,9 @@ const Color reactionColorSent = Color(0xff2993FB);
|
|||||||
/// The color of the skim when a message is highlighted.
|
/// The color of the skim when a message is highlighted.
|
||||||
const Color highlightSkimColor = Color(0xff000000);
|
const Color highlightSkimColor = Color(0xff000000);
|
||||||
|
|
||||||
|
/// The width of the bar used to indicate a legacy quote.
|
||||||
|
const double textMessageQuoteBarWidth = 3;
|
||||||
|
|
||||||
/// Navigation constants
|
/// Navigation constants
|
||||||
const String cropRoute = '/crop';
|
const String cropRoute = '/crop';
|
||||||
const String introRoute = '/intro';
|
const String introRoute = '/intro';
|
||||||
@@ -157,6 +160,9 @@ const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
|
|||||||
const String conversationSettingsRoute = '$settingsRoute/conversation';
|
const String conversationSettingsRoute = '$settingsRoute/conversation';
|
||||||
const String appearanceRoute = '$settingsRoute/appearance';
|
const String appearanceRoute = '$settingsRoute/appearance';
|
||||||
const String stickersRoute = '$settingsRoute/stickers';
|
const String stickersRoute = '$settingsRoute/stickers';
|
||||||
|
const String stickerPacksRoute = '$settingsRoute/stickers/sticker_packs';
|
||||||
|
const String storageSettingsRoute = '$settingsRoute/storage';
|
||||||
|
const String storageSharedMediaSettingsRoute = '$settingsRoute/storage/media';
|
||||||
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';
|
||||||
|
|||||||
@@ -98,11 +98,7 @@ class BidirectionalController<T> {
|
|||||||
hasOlderData = data.length >= pageSize;
|
hasOlderData = data.length >= pageSize;
|
||||||
|
|
||||||
// Don't trigger an update if we fetched nothing
|
// Don't trigger an update if we fetched nothing
|
||||||
if (data.isEmpty) {
|
|
||||||
_setIsFetching(false);
|
_setIsFetching(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_cache.insertAll(0, data);
|
_cache.insertAll(0, data);
|
||||||
|
|
||||||
// Evict items from the cache if we overstep the maximum
|
// Evict items from the cache if we overstep the maximum
|
||||||
@@ -217,6 +213,22 @@ class BidirectionalController<T> {
|
|||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Removes the first item for which [test] returns true.
|
||||||
|
void removeItem(bool Function(T) test) {
|
||||||
|
var found = false;
|
||||||
|
for (var i = 0; i < _cache.length; i++) {
|
||||||
|
if (test(_cache[i])) {
|
||||||
|
_cache.removeAt(i);
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
_dataStreamController.add(_cache);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Animate to the bottom of the view.
|
/// Animate to the bottom of the view.
|
||||||
void animateToBottom() {
|
void animateToBottom() {
|
||||||
_controller.animateTo(
|
_controller.animateTo(
|
||||||
|
|||||||
@@ -68,8 +68,11 @@ class RecordingData {
|
|||||||
|
|
||||||
class BidirectionalConversationController
|
class BidirectionalConversationController
|
||||||
extends BidirectionalController<Message> {
|
extends BidirectionalController<Message> {
|
||||||
BidirectionalConversationController(this.conversationJid)
|
BidirectionalConversationController(
|
||||||
: assert(
|
this.conversationJid,
|
||||||
|
this.focusNode, {
|
||||||
|
String? initialText,
|
||||||
|
}) : assert(
|
||||||
BidirectionalConversationController.currentController == null,
|
BidirectionalConversationController.currentController == null,
|
||||||
'There can only be one BidirectionalConversationController',
|
'There can only be one BidirectionalConversationController',
|
||||||
),
|
),
|
||||||
@@ -78,6 +81,9 @@ class BidirectionalConversationController
|
|||||||
maxPageAmount: maxMessagePages,
|
maxPageAmount: maxMessagePages,
|
||||||
) {
|
) {
|
||||||
_textController.addListener(_handleTextChanged);
|
_textController.addListener(_handleTextChanged);
|
||||||
|
if (initialText != null) {
|
||||||
|
_textController.text = initialText;
|
||||||
|
}
|
||||||
|
|
||||||
BidirectionalConversationController.currentController = this;
|
BidirectionalConversationController.currentController = this;
|
||||||
|
|
||||||
@@ -95,6 +101,10 @@ class BidirectionalConversationController
|
|||||||
final TextEditingController _textController = TextEditingController();
|
final TextEditingController _textController = TextEditingController();
|
||||||
TextEditingController get textController => _textController;
|
TextEditingController get textController => _textController;
|
||||||
|
|
||||||
|
/// The focus node of the textfield used for message text input. Useful for
|
||||||
|
/// forcing focus after selecting a message for editing.
|
||||||
|
final FocusNode focusNode;
|
||||||
|
|
||||||
/// Stream for SendButtonState updates
|
/// Stream for SendButtonState updates
|
||||||
final StreamController<conversation.SendButtonState>
|
final StreamController<conversation.SendButtonState>
|
||||||
_sendButtonStreamController = StreamController();
|
_sendButtonStreamController = StreamController();
|
||||||
@@ -313,10 +323,6 @@ class BidirectionalConversationController
|
|||||||
assert(text.isNotEmpty, 'Cannot send empty text messages');
|
assert(text.isNotEmpty, 'Cannot send empty text messages');
|
||||||
_textController.text = '';
|
_textController.text = '';
|
||||||
|
|
||||||
// Reset the message editing state
|
|
||||||
final wasEditing = _messageEditingState != null;
|
|
||||||
_messageEditingState = null;
|
|
||||||
|
|
||||||
// Add message to the database and send it
|
// Add message to the database and send it
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
@@ -324,7 +330,7 @@ class BidirectionalConversationController
|
|||||||
recipients: [conversationJid],
|
recipients: [conversationJid],
|
||||||
body: text,
|
body: text,
|
||||||
quotedMessage: _quotedMessage,
|
quotedMessage: _quotedMessage,
|
||||||
chatState: chatStateToString(ChatState.active),
|
chatState: ChatState.active.toName(),
|
||||||
editId: _messageEditingState?.id,
|
editId: _messageEditingState?.id,
|
||||||
editSid: _messageEditingState?.sid,
|
editSid: _messageEditingState?.sid,
|
||||||
currentConversationJid: conversationJid,
|
currentConversationJid: conversationJid,
|
||||||
@@ -332,6 +338,10 @@ class BidirectionalConversationController
|
|||||||
awaitable: true,
|
awaitable: true,
|
||||||
) as MessageAddedEvent;
|
) as MessageAddedEvent;
|
||||||
|
|
||||||
|
// Reset the message editing state
|
||||||
|
final wasEditing = _messageEditingState != null;
|
||||||
|
_messageEditingState = null;
|
||||||
|
|
||||||
// Reset the quote
|
// Reset the quote
|
||||||
removeQuote();
|
removeQuote();
|
||||||
|
|
||||||
@@ -413,6 +423,8 @@ class BidirectionalConversationController
|
|||||||
int id,
|
int id,
|
||||||
String sid,
|
String sid,
|
||||||
) {
|
) {
|
||||||
|
_log.fine('Beginning editing for id: $id, sid: $sid');
|
||||||
|
|
||||||
_messageEditingState = MessageEditingState(
|
_messageEditingState = MessageEditingState(
|
||||||
id,
|
id,
|
||||||
sid,
|
sid,
|
||||||
@@ -426,6 +438,9 @@ class BidirectionalConversationController
|
|||||||
|
|
||||||
_sendButtonStreamController
|
_sendButtonStreamController
|
||||||
.add(conversation.SendButtonState.cancelCorrection);
|
.add(conversation.SendButtonState.cancelCorrection);
|
||||||
|
|
||||||
|
// Focus the textfield.
|
||||||
|
focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exit the "edit mode" for a message.
|
/// Exit the "edit mode" for a message.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class BidirectionalSharedMediaController
|
|||||||
static BidirectionalSharedMediaController? currentController;
|
static BidirectionalSharedMediaController? currentController;
|
||||||
|
|
||||||
/// The JID of the conversation we want to get shared media of.
|
/// The JID of the conversation we want to get shared media of.
|
||||||
final String conversationJid;
|
final String? conversationJid;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<Message>> fetchOlderDataImpl(
|
Future<List<Message>> fetchOlderDataImpl(
|
||||||
|
|||||||
66
lib/ui/controller/sticker_pack_controller.dart
Normal file
66
lib/ui/controller/sticker_pack_controller.dart
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||||
|
import 'package:moxxyv2/ui/controller/bidirectional_controller.dart';
|
||||||
|
|
||||||
|
class BidirectionalStickerPackController
|
||||||
|
extends BidirectionalController<StickerPack> {
|
||||||
|
BidirectionalStickerPackController(this.includeStickers)
|
||||||
|
: assert(
|
||||||
|
instance == null,
|
||||||
|
'There can only be one BidirectionalStickerPackController',
|
||||||
|
),
|
||||||
|
super(
|
||||||
|
pageSize: stickerPackPaginationSize,
|
||||||
|
maxPageAmount: maxStickerPackPages,
|
||||||
|
) {
|
||||||
|
instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A flag telling the UI to also include stickers in the sticker pack requests.
|
||||||
|
final bool includeStickers;
|
||||||
|
|
||||||
|
/// Singleton instance access.
|
||||||
|
static BidirectionalStickerPackController? instance;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<StickerPack>> fetchOlderDataImpl(
|
||||||
|
StickerPack? oldestElement,
|
||||||
|
) async {
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
GetPagedStickerPackCommand(
|
||||||
|
olderThan: true,
|
||||||
|
timestamp: oldestElement?.addedTimestamp,
|
||||||
|
includeStickers: includeStickers,
|
||||||
|
),
|
||||||
|
) as PagedStickerPackResult;
|
||||||
|
|
||||||
|
return result.stickerPacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<StickerPack>> fetchNewerDataImpl(
|
||||||
|
StickerPack? newestElement,
|
||||||
|
) async {
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
GetPagedStickerPackCommand(
|
||||||
|
olderThan: false,
|
||||||
|
timestamp: newestElement?.addedTimestamp,
|
||||||
|
includeStickers: includeStickers,
|
||||||
|
),
|
||||||
|
) as PagedStickerPackResult;
|
||||||
|
|
||||||
|
return result.stickerPacks;
|
||||||
|
}
|
||||||
|
}
|
||||||
85
lib/ui/controller/storage_controller.dart
Normal file
85
lib/ui/controller/storage_controller.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
|
||||||
|
class StorageState {
|
||||||
|
const StorageState(
|
||||||
|
this.mediaUsage,
|
||||||
|
this.stickersUsage,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The storage usage of sticker packs in bytes.
|
||||||
|
final int stickersUsage;
|
||||||
|
|
||||||
|
/// The storage usage of media files in bytes.
|
||||||
|
final int mediaUsage;
|
||||||
|
|
||||||
|
/// The total used storage.
|
||||||
|
int get totalUsage => stickersUsage + mediaUsage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A controller class for managing requesting the storage usage and handling changes
|
||||||
|
/// to the storage usage induced by UI actions.
|
||||||
|
class StorageController {
|
||||||
|
StorageController()
|
||||||
|
: assert(
|
||||||
|
instance == null,
|
||||||
|
'Only one instance of StorageController can exist',
|
||||||
|
) {
|
||||||
|
StorageController.instance = this;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ignore: prefer_final_fields
|
||||||
|
StorageState _state = const StorageState(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The stream controller.
|
||||||
|
final StreamController<StorageState> _controller =
|
||||||
|
StreamController<StorageState>.broadcast();
|
||||||
|
Stream<StorageState> get stream => _controller.stream;
|
||||||
|
|
||||||
|
/// Singleton instance
|
||||||
|
static StorageController? instance;
|
||||||
|
|
||||||
|
/// Fetches the total storage usage from the service and triggers an event on the
|
||||||
|
/// event stream.
|
||||||
|
Future<void> fetchStorageUsage() async {
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
GetStorageUsageCommand(),
|
||||||
|
) as GetStorageUsageEvent;
|
||||||
|
|
||||||
|
_state = StorageState(
|
||||||
|
result.mediaUsage,
|
||||||
|
result.stickerUsage,
|
||||||
|
);
|
||||||
|
_controller.add(_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the state by replacing the storage usage with [newUsage].
|
||||||
|
void mediaUsageUpdated(int newUsage) {
|
||||||
|
_state = StorageState(
|
||||||
|
newUsage,
|
||||||
|
_state.stickersUsage,
|
||||||
|
);
|
||||||
|
_controller.add(_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the state by subtracting [size] from the stickersUsage.
|
||||||
|
void stickerPackRemoved(int size) {
|
||||||
|
_state = StorageState(
|
||||||
|
_state.mediaUsage,
|
||||||
|
_state.stickersUsage - size,
|
||||||
|
);
|
||||||
|
_controller.add(_state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disposes of the singleton
|
||||||
|
void dispose() {
|
||||||
|
StorageController.instance = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/widgets.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:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||||
@@ -14,9 +14,9 @@ import 'package:moxxyv2/ui/bloc/conversation_bloc.dart' as conversation;
|
|||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
|
||||||
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
|
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
|
||||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
||||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
|
|
||||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||||
import 'package:moxxyv2/ui/prestart.dart';
|
import 'package:moxxyv2/ui/prestart.dart';
|
||||||
|
import 'package:moxxyv2/ui/service/avatars.dart';
|
||||||
import 'package:moxxyv2/ui/service/progress.dart';
|
import 'package:moxxyv2/ui/service/progress.dart';
|
||||||
|
|
||||||
void setupEventHandler() {
|
void setupEventHandler() {
|
||||||
@@ -33,7 +33,10 @@ void setupEventHandler() {
|
|||||||
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
|
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
|
||||||
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
|
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
|
||||||
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
|
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
|
||||||
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
|
EventTypeMatcher<StreamNegotiationsCompletedEvent>(
|
||||||
|
onStreamNegotiationsDone,
|
||||||
|
),
|
||||||
|
EventTypeMatcher<AvatarUpdatedEvent>(onAvatarUpdated),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@@ -173,16 +176,21 @@ Future<void> onNotificationTappend(
|
|||||||
conversation.RequestedConversationEvent(
|
conversation.RequestedConversationEvent(
|
||||||
event.conversationJid,
|
event.conversationJid,
|
||||||
event.title,
|
event.title,
|
||||||
event.avatarUrl,
|
event.avatarPath,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onStickerPackAdded(
|
Future<void> onStreamNegotiationsDone(
|
||||||
StickerPackAddedEvent event, {
|
StreamNegotiationsCompletedEvent event, {
|
||||||
dynamic extra,
|
dynamic extra,
|
||||||
}) async {
|
}) async {
|
||||||
GetIt.I.get<stickers.StickersBloc>().add(
|
if (!event.resumed) {
|
||||||
stickers.StickerPackAddedEvent(event.stickerPack),
|
GetIt.I.get<UIAvatarsService>().resetCache();
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onAvatarUpdated(
|
||||||
|
AvatarUpdatedEvent event, {
|
||||||
|
dynamic extra,
|
||||||
|
}) async {}
|
||||||
|
|||||||
@@ -18,17 +18,26 @@ import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
|||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||||
import 'package:moxxyv2/ui/redirects.dart';
|
import 'package:moxxyv2/ui/redirects.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:qr_flutter/qr_flutter.dart';
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
/// Shows a dialog asking the user if they are sure that they want to proceed with an
|
/// Shows a dialog asking the user if they are sure that they want to proceed with an
|
||||||
/// action. Resolves to true if the user pressed the confirm button. Returns false if
|
/// action. Resolves to true if the user pressed the confirm button. Returns false if
|
||||||
/// the cancel button was pressed.
|
/// the cancel button was pressed.
|
||||||
|
///
|
||||||
|
/// If [affirmativeText] is given, then its value is used for the "OK" button. If not,
|
||||||
|
/// the i18n-defined "yes" value will be used.
|
||||||
|
///
|
||||||
|
/// If [destructive] is set to true, then the affirmative button's text color will be
|
||||||
|
/// set to red. If set to false, the default text color is used.
|
||||||
Future<bool> showConfirmationDialog(
|
Future<bool> showConfirmationDialog(
|
||||||
String title,
|
String title,
|
||||||
String body,
|
String body,
|
||||||
BuildContext context,
|
BuildContext context, {
|
||||||
) async {
|
String? affirmativeText,
|
||||||
|
bool destructive = false,
|
||||||
|
}) async {
|
||||||
final result = await showDialog<bool>(
|
final result = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -41,7 +50,10 @@ Future<bool> showConfirmationDialog(
|
|||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: Text(t.global.yes),
|
child: Text(
|
||||||
|
affirmativeText ?? t.global.yes,
|
||||||
|
style: destructive ? const TextStyle(color: Colors.red) : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: Navigator.of(context).pop,
|
onPressed: Navigator.of(context).pop,
|
||||||
@@ -118,14 +130,49 @@ void dismissSoftKeyboard(BuildContext context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A wrapper around [FilePicker.platform.pickFiles] that first checks if we have the
|
||||||
|
/// appropriate permission. If not, tries to request the permission. If that failed,
|
||||||
|
/// show a toast to inform the user and return null.
|
||||||
|
///
|
||||||
|
/// [type] is the type of file to pick.
|
||||||
|
///
|
||||||
|
/// [allowMultiple] indicates whether the file picker should allow multiple files to be
|
||||||
|
/// selected. Defaults to true.
|
||||||
|
///
|
||||||
|
/// [withData] is equal to the withData parameter of [FilePicker.platform.pickFiles].
|
||||||
|
Future<FilePickerResult?> safePickFiles(
|
||||||
|
FileType type, {
|
||||||
|
bool allowMultiple = true,
|
||||||
|
bool withData = false,
|
||||||
|
}) async {
|
||||||
|
// If we have no storage permission, request it. If that also failed, show a toast
|
||||||
|
// telling the user that the storage permission is not available.
|
||||||
|
final status = await Permission.storage.status;
|
||||||
|
if (status.isDenied) {
|
||||||
|
final newStatus = await Permission.storage.request();
|
||||||
|
if (!newStatus.isGranted) {
|
||||||
|
await Fluttertoast.showToast(
|
||||||
|
msg: t.errors.filePicker.permissionDenied,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_LONG,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return FilePicker.platform.pickFiles(
|
||||||
|
type: type,
|
||||||
|
allowMultiple: allowMultiple,
|
||||||
|
withData: withData,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Open the file picker to pick an image and open the cropping tool.
|
/// Open the file picker to pick an image and open the cropping tool.
|
||||||
/// The Future either resolves to null if the user cancels the action or
|
/// The Future either resolves to null if the user cancels the action or
|
||||||
/// the actual image data.
|
/// the actual image data.
|
||||||
Future<Uint8List?> pickAndCropImage(BuildContext context) async {
|
Future<Uint8List?> pickAndCropImage(BuildContext context) async {
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final result =
|
||||||
type: FileType.image,
|
await safePickFiles(FileType.image, allowMultiple: false, withData: true);
|
||||||
withData: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
return GetIt.I
|
return GetIt.I
|
||||||
@@ -199,9 +246,15 @@ Color getTileColor(BuildContext context) {
|
|||||||
String localeCodeToLanguageName(String localeCode) {
|
String localeCodeToLanguageName(String localeCode) {
|
||||||
switch (localeCode) {
|
switch (localeCode) {
|
||||||
case 'de':
|
case 'de':
|
||||||
return 'Deutsch';
|
return AppLocale.de.build().language;
|
||||||
case 'en':
|
case 'en':
|
||||||
return 'English';
|
return AppLocale.en.build().language;
|
||||||
|
case 'nl':
|
||||||
|
return AppLocale.nl.build().language;
|
||||||
|
case 'ja':
|
||||||
|
return AppLocale.ja.build().language;
|
||||||
|
case 'ru':
|
||||||
|
return AppLocale.ru.build().language;
|
||||||
case 'default':
|
case 'default':
|
||||||
return t.pages.settings.appearance.systemLanguage;
|
return t.pages.settings.appearance.systemLanguage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,25 @@ import 'package:moxxyv2/ui/pages/conversation/selected_message.dart';
|
|||||||
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
|
import 'package:moxxyv2/ui/pages/conversation/topbar.dart';
|
||||||
import 'package:moxxyv2/ui/pages/conversation/typing_indicator.dart';
|
import 'package:moxxyv2/ui/pages/conversation/typing_indicator.dart';
|
||||||
import 'package:moxxyv2/ui/service/data.dart';
|
import 'package:moxxyv2/ui/service/data.dart';
|
||||||
|
import 'package:moxxyv2/ui/service/read.dart';
|
||||||
import 'package:moxxyv2/ui/theme.dart';
|
import 'package:moxxyv2/ui/theme.dart';
|
||||||
|
import 'package:moxxyv2/ui/widgets/chat/bubbles/bubbles.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/bubbles/date.dart';
|
import 'package:moxxyv2/ui/widgets/chat/bubbles/date.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/bubbles/new_device.dart';
|
|
||||||
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
|
import 'package:moxxyv2/ui/widgets/combined_picker.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/context_menu.dart';
|
import 'package:moxxyv2/ui/widgets/context_menu.dart';
|
||||||
|
|
||||||
|
class ConversationPageArguments {
|
||||||
|
const ConversationPageArguments(
|
||||||
|
this.conversationJid,
|
||||||
|
this.initialText,
|
||||||
|
);
|
||||||
|
|
||||||
|
final String conversationJid;
|
||||||
|
|
||||||
|
final String? initialText;
|
||||||
|
}
|
||||||
|
|
||||||
int getMessageMenuOptionCount(
|
int getMessageMenuOptionCount(
|
||||||
Message message,
|
Message message,
|
||||||
Message? lastMessage,
|
Message? lastMessage,
|
||||||
@@ -49,12 +61,16 @@ int getMessageMenuOptionCount(
|
|||||||
class ConversationPage extends StatefulWidget {
|
class ConversationPage extends StatefulWidget {
|
||||||
const ConversationPage({
|
const ConversationPage({
|
||||||
required this.conversationJid,
|
required this.conversationJid,
|
||||||
|
this.initialText,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The JID of the current conversation
|
/// The JID of the current conversation
|
||||||
final String conversationJid;
|
final String conversationJid;
|
||||||
|
|
||||||
|
/// The optional initial text to put in the input field.
|
||||||
|
final String? initialText;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConversationPageState createState() => ConversationPageState();
|
ConversationPageState createState() => ConversationPageState();
|
||||||
}
|
}
|
||||||
@@ -88,6 +104,8 @@ class ConversationPageState extends State<ConversationPage>
|
|||||||
// Setup message paging
|
// Setup message paging
|
||||||
_conversationController = BidirectionalConversationController(
|
_conversationController = BidirectionalConversationController(
|
||||||
widget.conversationJid,
|
widget.conversationJid,
|
||||||
|
_textfieldFocusNode,
|
||||||
|
initialText: widget.initialText,
|
||||||
);
|
);
|
||||||
_conversationController.fetchOlderData();
|
_conversationController.fetchOlderData();
|
||||||
|
|
||||||
@@ -232,10 +250,7 @@ class ConversationPageState extends State<ConversationPage>
|
|||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
),
|
),
|
||||||
child: NewDeviceBubble(
|
child: bubbleFromPseudoMessageType(context, item),
|
||||||
data: item.pseudoMessageData!,
|
|
||||||
title: state.conversation!.title,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -294,6 +309,9 @@ class ConversationPageState extends State<ConversationPage>
|
|||||||
sentBySelf,
|
sentBySelf,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Dismiss the soft-keyboard
|
||||||
|
dismissSoftKeyboard(context);
|
||||||
|
|
||||||
// Start the actual animation
|
// Start the actual animation
|
||||||
_selectionController.selectMessage(
|
_selectionController.selectMessage(
|
||||||
SelectedMessageData(
|
SelectedMessageData(
|
||||||
@@ -319,6 +337,13 @@ class ConversationPageState extends State<ConversationPage>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
visibilityCallback: (info) {
|
||||||
|
if (sentBySelf) return;
|
||||||
|
|
||||||
|
if (info.visibleFraction >= 0) {
|
||||||
|
GetIt.I.get<UIReadMarkerService>().handleMarker(message);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,6 +362,8 @@ class ConversationPageState extends State<ConversationPage>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear the read marker cache
|
||||||
|
GetIt.I.get<UIReadMarkerService>().clear();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
child: KeyboardReplacerScaffold(
|
child: KeyboardReplacerScaffold(
|
||||||
@@ -448,9 +475,12 @@ class ConversationPageState extends State<ConversationPage>
|
|||||||
children: [
|
children: [
|
||||||
BlocBuilder<ConversationBloc, ConversationState>(
|
BlocBuilder<ConversationBloc, ConversationState>(
|
||||||
buildWhen: (prev, next) =>
|
buildWhen: (prev, next) =>
|
||||||
prev.conversation?.inRoster != next.conversation?.inRoster,
|
prev.conversation?.showAddToRoster !=
|
||||||
|
next.conversation?.showAddToRoster,
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if ((state.conversation?.inRoster ?? false) ||
|
final showAddToRoster =
|
||||||
|
state.conversation?.showAddToRoster ?? false;
|
||||||
|
if (!showAddToRoster ||
|
||||||
state.conversation?.type == ConversationType.note) {
|
state.conversation?.type == ConversationType.note) {
|
||||||
return const SizedBox();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/warning_types.dart';
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
|
||||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||||
import 'package:moxxyv2/ui/helpers.dart';
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
||||||
@@ -213,7 +211,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
|||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (message.canRetract(sentBySelf))
|
if (message.canRetract(sentBySelf))
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon: Icons.delete,
|
icon: Icons.delete,
|
||||||
@@ -233,16 +230,7 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
|||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (message.canEdit(sentBySelf))
|
||||||
// TODO(Unknown): Also allow correcting older messages
|
|
||||||
if (message.canEdit(sentBySelf) &&
|
|
||||||
GetIt.I
|
|
||||||
.get<ConversationBloc>()
|
|
||||||
.state
|
|
||||||
.conversation
|
|
||||||
?.lastMessage
|
|
||||||
?.id ==
|
|
||||||
message.id)
|
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon: Icons.edit,
|
icon: Icons.edit,
|
||||||
text: t.pages.conversation.edit,
|
text: t.pages.conversation.edit,
|
||||||
@@ -256,7 +244,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
|||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (message.errorMenuVisible)
|
if (message.errorMenuVisible)
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon: Icons.info_outline,
|
icon: Icons.info_outline,
|
||||||
@@ -264,22 +251,19 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
showInfoDialog(
|
showInfoDialog(
|
||||||
t.errors.conversation.messageErrorDialogTitle,
|
t.errors.conversation.messageErrorDialogTitle,
|
||||||
errorToTranslatableString(
|
message.errorType!.translatableString,
|
||||||
message.errorType!,
|
|
||||||
),
|
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (message.hasWarning)
|
if (message.hasWarning)
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon: Icons.warning,
|
icon: Icons.warning,
|
||||||
text: t.pages.conversation.showWarning,
|
text: t.pages.conversation.showWarning,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
showInfoDialog(
|
showInfoDialog(
|
||||||
'Warning',
|
t.pages.conversation.warning,
|
||||||
warningToTranslatableString(
|
warningToTranslatableString(
|
||||||
message.warningType!,
|
message.warningType!,
|
||||||
),
|
),
|
||||||
@@ -288,22 +272,24 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
|||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (message.isCopyable)
|
if (message.isCopyable)
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon: Icons.content_copy,
|
icon: Icons.content_copy,
|
||||||
text: t.pages.conversation.copy,
|
text: t.pages.conversation.copy,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// TODO(Unknown): Show a toast saying the message has been copied
|
|
||||||
Clipboard.setData(
|
Clipboard.setData(
|
||||||
ClipboardData(
|
ClipboardData(
|
||||||
text: message.body,
|
text: message.body,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
|
|
||||||
|
// Show an informative toast
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: t.pages.conversation.messageCopied,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (message.isQuotable && message.conversationJid != '')
|
if (message.isQuotable && message.conversationJid != '')
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon: Icons.forward,
|
icon: Icons.forward,
|
||||||
@@ -316,7 +302,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
|||||||
selectionController.dismiss();
|
selectionController.dismiss();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
if (message.isQuotable)
|
if (message.isQuotable)
|
||||||
ContextMenuItem(
|
ContextMenuItem(
|
||||||
icon: Icons.reply,
|
icon: Icons.reply,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class ConversationTopbar extends StatelessWidget
|
|||||||
|
|
||||||
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?.avatarPath != next.conversation?.avatarPath ||
|
||||||
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;
|
prev.conversation?.encrypted != next.conversation?.encrypted;
|
||||||
@@ -110,14 +110,17 @@ class ConversationTopbar extends StatelessWidget
|
|||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: RebuildOnContactIntegrationChange(
|
child: RebuildOnContactIntegrationChange(
|
||||||
builder: () => AvatarWrapper(
|
builder: () => CachingXMPPAvatar(
|
||||||
|
jid: state.conversation?.jid ?? '',
|
||||||
radius: 25,
|
radius: 25,
|
||||||
avatarUrl: state.conversation
|
hasContactId:
|
||||||
?.avatarPathWithOptionalContact ??
|
state.conversation?.contactId != null,
|
||||||
'',
|
hash: state.conversation?.avatarHash,
|
||||||
altText: state
|
path: state.conversation?.avatarPath,
|
||||||
.conversation?.titleWithOptionalContact ??
|
shouldRequest: state.conversation != null,
|
||||||
'A',
|
altIcon: state.conversation?.isSelfChat ?? false
|
||||||
|
? Icons.notes
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user