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
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
#### 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 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.
|
||||
|
||||
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)
|
||||
|
||||
@@ -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`.
|
||||
|
||||
### 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
|
||||
|
||||
This project is the successor of moxxyv1, which was written in *React Native* and abandoned
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow receiving share intents for all kinds of things -->
|
||||
<!-- Allow receiving share intents for all kinds of things -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
@@ -38,6 +38,14 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</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>
|
||||
<!-- Don't delete the meta-data below.
|
||||
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')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
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,354 +1,409 @@
|
||||
{
|
||||
"@@name": "Deutsch",
|
||||
"language": "Deutsch",
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
|
||||
"dialogAccept": "Okay",
|
||||
"dialogCancel": "Abbrechen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein"
|
||||
"title": "Moxxy",
|
||||
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
|
||||
"dialogAccept": "Okay",
|
||||
"dialogCancel": "Abbrechen",
|
||||
"yes": "Ja",
|
||||
"no": "Nein"
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"idle": "Bereit",
|
||||
"ready": "Bereit zum Nachrichtenempfang",
|
||||
"connecting": "Verbinde...",
|
||||
"disconnect": "Keine Verbindung",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"message": {
|
||||
"reply": "Antworten",
|
||||
"markAsRead": "Als gelesen markieren"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Nachrichten",
|
||||
"messagesChannelDescription": "Empfangene Nachrichten",
|
||||
"warningChannelName": "Warnungen",
|
||||
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Fehler"
|
||||
}
|
||||
"permanent": {
|
||||
"idle": "Bereit",
|
||||
"ready": "Bereit zum Nachrichtenempfang",
|
||||
"connecting": "Verbinde...",
|
||||
"disconnect": "Keine Verbindung",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"message": {
|
||||
"reply": "Antworten",
|
||||
"markAsRead": "Als gelesen markieren"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Nachrichten",
|
||||
"messagesChannelDescription": "Empfangene Nachrichten",
|
||||
"warningChannelName": "Warnungen",
|
||||
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Fehler"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Gerade",
|
||||
"nMinutesAgo": "vor ${min}min",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Die",
|
||||
"wednessdayAbbrev": "Mit",
|
||||
"thursdayAbbrev": "Don",
|
||||
"fridayAbbrev": "Fre",
|
||||
"saturdayAbbrev": "Sam",
|
||||
"sundayAbbrev": "Son",
|
||||
"january": "Januar",
|
||||
"february": "Februar",
|
||||
"march": "März",
|
||||
"april": "April",
|
||||
"may": "Mai",
|
||||
"june": "Juni",
|
||||
"july": "Juli",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "Oktober",
|
||||
"november": "November",
|
||||
"december": "Dezember",
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern"
|
||||
"justNow": "Gerade",
|
||||
"nMinutesAgo": "vor ${min}min",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Die",
|
||||
"wednessdayAbbrev": "Mit",
|
||||
"thursdayAbbrev": "Don",
|
||||
"fridayAbbrev": "Fre",
|
||||
"saturdayAbbrev": "Sam",
|
||||
"sundayAbbrev": "Son",
|
||||
"january": "Januar",
|
||||
"february": "Februar",
|
||||
"march": "März",
|
||||
"april": "April",
|
||||
"may": "Mai",
|
||||
"june": "Juni",
|
||||
"july": "Juli",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "Oktober",
|
||||
"november": "November",
|
||||
"december": "Dezember",
|
||||
"today": "Heute",
|
||||
"yesterday": "Gestern"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Bild",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "Datei",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||
"you": "Du"
|
||||
"image": "Bild",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "Datei",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||
"you": "Du"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
"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",
|
||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||
|
||||
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
|
||||
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Verbindung zum Server nicht möglich",
|
||||
"saslAccountDisabled": "Dein Account ist deaktiviert",
|
||||
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
|
||||
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Ungültige Logindaten",
|
||||
"startTlsFailed": "Konnte keine sichere Verbindung zum Server aufbauen",
|
||||
"noConnection": "Konnte keine Verbindung zum Server aufbauen",
|
||||
"unspecified": "Unbestimmter Fehler"
|
||||
},
|
||||
"message": {
|
||||
"unspecified": "Unbekannter Fehler",
|
||||
"fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen",
|
||||
"contactDoesntSupportOmemo": "Der Kontakt unterstützt Verschlüsselung mit OMEMO:2 nicht",
|
||||
"fileDownloadFailed": "Das Herunterladen der Datei ist fehlgeschlagen",
|
||||
"serviceUnavailable": "Die Nachricht konnte nicht gesendet werden",
|
||||
"remoteServerTimeout": "Die Nachricht konnte nicht zugestellt werden",
|
||||
"remoteServerNotFound": "Die Nachricht konnte nicht gesendet werden, da der Empfängerserver unbekannt ist",
|
||||
"failedToEncrypt": "Die Nachricht konnte nicht verschlüsselt werden",
|
||||
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
|
||||
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
|
||||
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
|
||||
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
||||
"openFileGenericError": "Fehler beim Öffnen der Datei",
|
||||
"messageErrorDialogTitle": "Fehler"
|
||||
}
|
||||
"general": {
|
||||
"noInternet": "Keine Internetverbindung."
|
||||
},
|
||||
"filePicker": {
|
||||
"permissionDenied": "Die Speicherberechtigung wurde nicht erteilt."
|
||||
},
|
||||
"omemo": {
|
||||
"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",
|
||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
|
||||
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Verbindung zum Server nicht möglich",
|
||||
"saslAccountDisabled": "Dein Konto ist deaktiviert",
|
||||
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
|
||||
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Ungültige Logindaten",
|
||||
"startTlsFailed": "Konnte keine sichere Verbindung zum Server aufbauen",
|
||||
"noConnection": "Konnte keine Verbindung zum Server aufbauen",
|
||||
"unspecified": "Unbestimmter Fehler"
|
||||
},
|
||||
"message": {
|
||||
"unspecified": "Unbekannter Fehler",
|
||||
"fileUploadFailed": "Das Hochladen der Datei ist fehlgeschlagen",
|
||||
"contactDoesntSupportOmemo": "Der Kontakt unterstützt Verschlüsselung mit OMEMO:2 nicht",
|
||||
"fileDownloadFailed": "Das Herunterladen der Datei ist fehlgeschlagen",
|
||||
"serviceUnavailable": "Die Nachricht konnte nicht gesendet werden",
|
||||
"remoteServerTimeout": "Die Nachricht konnte nicht zugestellt werden",
|
||||
"remoteServerNotFound": "Die Nachricht konnte nicht gesendet werden, da der Empfängerserver unbekannt ist",
|
||||
"failedToEncrypt": "Die Nachricht konnte nicht verschlüsselt werden",
|
||||
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
|
||||
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
|
||||
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
|
||||
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
||||
"openFileGenericError": "Fehler beim Öffnen der Datei",
|
||||
"messageErrorDialogTitle": "Fehler"
|
||||
},
|
||||
"newChat": {
|
||||
"remoteServerError": "Konnte den Server nicht erreichen.",
|
||||
"groupchatUnsupported": "Das Beitreten eines Gruppenchats ist aktuell nicht unterstützt.",
|
||||
"unknown": "Unbekannter Fehler."
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
|
||||
}
|
||||
"message": {
|
||||
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"noAccount": "Kein XMPP-Account vorhanden? Einen zu erstellen ist sehr einfach.",
|
||||
"loginButton": "Einloggen",
|
||||
"registerButton": "Registrieren"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"password": "Passwort",
|
||||
"advancedOptions": "Fortgeschrittene Optionen",
|
||||
"createAccount": "Account auf dem Server erstellen"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Neuer chat",
|
||||
"speeddialJoinGroupchat": "Gruppenchat beitreten",
|
||||
"speeddialAddNoteToSelf": "Notiz an mich",
|
||||
"overlaySettings": "Einstellungen",
|
||||
"noOpenChats": "Du hast keine offenen chats",
|
||||
"startChat": "Einen chat anfangen",
|
||||
"closeChat": "Chat schließen",
|
||||
"closeChatBody": "Bist du dir sicher, dass du den Chat mit ${conversationTitle} schließen möchtest?",
|
||||
"markAsRead": "Als gelesen markieren"
|
||||
},
|
||||
"conversation": {
|
||||
"unencrypted": "Unverschlüsselt",
|
||||
"encrypted": "Verschlüsselt",
|
||||
"closeChat": "Chat schließen",
|
||||
"closeChatConfirmTitle": "Chat schließen",
|
||||
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
|
||||
"blockShort": "Blockieren",
|
||||
"blockUser": "Nutzer blockieren",
|
||||
"online": "Online",
|
||||
"retract": "Nachricht löschen",
|
||||
"retractBody": "Bist du dir sicher, dass du die Nachricht löschen willst? Bedenke, dass dies nur eine Bitte ist, die dein gegenüber nicht beachten muss.",
|
||||
"forward": "Weiterleiten",
|
||||
"edit": "Bearbeiten",
|
||||
"quote": "Zitieren",
|
||||
"copy": "Inhalt kopieren",
|
||||
"addReaction": "Reaktion hinzufügen",
|
||||
"showError": "Fehler anzeigen",
|
||||
"showWarning": "Warnung anzeigen",
|
||||
"addToContacts": "Zu Kontaken hinzufügen",
|
||||
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
|
||||
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
||||
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||
"stickerSettings": "Stickereinstellungen",
|
||||
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
|
||||
"messageHint": "Nachricht senden...",
|
||||
"sendImages": "Bilder senden",
|
||||
"sendFiles": "Dateien senden",
|
||||
"takePhotos": "Bilder aufnehmen"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Neuen Kontakt hinzufügen",
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"subtitle": "Du kannst einen Kontakt hinzufügen, indem Du entweder die XMPP-Adresse eingibst oder den QR-Code deines Kontaktes scannst",
|
||||
"buttonAddToContact": "Kontakt hinzufügen"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Neuer chat",
|
||||
"addContact": "Kontakt hinzufügen",
|
||||
"createGroupchat": "Gruppenchat erstellen"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Als Profilbild festlegen"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Teilen mit...",
|
||||
"confirmTitle": "Dateien senden?",
|
||||
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Sicherheit",
|
||||
"profile": "Profil",
|
||||
"media": "Medien"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Benachrichtigungen",
|
||||
"notificationsMuted": "Stumm",
|
||||
"notificationsEnabled": "Eingeschaltet",
|
||||
"sharedMedia": "Medien"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Eigene Geräte",
|
||||
"thisDevice": "Dieses Gerät",
|
||||
"otherDevices": "Andere Geräte",
|
||||
"deleteDeviceConfirmTitle": "Gerät löschen",
|
||||
"deleteDeviceConfirmBody": "Das bedeutet, dass Kontakte für dieses Gerät nichtmehr verschlüsseln können. Fortfahren?",
|
||||
"recreateOwnSessions": "Sessions neuerstellen",
|
||||
"recreateOwnSessionsConfirmTitle": "Eigene Sessions neuerstellen?",
|
||||
"recreateOwnSessionsConfirmBody": "Das wird alle kryptographischen Sessions mit den eigenen Geräten neuerstellen. Verwende dies nur, wenn deine eigenen Geräte Entschlüsselungsfehler erzeugen.",
|
||||
"recreateOwnDevice": "Gerät neuerstellen",
|
||||
"recreateOwnDeviceConfirmTitle": "Gerät neuerstellen?",
|
||||
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Sicherheit",
|
||||
"recreateSessions": "Sessions zurücksetzen",
|
||||
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
|
||||
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen.",
|
||||
"noSessions": "Es sind keine kryptographischen Sessions vorhanden, die für Ende-zu-Ende-Verschlüsselung verwendet werden."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Blockliste",
|
||||
"noUsersBlocked": "Du hast niemanden blockiert",
|
||||
"unblockAll": "Alle entblocken",
|
||||
"unblockAllConfirmTitle": "Alle entblocken",
|
||||
"unblockAllConfirmBody": "Bist Du dir sicher, dass du alle geblockten Personen entblocken möchtest?",
|
||||
"unblockJidConfirmTitle": "${jid} entblocken?",
|
||||
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Hintergrund weichzeichnen",
|
||||
"setAsBackground": "Als Hintergrundbild festlegen"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Stickerpack entfernen",
|
||||
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
|
||||
"installConfirmTitle": "Stickerpack installieren",
|
||||
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
|
||||
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
|
||||
"fetchingFailure": "Konnte das Stickerpack nicht finden"
|
||||
},
|
||||
"settings": {
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"accountSection": "Account",
|
||||
"signOut": "Abmelden",
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
"miscellaneousSection": "Unterschiedlich",
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "Generell"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"licensed": "Lizensiert unter GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "Quellcode anschauen",
|
||||
"nMoreToGo": "Noch ${n}...",
|
||||
"debugMenuShown": "Du bist jetzt ein Entwickler!",
|
||||
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
"languageSection": "Sprache",
|
||||
"language": "Appsprache",
|
||||
"languageSubtext": "Aktuell ausgewählt: $selectedLanguage",
|
||||
"systemLanguage": "Systemsprache"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Open-Source Lizenzen",
|
||||
"licensedUnder": "Lizensiert unter $license"
|
||||
},
|
||||
"conversation": {
|
||||
"title": "Chat",
|
||||
"appearance": "Aussehen",
|
||||
"selectBackgroundImage": "Hintergrundbild auswählen",
|
||||
"selectBackgroundImageDescription": "Dieses Bild wird als Hintergrundbild in allen Chats verwendet",
|
||||
"removeBackgroundImage": "Hintergrundbild entfernen",
|
||||
"removeBackgroundImageConfirmTitle": "Hintergrundbild entfernen",
|
||||
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
||||
"newChatsSection": "Neue Chats",
|
||||
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
|
||||
"behaviourSection": "Verhalten",
|
||||
"contactsIntegration": "Kontaktintegration",
|
||||
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debuggingoptionen",
|
||||
"generalSection": "Generell",
|
||||
"generalEnableDebugging": "Debugging einschalten",
|
||||
"generalEncryptionPassword": "Verschlüsselungspasswort",
|
||||
"generalEncryptionPasswordSubtext": "Die Logs enthalten eventuell sensible Daten. Wähle also daher eine starke Passphrase",
|
||||
"generalLoggingIp": "Logging-IP",
|
||||
"generalLoggingIpSubtext": "Die IP-Adresse an die die Logs gesendet werden",
|
||||
"generalLoggingPort": "Logging-Port",
|
||||
"generalLoggingPortSubtext": "Der Port an den die Logs gesendet werden"
|
||||
},
|
||||
"network": {
|
||||
"title": "Netzwerk",
|
||||
"automaticDownloadsSection": "Automatische Downloads",
|
||||
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
|
||||
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
||||
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
||||
"automaticDownloadAlways": "Immer",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Mobile Daten"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privatsphäre",
|
||||
"generalSection": "Generell",
|
||||
"showContactRequests": "Kontaktanfragen zeigen",
|
||||
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
|
||||
"profilePictureVisibility": "Öffentliches Profilbild",
|
||||
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"sendChatMarkers": "Chatmarker senden",
|
||||
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
|
||||
"sendChatStates": "Chatstates senden",
|
||||
"sendChatStatesSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du gerade im Chat aktiv bist oder schreibst",
|
||||
"redirectsSection": "Weiterleitungen",
|
||||
"redirectText": "Dies leitet Links von $serviceName, die du öffnest, an einen Proxydienst weiter, wie zum Beispiel $exampleProxy",
|
||||
"currentlySelected": "Aktuell ausgewählt: $proxy",
|
||||
"redirectsTitle": "${serviceName}weiterleitung",
|
||||
"cannotEnableRedirect": "Kann ${serviceName}weiterleitung nicht aktivieren",
|
||||
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
||||
"urlEmpty": "URL kann nicht leer sein",
|
||||
"urlInvalid": "Ungültige URL",
|
||||
"redirectDialogTitle": "${serviceName}weiterleitung",
|
||||
"stickersPrivacy": "Stickerliste öffentlich halten",
|
||||
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Stickers",
|
||||
"stickerSection": "Sticker",
|
||||
"displayStickers": "Sticker im Chat anzeigen",
|
||||
"autoDownload": "Sticker automatisch herunterladen",
|
||||
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
|
||||
"stickerPacksSection": "Stickerpacks",
|
||||
"importStickerPack": "Stickerpack importieren",
|
||||
"importSuccess": "Stickerpack erfolgreich importiert",
|
||||
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten"
|
||||
}
|
||||
}
|
||||
"intro": {
|
||||
"noAccount": "Kein XMPP-Konto vorhanden? Keine Sorge, es ist ganz einfach, eines zu erstellen.",
|
||||
"loginButton": "Einloggen",
|
||||
"registerButton": "Registrieren"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"password": "Passwort",
|
||||
"advancedOptions": "Fortgeschrittene Optionen",
|
||||
"createAccount": "Konto auf dem Server erstellen"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Neuer chat",
|
||||
"speeddialJoinGroupchat": "Gruppenchat beitreten",
|
||||
"speeddialAddNoteToSelf": "Notiz an mich",
|
||||
"overlaySettings": "Einstellungen",
|
||||
"noOpenChats": "Du hast keine offenen chats",
|
||||
"startChat": "Einen chat anfangen",
|
||||
"closeChat": "Chat schließen",
|
||||
"closeChatBody": "Bist du dir sicher, dass du den Chat mit ${conversationTitle} schließen möchtest?",
|
||||
"markAsRead": "Als gelesen markieren"
|
||||
},
|
||||
"conversation": {
|
||||
"unencrypted": "Unverschlüsselt",
|
||||
"encrypted": "Verschlüsselt",
|
||||
"closeChat": "Chat schließen",
|
||||
"closeChatConfirmTitle": "Chat schließen",
|
||||
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
|
||||
"blockShort": "Blockieren",
|
||||
"blockUser": "Nutzer blockieren",
|
||||
"online": "Online",
|
||||
"retract": "Nachricht löschen",
|
||||
"retractBody": "Bist du dir sicher, dass du die Nachricht löschen willst? Bedenke, dass dies nur eine Bitte ist, die dein gegenüber nicht beachten muss.",
|
||||
"forward": "Weiterleiten",
|
||||
"edit": "Bearbeiten",
|
||||
"quote": "Zitieren",
|
||||
"copy": "Inhalt kopieren",
|
||||
"messageCopied": "Nachrichteninhalt in die Zwischenablage kopiert",
|
||||
"addReaction": "Reaktion hinzufügen",
|
||||
"showError": "Fehler anzeigen",
|
||||
"showWarning": "Warnung anzeigen",
|
||||
"warning": "Warnung",
|
||||
"addToContacts": "Zu Kontaken hinzufügen",
|
||||
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
|
||||
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
||||
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||
"stickerSettings": "Stickereinstellungen",
|
||||
"newDeviceMessage": {
|
||||
"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...",
|
||||
"sendImages": "Bilder senden",
|
||||
"sendFiles": "Dateien senden",
|
||||
"takePhotos": "Bilder aufnehmen"
|
||||
},
|
||||
"startchat": {
|
||||
"title": "Neuer Chat",
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"subtitle": "Du kannst einen neuen Chat beginnen, indem du entweder eine XMPP-Adresse eingibst oder einen QR-Code scannst.",
|
||||
"buttonAddToContact": "Neuen Chat beginnen"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Neuer Chat",
|
||||
"startChat": "Neuen Chat beginnen",
|
||||
"createGroupchat": "Gruppenchat erstellen"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Als Profilbild festlegen"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Teilen mit...",
|
||||
"confirmTitle": "Dateien senden?",
|
||||
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Sicherheit",
|
||||
"profile": "Profil",
|
||||
"media": "Medien"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Benachrichtigungen",
|
||||
"notificationsMuted": "Stumm",
|
||||
"notificationsEnabled": "Eingeschaltet",
|
||||
"sharedMedia": "Medien"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Eigene Geräte",
|
||||
"thisDevice": "Dieses Gerät",
|
||||
"otherDevices": "Andere Geräte",
|
||||
"deleteDeviceConfirmTitle": "Gerät löschen",
|
||||
"deleteDeviceConfirmBody": "Das bedeutet, dass Kontakte für dieses Gerät nichtmehr verschlüsseln können. Fortfahren?",
|
||||
"recreateOwnSessions": "Sessions neuerstellen",
|
||||
"recreateOwnSessionsConfirmTitle": "Eigene Sessions neuerstellen?",
|
||||
"recreateOwnSessionsConfirmBody": "Das wird alle kryptographischen Sessions mit den eigenen Geräten neuerstellen. Verwende dies nur, wenn deine eigenen Geräte Entschlüsselungsfehler erzeugen.",
|
||||
"recreateOwnDevice": "Gerät neuerstellen",
|
||||
"recreateOwnDeviceConfirmTitle": "Gerät neuerstellen?",
|
||||
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Sicherheit",
|
||||
"recreateSessions": "Sessions zurücksetzen",
|
||||
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
|
||||
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen.",
|
||||
"noSessions": "Es sind keine kryptographischen Sessions vorhanden, die für Ende-zu-Ende-Verschlüsselung verwendet werden."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Blockliste",
|
||||
"noUsersBlocked": "Du hast niemanden blockiert",
|
||||
"unblockAll": "Alle entblocken",
|
||||
"unblockAllConfirmTitle": "Alle entblocken",
|
||||
"unblockAllConfirmBody": "Bist Du dir sicher, dass du alle geblockten Personen entblocken möchtest?",
|
||||
"unblockJidConfirmTitle": "${jid} entblocken?",
|
||||
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Hintergrund weichzeichnen",
|
||||
"setAsBackground": "Als Hintergrundbild festlegen"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Stickerpack entfernen",
|
||||
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
|
||||
"installConfirmTitle": "Stickerpack installieren",
|
||||
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
|
||||
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
|
||||
"fetchingFailure": "Konnte das Stickerpack nicht finden"
|
||||
},
|
||||
"settings": {
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"accountSection": "Konto",
|
||||
"signOut": "Abmelden",
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
"miscellaneousSection": "Unterschiedlich",
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "Generell"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"licensed": "Lizensiert unter GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "Quellcode anschauen",
|
||||
"nMoreToGo": "Noch ${n}...",
|
||||
"debugMenuShown": "Du bist jetzt ein Entwickler!",
|
||||
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
"languageSection": "Sprache",
|
||||
"language": "Appsprache",
|
||||
"languageSubtext": "Aktuell ausgewählt: $selectedLanguage",
|
||||
"systemLanguage": "Systemsprache"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Open-Source Lizenzen",
|
||||
"licensedUnder": "Lizensiert unter $license"
|
||||
},
|
||||
"conversation": {
|
||||
"title": "Chat",
|
||||
"appearance": "Aussehen",
|
||||
"selectBackgroundImage": "Hintergrundbild auswählen",
|
||||
"selectBackgroundImageDescription": "Dieses Bild wird als Hintergrundbild in allen Chats verwendet",
|
||||
"removeBackgroundImage": "Hintergrundbild entfernen",
|
||||
"removeBackgroundImageConfirmTitle": "Hintergrundbild entfernen",
|
||||
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
||||
"newChatsSection": "Neue Chats",
|
||||
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
|
||||
"behaviourSection": "Verhalten",
|
||||
"contactsIntegration": "Kontaktintegration",
|
||||
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debuggingoptionen",
|
||||
"generalSection": "Generell",
|
||||
"generalEnableDebugging": "Debugging einschalten",
|
||||
"generalEncryptionPassword": "Verschlüsselungspasswort",
|
||||
"generalEncryptionPasswordSubtext": "Die Logs enthalten eventuell sensible Daten. Wähle also daher eine starke Passphrase",
|
||||
"generalLoggingIp": "Logging-IP",
|
||||
"generalLoggingIpSubtext": "Die IP-Adresse an die die Logs gesendet werden",
|
||||
"generalLoggingPort": "Logging-Port",
|
||||
"generalLoggingPortSubtext": "Der Port an den die Logs gesendet werden"
|
||||
},
|
||||
"network": {
|
||||
"title": "Netzwerk",
|
||||
"automaticDownloadsSection": "Automatische Downloads",
|
||||
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
|
||||
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
||||
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
||||
"automaticDownloadAlways": "Immer",
|
||||
"wifi": "WLAN",
|
||||
"mobileData": "Mobile Daten"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privatsphäre",
|
||||
"generalSection": "Generell",
|
||||
"showContactRequests": "Kontaktanfragen zeigen",
|
||||
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
|
||||
"profilePictureVisibility": "Öffentliches Profilbild",
|
||||
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"sendChatMarkers": "Chatmarker senden",
|
||||
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
|
||||
"sendChatStates": "Chatstates senden",
|
||||
"sendChatStatesSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du gerade im Chat aktiv bist oder schreibst",
|
||||
"redirectsSection": "Weiterleitungen",
|
||||
"redirectText": "Dies leitet Links von $serviceName, die du öffnest, an einen Proxydienst weiter, wie zum Beispiel $exampleProxy",
|
||||
"currentlySelected": "Aktuell ausgewählt: $proxy",
|
||||
"redirectsTitle": "${serviceName}weiterleitung",
|
||||
"cannotEnableRedirect": "Kann ${serviceName}weiterleitung nicht aktivieren",
|
||||
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
||||
"urlEmpty": "URL kann nicht leer sein",
|
||||
"urlInvalid": "Ungültige URL",
|
||||
"redirectDialogTitle": "${serviceName}weiterleitung",
|
||||
"stickersPrivacy": "Stickerliste öffentlich halten",
|
||||
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Stickers",
|
||||
"stickerSection": "Sticker",
|
||||
"displayStickers": "Sticker im Chat anzeigen",
|
||||
"autoDownload": "Sticker automatisch herunterladen",
|
||||
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
|
||||
"stickerPacksSection": "Stickerpacks",
|
||||
"importStickerPack": "Stickerpack importieren",
|
||||
"importSuccess": "Stickerpack erfolgreich importiert",
|
||||
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten",
|
||||
"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:
|
||||
input_directory: assets/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": {
|
||||
"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": {
|
||||
"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": {
|
||||
"lastModified": 1667395993,
|
||||
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
||||
@@ -17,24 +77,71 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1676076353,
|
||||
"narHash": "sha256-mdUtE8Tp40cZETwcq5tCwwLqkJVV1ULJQ5GKRtbshag=",
|
||||
"owner": "AtaraxiaSjel",
|
||||
"lastModified": 1689679375,
|
||||
"narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5deb99bdccbbb97e7562dee4ba8a3ee3021688e6",
|
||||
"rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "AtaraxiaSjel",
|
||||
"ref": "update/flutter",
|
||||
"owner": "NixOS",
|
||||
"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",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"android-nixpkgs": "android-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";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:AtaraxiaSjel/nixpkgs/update/flutter";
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
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 {
|
||||
inherit system;
|
||||
config = {
|
||||
android_sdk.accept_license = 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 {
|
||||
# TODO: Find a way to pin these
|
||||
#toolsVersion = "26.1.1";
|
||||
#platformToolsVersion = "31.0.3";
|
||||
#buildToolsVersions = [ "31.0.0" ];
|
||||
#includeEmulator = true;
|
||||
#emulatorVersion = "30.6.3";
|
||||
platformVersions = [ "28" ];
|
||||
includeSources = false;
|
||||
includeSystemImages = true;
|
||||
systemImageTypes = [ "default" ];
|
||||
abiVersions = [ "x86_64" ];
|
||||
includeNDK = false;
|
||||
useGoogleAPIs = false;
|
||||
useGoogleTVAddOns = false;
|
||||
};
|
||||
# Everything to make Flutter happy
|
||||
sdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
|
||||
cmdline-tools-latest
|
||||
build-tools-30-0-3
|
||||
build-tools-33-0-2
|
||||
build-tools-34-0-0
|
||||
platform-tools
|
||||
emulator
|
||||
patcher-v4
|
||||
platforms-android-28
|
||||
platforms-android-29
|
||||
platforms-android-30
|
||||
platforms-android-31
|
||||
platforms-android-33
|
||||
|
||||
# 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;
|
||||
|
||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||
@@ -38,13 +51,27 @@
|
||||
in {
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
flutter pinnedJDK android.platform-tools dart scrcpy # Flutter/Android
|
||||
pythonEnv gnumake # Build scripts
|
||||
gitlint jq # Code hygiene
|
||||
ripgrep # General utilities
|
||||
# Android
|
||||
pinnedJDK sdk
|
||||
scrcpy
|
||||
|
||||
# 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;
|
||||
|
||||
# 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:
|
||||
type: List<RosterItem>?
|
||||
deserialise: true
|
||||
stickers:
|
||||
type: List<StickerPack>?
|
||||
deserialise: true
|
||||
# Triggered if a conversation has been added.
|
||||
# Also returned by [AddConversationCommand]
|
||||
- name: ConversationAddedEvent
|
||||
@@ -208,7 +205,7 @@ files:
|
||||
attributes:
|
||||
conversationJid: String
|
||||
title: String
|
||||
avatarUrl: String
|
||||
avatarPath: String
|
||||
- name: StickerPackImportSuccessEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
@@ -274,6 +271,70 @@ files:
|
||||
reactions:
|
||||
type: List<ReactionGroup>
|
||||
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
|
||||
builder_name: "Event"
|
||||
builder_baseclass: "BackgroundEvent"
|
||||
@@ -484,9 +545,8 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationJid: String
|
||||
sid: String
|
||||
newUnreadCounter: int
|
||||
id: int
|
||||
sendMarker: bool
|
||||
- name: AddReactionToMessageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -567,7 +627,7 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationJid: String
|
||||
conversationJid: String?
|
||||
olderThan: bool
|
||||
timestamp: int?
|
||||
- name: GetReactionsForMessageCommand
|
||||
@@ -576,6 +636,46 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
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
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
||||
@@ -10,7 +10,6 @@ import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/commands.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/conversation_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/server_info_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/stickers_bloc.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/postregister/postregister.dart";
|
||||
*/
|
||||
import 'package:moxxyv2/ui/pages/addcontact.dart';
|
||||
import 'package:moxxyv2/ui/pages/blocklist.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/conversation.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/privacy/privacy.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/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/sharedmedia.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/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/progress.dart';
|
||||
import 'package:moxxyv2/ui/service/read.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
@@ -83,7 +89,13 @@ void setupLogging() {
|
||||
Future<void> setupUIServices() async {
|
||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||
GetIt.I.registerSingleton<UIAvatarsService>(UIAvatarsService());
|
||||
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) {
|
||||
@@ -95,7 +107,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||
GetIt.I.registerSingleton<StartChatBloc>(StartChatBloc());
|
||||
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
||||
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
||||
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
||||
@@ -147,8 +159,8 @@ void main() async {
|
||||
BlocProvider<PreferencesBloc>(
|
||||
create: (_) => GetIt.I.get<PreferencesBloc>(),
|
||||
),
|
||||
BlocProvider<AddContactBloc>(
|
||||
create: (_) => GetIt.I.get<AddContactBloc>(),
|
||||
BlocProvider<StartChatBloc>(
|
||||
create: (_) => GetIt.I.get<StartChatBloc>(),
|
||||
),
|
||||
BlocProvider<CropBloc>(
|
||||
create: (_) => GetIt.I.get<CropBloc>(),
|
||||
@@ -268,11 +280,13 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
case newConversationRoute:
|
||||
return NewConversationPage.route;
|
||||
case conversationRoute:
|
||||
final args = settings.arguments! as ConversationPageArguments;
|
||||
return PageTransition<dynamic>(
|
||||
type: PageTransitionType.rightToLeft,
|
||||
settings: settings,
|
||||
child: ConversationPage(
|
||||
conversationJid: settings.arguments! as String,
|
||||
conversationJid: args.conversationJid,
|
||||
initialText: args.initialText,
|
||||
),
|
||||
);
|
||||
// case sharedMediaRoute:
|
||||
@@ -298,7 +312,7 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
case debuggingRoute:
|
||||
return DebuggingPage.route;
|
||||
case addContactRoute:
|
||||
return AddContactPage.route;
|
||||
return StartChatPage.route;
|
||||
case cropRoute:
|
||||
return CropPage.route;
|
||||
case sendFilesRoute:
|
||||
@@ -323,8 +337,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
);
|
||||
case stickersRoute:
|
||||
return StickersSettingsPage.route;
|
||||
case stickerPacksRoute:
|
||||
return StickerPacksSettingsPage.route;
|
||||
case stickerPackRoute:
|
||||
return StickerPackPage.route;
|
||||
case storageSettingsRoute:
|
||||
return StorageSettingsPage.route;
|
||||
case storageSharedMediaSettingsRoute:
|
||||
return StorageSharedMediaPage.route;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
@@ -14,60 +12,100 @@ import 'package:moxxyv2/shared/avatar.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
|
||||
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
||||
/// 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 {
|
||||
final Logger _log = Logger('AvatarService');
|
||||
|
||||
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
||||
await updateAvatarForJid(
|
||||
event.jid,
|
||||
event.hash,
|
||||
base64Decode(_cleanBase64String(event.base64)),
|
||||
);
|
||||
/// List of JIDs for which we have already requested the avatar in the current stream.
|
||||
final List<JID> _requestedInStream = [];
|
||||
|
||||
void resetCache() {
|
||||
_requestedInStream.clear();
|
||||
}
|
||||
|
||||
Future<void> updateAvatarForJid(
|
||||
String jid,
|
||||
Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
|
||||
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,
|
||||
List<int> data,
|
||||
) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final originalConversation = await cs.getConversationByJid(jid);
|
||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||
final originalConversation = await cs.getConversationByJid(jid.toString());
|
||||
final originalRoster = await rs.getRosterItemByJid(jid.toString());
|
||||
|
||||
if (originalConversation == null && originalRoster == null) return;
|
||||
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
data,
|
||||
hash,
|
||||
jid,
|
||||
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
||||
jid.toString(),
|
||||
(originalConversation?.avatarPath ?? originalRoster?.avatarPath)!,
|
||||
);
|
||||
|
||||
if (originalConversation != null) {
|
||||
final conversation = await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
jid.toString(),
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
jid,
|
||||
avatarUrl: avatarPath,
|
||||
jid.toString(),
|
||||
avatarPath: avatarPath,
|
||||
avatarHash: hash,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -81,88 +119,21 @@ class AvatarService {
|
||||
if (originalRoster != null) {
|
||||
final roster = await rs.updateRosterItem(
|
||||
originalRoster.id,
|
||||
avatarUrl: avatarPath,
|
||||
avatarPath: avatarPath,
|
||||
avatarHash: hash,
|
||||
);
|
||||
|
||||
sendEvent(RosterDiffEvent(modified: [roster]));
|
||||
}
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
|
||||
final am = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final idResult = await am.getAvatarId(JID.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,
|
||||
sendEvent(
|
||||
AvatarUpdatedEvent(
|
||||
jid: jid.toString(),
|
||||
path: avatarPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
|
||||
/// of the avatar data.
|
||||
@@ -201,6 +172,7 @@ class AvatarService {
|
||||
imageSize.height.toInt(),
|
||||
// TODO(PapaTutuWawa): Maybe do a check here
|
||||
'image/png',
|
||||
null,
|
||||
),
|
||||
public,
|
||||
);
|
||||
@@ -213,38 +185,44 @@ class AvatarService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
|
||||
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
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.getXmppState();
|
||||
final jid = state.jid!;
|
||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.info('Error while getting latest avatar id for own avatar');
|
||||
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;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
final id = rawId.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');
|
||||
if (id == state.avatarHash) {
|
||||
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||
return;
|
||||
}
|
||||
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||
|
||||
_log.info('Received data for our own avatar');
|
||||
|
||||
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(
|
||||
base64Decode(_cleanBase64String(avatarData.base64)),
|
||||
avatarData.data,
|
||||
avatarData.hash,
|
||||
jid,
|
||||
jid.toString(),
|
||||
state.avatarUrl,
|
||||
);
|
||||
await xss.modifyXmppState(
|
||||
|
||||
@@ -41,19 +41,25 @@ class ContactsService {
|
||||
final Map<String, String?> _contactDisplayNames = {};
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (await _canUseContactIntegration()) {
|
||||
enableDatabaseListener();
|
||||
await enable(shouldScan: false);
|
||||
}
|
||||
|
||||
/// Enable listening to contact database events. If [shouldScan] is true, also
|
||||
/// performs a scan of the contacts database, if we're allowed.
|
||||
Future<void> enable({bool shouldScan = true}) async {
|
||||
FlutterContacts.addListener(_onContactsDatabaseUpdate);
|
||||
|
||||
if (shouldScan && await _canUseContactIntegration()) {
|
||||
unawaited(scanContacts());
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable listening to contact database events
|
||||
void enableDatabaseListener() {
|
||||
FlutterContacts.addListener(_onContactsDatabaseUpdate);
|
||||
}
|
||||
|
||||
/// Disable listening to contact database events
|
||||
void disableDatabaseListener() {
|
||||
/// Disable listening to contact database events. Also removes all roster items
|
||||
/// that are pseudo roster items.
|
||||
Future<void> disable() async {
|
||||
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
|
||||
|
||||
await GetIt.I.get<RosterService>().removePseudoRosterItems();
|
||||
}
|
||||
|
||||
Future<void> _onContactsDatabaseUpdate() async {
|
||||
@@ -123,7 +129,6 @@ class ContactsService {
|
||||
Future<Map<String, String>> _getContactIds() async {
|
||||
if (_contactIds != null) return _contactIds!;
|
||||
|
||||
// TODO(Unknown): Can we just .cast<String, String>() here?
|
||||
_contactIds = Map<String, String>.fromEntries(
|
||||
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
|
||||
(item) => MapEntry(
|
||||
@@ -276,7 +281,8 @@ class ContactsService {
|
||||
return cs.updateConversation(
|
||||
contact.jid,
|
||||
contactId: contact.id,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactAvatarPath:
|
||||
contact.thumbnail != null ? contactAvatarPath : null,
|
||||
contactDisplayName: contact.displayName,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -87,8 +87,7 @@ class ConversationService {
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
),
|
||||
);
|
||||
@@ -136,7 +135,8 @@ class ConversationService {
|
||||
Message? lastMessage,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
String? avatarPath,
|
||||
Object? avatarHash = notSpecified,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
@@ -160,8 +160,11 @@ class ConversationService {
|
||||
if (unreadCounter != null) {
|
||||
c['unreadCounter'] = unreadCounter;
|
||||
}
|
||||
if (avatarUrl != null) {
|
||||
c['avatarUrl'] = avatarUrl;
|
||||
if (avatarPath != null) {
|
||||
c['avatarPath'] = avatarPath;
|
||||
}
|
||||
if (avatarHash != notSpecified) {
|
||||
c['avatarHash'] = avatarHash as String?;
|
||||
}
|
||||
if (muted != null) {
|
||||
c['muted'] = boolToInt(muted);
|
||||
@@ -191,8 +194,7 @@ class ConversationService {
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
var newConversation = Conversation.fromDatabaseJson(
|
||||
result,
|
||||
rosterItem != null,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
);
|
||||
|
||||
@@ -215,7 +217,7 @@ class ConversationService {
|
||||
String title,
|
||||
Message? lastMessage,
|
||||
ConversationType type,
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
int lastChangeTimestamp,
|
||||
@@ -231,14 +233,14 @@ class ConversationService {
|
||||
final newConversation = Conversation(
|
||||
title,
|
||||
lastMessage,
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
null,
|
||||
jid,
|
||||
unreadCounter,
|
||||
type,
|
||||
lastChangeTimestamp,
|
||||
open,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
muted,
|
||||
encrypted,
|
||||
ChatState.gone,
|
||||
|
||||
@@ -3,13 +3,6 @@ const messagesTable = 'Messages';
|
||||
const rosterTable = 'RosterItems';
|
||||
const mediaTable = 'SharedMedia';
|
||||
const preferenceTable = 'Preferences';
|
||||
const omemoDeviceTable = 'OmemoDevices';
|
||||
const omemoDeviceListTable = 'OmemoDeviceList';
|
||||
const omemoRatchetsTable = 'OmemoSessions';
|
||||
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
||||
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
||||
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
||||
const omemoFingerprintCache = 'OmemoFingerprintCache';
|
||||
const xmppStateTable = 'XmppState';
|
||||
const contactsTable = 'Contacts';
|
||||
const stickersTable = 'Stickers';
|
||||
@@ -19,6 +12,10 @@ const subscriptionsTable = 'SubscriptionRequests';
|
||||
const fileMetadataTable = 'FileMetadata';
|
||||
const fileMetadataHashesTable = 'FileMetadataHashes';
|
||||
const reactionsTable = 'Reactions';
|
||||
const omemoDevicesTable = 'OmemoDevices';
|
||||
const omemoDeviceListTable = 'OmemoDeviceList';
|
||||
const omemoRatchetsTable = 'OmemoRatchets';
|
||||
const omemoTrustTable = 'OmemoTrustTable';
|
||||
|
||||
const typeString = 0;
|
||||
const typeInt = 1;
|
||||
|
||||
@@ -18,7 +18,8 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
);
|
||||
|
||||
// Messages
|
||||
await db.execute('''
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $messagesTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
@@ -46,13 +47,15 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''');
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
||||
);
|
||||
|
||||
// Reactions
|
||||
await db.execute('''
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $reactionsTable (
|
||||
senderJid 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 fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
||||
);
|
||||
|
||||
// File metadata
|
||||
await db.execute('''
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $fileMetadataTable (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
path TEXT,
|
||||
@@ -83,8 +88,10 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
cipherTextHashes TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER
|
||||
)''');
|
||||
await db.execute('''
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $fileMetadataHashesTable (
|
||||
algorithm 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 fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
||||
);
|
||||
@@ -101,19 +109,20 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $conversationsTable (
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
avatarUrl TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
avatarPath TEXT NOT NULL,
|
||||
avatarHash TEXT,
|
||||
type TEXT NOT NULL,
|
||||
lastChangeTimestamp INTEGER NOT NULL,
|
||||
unreadCounter INTEGER NOT NULL,
|
||||
open INTEGER NOT NULL,
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER,
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
unreadCounter INTEGER NOT NULL,
|
||||
open INTEGER NOT NULL,
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER,
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
|
||||
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
@@ -124,11 +133,13 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
);
|
||||
|
||||
// Contacts
|
||||
await db.execute('''
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $contactsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL
|
||||
)''');
|
||||
)''',
|
||||
);
|
||||
|
||||
// Roster
|
||||
await db.execute(
|
||||
@@ -137,7 +148,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
avatarUrl TEXT NOT NULL,
|
||||
avatarPath TEXT NOT NULL,
|
||||
avatarHash TEXT NOT NULL,
|
||||
subscription TEXT NOT NULL,
|
||||
ask TEXT NOT NULL,
|
||||
@@ -172,7 +183,8 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
description TEXT NOT NULL,
|
||||
hashAlgorithm 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
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoRatchetsTable (
|
||||
id INTEGER NOT NULL,
|
||||
jid TEXT NOT NULL,
|
||||
dhs TEXT NOT NULL,
|
||||
dhs_pub TEXT NOT NULL,
|
||||
dhr TEXT,
|
||||
rk TEXT NOT NULL,
|
||||
cks TEXT,
|
||||
ckr TEXT,
|
||||
ns INTEGER NOT NULL,
|
||||
nr INTEGER NOT NULL,
|
||||
pn INTEGER NOT NULL,
|
||||
ik_pub TEXT NOT NULL,
|
||||
session_ad TEXT NOT NULL,
|
||||
acknowledged INTEGER NOT NULL,
|
||||
mkskipped TEXT NOT NULL,
|
||||
kex_timestamp INTEGER NOT NULL,
|
||||
kex TEXT,
|
||||
PRIMARY KEY (jid, id)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustCacheTable (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
trust INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustDeviceListTable (
|
||||
jid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustEnableListTable (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
enabled INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceTable (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
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,
|
||||
id INTEGER NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
devices TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoFingerprintCache (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
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)
|
||||
)''',
|
||||
);
|
||||
|
||||
|
||||
@@ -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_shared_media.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:random_string/random_string.dart';
|
||||
// ignore: implementation_imports
|
||||
@@ -144,6 +149,11 @@ const List<DatabaseMigration<Database>> migrations = [
|
||||
DatabaseMigration(35, upgradeFromV34ToV35),
|
||||
DatabaseMigration(36, upgradeFromV35ToV36),
|
||||
DatabaseMigration(37, upgradeFromV36ToV37),
|
||||
DatabaseMigration(38, upgradeFromV37ToV38),
|
||||
DatabaseMigration(39, upgradeFromV38ToV39),
|
||||
DatabaseMigration(40, upgradeFromV39ToV40),
|
||||
DatabaseMigration(41, upgradeFromV40ToV41),
|
||||
DatabaseMigration(42, upgradeFromV41ToV42),
|
||||
];
|
||||
|
||||
class DatabaseService {
|
||||
@@ -179,10 +189,23 @@ class DatabaseService {
|
||||
_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(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 37,
|
||||
version: version,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// 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';
|
||||
|
||||
Future<void> upgradeFromV12ToV13(Database db) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoFingerprintCache (
|
||||
CREATE TABLE OmemoFingerprintCache (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER 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:convert';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.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/contacts.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/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/service.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_state.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/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@@ -100,6 +105,12 @@ void setupBackgroundEventHandler() {
|
||||
EventTypeMatcher<GetPagedMessagesCommand>(performGetPagedMessages),
|
||||
EventTypeMatcher<GetPagedSharedMediaCommand>(performGetPagedSharedMedia),
|
||||
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);
|
||||
@@ -180,7 +191,6 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(
|
||||
.where((c) => c.open)
|
||||
.toList(),
|
||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -318,7 +328,7 @@ Future<void> performSendMessage(
|
||||
command.editSid!,
|
||||
command.recipients.first,
|
||||
command.chatState.isNotEmpty
|
||||
? chatStateFromString(command.chatState)
|
||||
? ChatState.fromName(command.chatState)
|
||||
: null,
|
||||
);
|
||||
return;
|
||||
@@ -328,7 +338,7 @@ Future<void> performSendMessage(
|
||||
body: command.body,
|
||||
recipients: command.recipients,
|
||||
chatState: command.chatState.isNotEmpty
|
||||
? chatStateFromString(command.chatState)
|
||||
? ChatState.fromName(command.chatState)
|
||||
: null,
|
||||
quotedMessage: command.quotedMessage,
|
||||
currentConversationJid: command.currentConversationJid,
|
||||
@@ -392,13 +402,13 @@ Future<void> performSetPreferences(
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
if (command.preferences.enableContactIntegration) {
|
||||
if (!oldPrefs.enableContactIntegration) {
|
||||
css.enableDatabaseListener();
|
||||
await css.enable();
|
||||
}
|
||||
|
||||
unawaited(css.scanContacts());
|
||||
} else {
|
||||
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(
|
||||
AddContactCommand command, {
|
||||
dynamic extra,
|
||||
@@ -459,76 +506,108 @@ Future<void> performAddContact(
|
||||
final inRoster = await roster.isInRoster(jid);
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
create: () async {
|
||||
// Create
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactId = await css.getContactIdForJid(jid);
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final newConversation = await cs.addConversationFromData(
|
||||
jid.split('@')[0],
|
||||
null,
|
||||
ConversationType.chat,
|
||||
'',
|
||||
jid,
|
||||
0,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
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,
|
||||
);
|
||||
sendEvent(
|
||||
AddContactResultEvent(
|
||||
conversation: newConversation,
|
||||
added: !inRoster,
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
|
||||
return newConversation;
|
||||
},
|
||||
update: (c) async {
|
||||
final newConversation = await cs.updateConversation(
|
||||
jid,
|
||||
open: true,
|
||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
return newConversation;
|
||||
},
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
AddContactResultEvent(conversation: newConversation, added: !inRoster),
|
||||
id: id,
|
||||
);
|
||||
|
||||
return newConversation;
|
||||
},
|
||||
);
|
||||
|
||||
// Manage subscription requests
|
||||
final srs = GetIt.I.get<SubscriptionRequestService>();
|
||||
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);
|
||||
}
|
||||
// Add to roster, if needed
|
||||
await _maybeAchieveBothSubscription(jid);
|
||||
} else {
|
||||
await roster.addToRosterWrapper('', '', jid, jid.split('@')[0]);
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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, '');
|
||||
if (isGroupchat) {
|
||||
// The JID points to a groupchat. Handle that on the UI side
|
||||
sendEvent(
|
||||
JidIsGroupchatEvent(),
|
||||
id: id,
|
||||
);
|
||||
} else {
|
||||
await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
create: () async {
|
||||
// Create
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactId = await css.getContactIdForJid(jid);
|
||||
final prefs =
|
||||
await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final newConversation = await cs.addConversationFromData(
|
||||
jid.split('@')[0],
|
||||
null,
|
||||
ConversationType.chat,
|
||||
'',
|
||||
jid,
|
||||
0,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
AddContactResultEvent(
|
||||
conversation: newConversation,
|
||||
added: !inRoster,
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
|
||||
return newConversation;
|
||||
},
|
||||
);
|
||||
|
||||
// Add to roster, if required
|
||||
await _maybeAchieveBothSubscription(jid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performRemoveContact(
|
||||
@@ -547,7 +626,7 @@ Future<void> performRemoveContact(
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: conversation.copyWith(
|
||||
inRoster: false,
|
||||
showAddToRoster: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -594,6 +673,7 @@ Future<void> performRequestDownload(
|
||||
),
|
||||
message.id,
|
||||
message.fileMetadata!.id,
|
||||
message.fileMetadata!.plaintextHashes?.isNotEmpty ?? false,
|
||||
message.conversationJid,
|
||||
mimeGuess,
|
||||
),
|
||||
@@ -615,23 +695,32 @@ Future<void> performSetShareOnlineStatus(
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final srs = GetIt.I.get<SubscriptionRequestService>();
|
||||
final item = await rs.getRosterItemByJid(command.jid);
|
||||
|
||||
// TODO(Unknown): Maybe log
|
||||
if (item == null) return;
|
||||
|
||||
final jid = JID.fromString(command.jid);
|
||||
final pm = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<PresenceManager>(presenceManager)!;
|
||||
if (command.share) {
|
||||
if (item.ask == 'subscribe') {
|
||||
await srs.acceptSubscriptionRequest(command.jid);
|
||||
} else {
|
||||
srs.sendSubscriptionRequest(command.jid);
|
||||
switch (item.subscription) {
|
||||
case 'to':
|
||||
await pm.acceptSubscriptionRequest(jid);
|
||||
break;
|
||||
case 'none':
|
||||
case 'from':
|
||||
await pm.requestSubscription(jid);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (item.ask == 'subscribe') {
|
||||
await srs.rejectSubscriptionRequest(command.jid);
|
||||
} else {
|
||||
srs.sendUnsubscriptionRequest(command.jid);
|
||||
switch (item.subscription) {
|
||||
case 'both':
|
||||
case 'from':
|
||||
case 'to':
|
||||
await pm.unsubscribe(jid);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,9 +762,9 @@ Future<void> performSendChatState(
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
|
||||
if (command.jid != '') {
|
||||
conn
|
||||
await conn
|
||||
.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 xss = GetIt.I.get<XmppStateService>();
|
||||
unawaited(conn.disconnect());
|
||||
await xss.modifyXmppState((state) => XmppState());
|
||||
await xss.modifyXmppState(
|
||||
(state) => XmppState(),
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
SignedOutEvent(),
|
||||
@@ -754,7 +845,7 @@ Future<void> performGetOmemoFingerprints(
|
||||
final omemo = GetIt.I.get<OmemoService>();
|
||||
sendEvent(
|
||||
GetConversationOmemoFingerprintsResult(
|
||||
fingerprints: await omemo.getOmemoKeysForJid(command.jid),
|
||||
fingerprints: await omemo.getFingerprintsForJid(command.jid),
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
@@ -767,7 +858,7 @@ Future<void> performEnableOmemoKey(
|
||||
final id = extra as String;
|
||||
|
||||
final omemo = GetIt.I.get<OmemoService>();
|
||||
await omemo.setOmemoKeyEnabled(
|
||||
await omemo.setDeviceEnablement(
|
||||
command.jid,
|
||||
command.deviceId,
|
||||
command.enabled,
|
||||
@@ -783,10 +874,14 @@ Future<void> performRecreateSessions(
|
||||
RecreateSessionsCommand command, {
|
||||
dynamic extra,
|
||||
}) 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>();
|
||||
await conn.getManagerById<BaseOmemoManager>(omemoManager)!.sendOmemoHeartbeat(
|
||||
// And force the creation of new ones
|
||||
await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!
|
||||
.sendOmemoHeartbeat(
|
||||
command.jid,
|
||||
);
|
||||
}
|
||||
@@ -815,14 +910,14 @@ Future<void> performGetOwnOmemoFingerprints(
|
||||
final id = extra as String;
|
||||
final os = GetIt.I.get<OmemoService>();
|
||||
final xs = GetIt.I.get<XmppService>();
|
||||
await os.ensureInitialized();
|
||||
|
||||
final jid = (await xs.getConnectionSettings())!.jid;
|
||||
final device = await os.getDevice();
|
||||
sendEvent(
|
||||
GetOwnOmemoFingerprintsResult(
|
||||
ownDeviceFingerprint: await os.getDeviceFingerprint(),
|
||||
ownDeviceId: await os.getDeviceId(),
|
||||
fingerprints: await os.getOwnFingerprints(jid),
|
||||
ownDeviceFingerprint: await device.getFingerprint(),
|
||||
ownDeviceId: device.id,
|
||||
fingerprints: await os.getFingerprintsForJid(jid.toString()),
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
@@ -834,7 +929,7 @@ Future<void> performRemoveOwnDevice(
|
||||
}) async {
|
||||
await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BaseOmemoManager>(omemoManager)!
|
||||
.getManagerById<OmemoManager>(omemoManager)!
|
||||
.deleteDevice(command.deviceId);
|
||||
}
|
||||
|
||||
@@ -843,9 +938,7 @@ Future<void> performRegenerateOwnDevice(
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final id = extra as String;
|
||||
final jid =
|
||||
GetIt.I.get<XmppConnection>().connectionSettings.jid.toBare().toString();
|
||||
final device = await GetIt.I.get<OmemoService>().regenerateDevice(jid);
|
||||
final device = await GetIt.I.get<OmemoService>().regenerateDevice();
|
||||
|
||||
sendEvent(
|
||||
RegenerateOwnDeviceResult(device: device),
|
||||
@@ -864,17 +957,14 @@ Future<void> performMessageRetraction(
|
||||
true,
|
||||
);
|
||||
if (command.conversationJid != '') {
|
||||
(GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!)
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageRetraction: MessageRetractionData(
|
||||
command.originId,
|
||||
t.messages.retractedFallback,
|
||||
),
|
||||
),
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(command.conversationJid),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageRetractionData(command.originId, t.messages.retractedFallback),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -899,9 +989,9 @@ Future<void> performMarkConversationAsRead(
|
||||
sendEvent(ConversationUpdatedEvent(conversation: conversation));
|
||||
|
||||
if (conversation.lastMessage != null) {
|
||||
await GetIt.I.get<XmppService>().sendReadMarker(
|
||||
command.conversationJid,
|
||||
conversation.lastMessage!.sid,
|
||||
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||
conversation.lastMessage!.id,
|
||||
conversation.type != ConversationType.note,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -916,26 +1006,10 @@ Future<void> performMarkMessageAsRead(
|
||||
MarkMessageAsReadCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
final conversation = await cs.createOrUpdateConversation(
|
||||
command.conversationJid,
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
command.conversationJid,
|
||||
unreadCounter: command.newUnreadCounter,
|
||||
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||
command.id,
|
||||
command.sendMarker,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (conversation != null) {
|
||||
sendEvent(ConversationUpdatedEvent(conversation: conversation));
|
||||
|
||||
await GetIt.I.get<XmppService>().sendReadMarker(
|
||||
command.conversationJid,
|
||||
command.sid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> performAddMessageReaction(
|
||||
@@ -956,24 +1030,25 @@ Future<void> performAddMessageReaction(
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
|
||||
// Send the reaction
|
||||
GetIt.I
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints:
|
||||
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(command.conversationJid),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageReactionsData(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
);
|
||||
),
|
||||
const MarkableData(false),
|
||||
MessageProcessingHintData([
|
||||
if (!msg.containsNoStore) MessageProcessingHint.store,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -995,24 +1070,25 @@ Future<void> performRemoveMessageReaction(
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
|
||||
// Send the reaction
|
||||
GetIt.I
|
||||
final manager = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<MessageManager>(messageManager)!
|
||||
.sendMessage(
|
||||
MessageDetails(
|
||||
to: command.conversationJid,
|
||||
messageReactions: MessageReactions(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
),
|
||||
requestChatMarkers: false,
|
||||
messageProcessingHints:
|
||||
!msg.containsNoStore ? [MessageProcessingHint.store] : null,
|
||||
.getManagerById<MessageManager>(messageManager)!;
|
||||
await manager.sendMessage(
|
||||
JID.fromString(command.conversationJid),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageReactionsData(
|
||||
msg.originId ?? msg.sid,
|
||||
await rs.getReactionsForMessageByJid(
|
||||
command.messageId,
|
||||
jid,
|
||||
),
|
||||
);
|
||||
),
|
||||
const MarkableData(false),
|
||||
MessageProcessingHintData([
|
||||
if (!msg.containsNoStore) MessageProcessingHint.store,
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1020,9 +1096,9 @@ Future<void> performMarkDeviceVerified(
|
||||
MarkOmemoDeviceAsVerifiedCommand command, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
await GetIt.I.get<OmemoService>().verifyDevice(
|
||||
command.deviceId,
|
||||
await GetIt.I.get<OmemoService>().setDeviceVerified(
|
||||
command.jid,
|
||||
command.deviceId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1129,6 +1205,8 @@ Future<void> performFetchStickerPack(
|
||||
stickerPack.hashValue,
|
||||
stickerPack.restricted,
|
||||
false,
|
||||
0,
|
||||
0,
|
||||
),
|
||||
),
|
||||
id: id,
|
||||
@@ -1248,3 +1326,125 @@ Future<void> performGetReactions(
|
||||
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:path/path.dart' as path;
|
||||
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.
|
||||
class FileMetadataWrapper {
|
||||
@@ -67,7 +68,8 @@ Future<String> computeCachedPathForFile(
|
||||
return path.join(
|
||||
basePath,
|
||||
hash != null
|
||||
? '$hash.$ext'
|
||||
// NOTE: [ext] already includes a leading "."
|
||||
? '$hash$ext'
|
||||
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
|
||||
);
|
||||
}
|
||||
@@ -89,6 +91,10 @@ class FilesService {
|
||||
'value': hash.value,
|
||||
'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 –
|
||||
/// by the body of the quoted message. For non-media messages, we always use
|
||||
/// the body as fallback.
|
||||
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
|
||||
if (quotedMessage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String createFallbackBodyForQuotedMessage(Message quotedMessage) {
|
||||
if (quotedMessage.isMedia) {
|
||||
// Create formatted size string, if size is stored
|
||||
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 cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
@@ -190,7 +193,7 @@ class HttpFileTransferService {
|
||||
);
|
||||
} catch (ex) {
|
||||
_log.warning('Encrypting ${job.path} failed: $ex');
|
||||
await _fileUploadFailed(job, messageFailedToEncryptFile);
|
||||
await _fileUploadFailed(job, MessageErrorType.failedToEncryptFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -209,7 +212,7 @@ class HttpFileTransferService {
|
||||
|
||||
if (slotResult.isType<HttpFileUploadError>()) {
|
||||
_log.severe('Failed to request upload slot for ${job.path}!');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||
return;
|
||||
}
|
||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||
@@ -236,7 +239,7 @@ class HttpFileTransferService {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (!isRequestOkay(uploadStatusCode)) {
|
||||
_log.severe('Upload failed due to status code $uploadStatusCode');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
@@ -324,7 +327,7 @@ class HttpFileTransferService {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
errorType: noError,
|
||||
errorType: null,
|
||||
isUploading: false,
|
||||
fileMetadata: metadata,
|
||||
);
|
||||
@@ -338,14 +341,13 @@ class HttpFileTransferService {
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
JID.fromString(recipient),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageBodyData(slot.getUrl),
|
||||
const MessageDeliveryReceiptData(true),
|
||||
StableIdData(msg.originId, null),
|
||||
StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
@@ -353,11 +355,12 @@ class HttpFileTransferService {
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
[source],
|
||||
includeOOBFallback: true,
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
),
|
||||
FileUploadNotificationReplacementData(oldSid),
|
||||
MessageIdData(msg.sid),
|
||||
]),
|
||||
);
|
||||
_log.finest(
|
||||
'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>();
|
||||
|
||||
// Notify UI of download failure
|
||||
@@ -451,7 +457,7 @@ class HttpFileTransferService {
|
||||
_log.warning(
|
||||
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
||||
);
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
await _fileDownloadFailed(job, MessageErrorType.fileDownloadFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -479,7 +485,7 @@ class HttpFileTransferService {
|
||||
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -488,7 +494,7 @@ class HttpFileTransferService {
|
||||
_log.warning(
|
||||
'Decryption of $downloadPath ($downloadedPath) failed: $ex',
|
||||
);
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -571,15 +577,13 @@ class HttpFileTransferService {
|
||||
);
|
||||
|
||||
// Only add the hash pointers if the file hashes match what was sent
|
||||
if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
||||
if (integrityCheckPassed) {
|
||||
await fs.createMetadataHashEntries(
|
||||
job.location.plaintextHashes!,
|
||||
job.metadataId,
|
||||
);
|
||||
} else {
|
||||
_log.warning('Integrity check failed for file');
|
||||
}
|
||||
if ((job.location.plaintextHashes?.isNotEmpty ?? false) &&
|
||||
integrityCheckPassed &&
|
||||
job.createMetadataHashes) {
|
||||
await fs.createMetadataHashEntries(
|
||||
job.location.plaintextHashes!,
|
||||
job.metadataId,
|
||||
);
|
||||
}
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
@@ -591,7 +595,7 @@ class HttpFileTransferService {
|
||||
warningType:
|
||||
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
||||
errorType: conversation.encrypted && !decryptionKeysAvailable
|
||||
? messageChatEncryptedButFileNot
|
||||
? MessageErrorType.chatEncryptedButPlaintextFile
|
||||
: null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
@@ -55,15 +55,32 @@ class FileDownloadJob {
|
||||
this.location,
|
||||
this.mId,
|
||||
this.metadataId,
|
||||
this.createMetadataHashes,
|
||||
this.conversationJid,
|
||||
this.mimeGuess, {
|
||||
this.shouldShowNotification = true,
|
||||
});
|
||||
|
||||
/// The location where the file can be found.
|
||||
final MediaFileLocation location;
|
||||
|
||||
/// The id of the message associated with the download.
|
||||
final int mId;
|
||||
|
||||
/// The id of the file metadata describing the file.
|
||||
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;
|
||||
|
||||
/// A guess to the files's MIME type.
|
||||
final String? mimeGuess;
|
||||
|
||||
/// Flag indicating whether a notification should be shown after successful download.
|
||||
final bool shouldShowNotification;
|
||||
|
||||
@override
|
||||
|
||||
@@ -10,8 +10,10 @@ import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/cache.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.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
|
||||
/// 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(
|
||||
String jid,
|
||||
String? jid,
|
||||
bool olderThan,
|
||||
int? oldestTimestamp,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final comparator = olderThan ? '<' : '>';
|
||||
final queryPrefix = jid != null ? 'conversationJid = ? AND' : '';
|
||||
final query = oldestTimestamp != null
|
||||
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||
: 'conversationJid = ? AND file_metadata_id IS NOT NULL';
|
||||
? 'file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||
: 'file_metadata_id IS NOT NULL';
|
||||
final rawMessages = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
@@ -279,11 +283,26 @@ SELECT
|
||||
fm.cipherTextHashes as fm_cipherTextHashes,
|
||||
fm.filename as fm_filename,
|
||||
fm.size as fm_size
|
||||
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg
|
||||
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id;
|
||||
FROM
|
||||
(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,
|
||||
],
|
||||
);
|
||||
@@ -324,12 +343,12 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
String? originId,
|
||||
String? quoteId,
|
||||
FileMetadata? fileMetadata,
|
||||
int? errorType,
|
||||
MessageErrorType? errorType,
|
||||
int? warningType,
|
||||
bool isDownloading = false,
|
||||
bool isUploading = false,
|
||||
String? stickerPackId,
|
||||
int? pseudoMessageType,
|
||||
PseudoMessageType? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
bool received = false,
|
||||
bool displayed = false,
|
||||
@@ -444,7 +463,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
m['acked'] = boolToInt(acked);
|
||||
}
|
||||
if (errorType != notSpecified) {
|
||||
m['errorType'] = errorType as int?;
|
||||
m['errorType'] = (errorType as MessageErrorType?)?.value;
|
||||
}
|
||||
if (warningType != notSpecified) {
|
||||
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 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.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 {
|
||||
@override
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
@@ -45,6 +65,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
// Remove stale items
|
||||
for (final jid in removed) {
|
||||
await rs.removeRosterItemByJid(jid);
|
||||
await updateConversation(jid, true);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
@@ -54,21 +75,23 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
// Skip adding items twice
|
||||
if (exists) continue;
|
||||
|
||||
rosterAdded.add(
|
||||
await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@').first,
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
),
|
||||
final newRosterItem = await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
item.name ?? item.jid.split('@').first,
|
||||
item.subscription,
|
||||
item.ask ?? '',
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
);
|
||||
rosterAdded.add(newRosterItem);
|
||||
|
||||
// Update the cached conversation item
|
||||
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||
}
|
||||
|
||||
// Update modified items
|
||||
@@ -80,15 +103,17 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterModified.add(
|
||||
await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
),
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
);
|
||||
rosterModified.add(newRosterItem);
|
||||
|
||||
// Update the cached conversation item
|
||||
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||
}
|
||||
|
||||
// Tell the UI
|
||||
|
||||
@@ -5,10 +5,9 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.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/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
|
||||
@@ -36,19 +35,16 @@ class NotificationsService {
|
||||
MessageNotificationTappedEvent(
|
||||
conversationJid: action.payload!['conversationJid']!,
|
||||
title: action.payload!['title']!,
|
||||
avatarUrl: action.payload!['avatarUrl']!,
|
||||
avatarPath: action.payload!['avatarPath']!,
|
||||
),
|
||||
);
|
||||
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
|
||||
// TODO(Unknown): Maybe refactor this call such that we don't have to use
|
||||
// a command.
|
||||
await performMarkMessageAsRead(
|
||||
MarkMessageAsReadCommand(
|
||||
conversationJid: action.payload!['conversationJid']!,
|
||||
sid: action.payload!['sid']!,
|
||||
newUnreadCounter: 0,
|
||||
),
|
||||
);
|
||||
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||
int.parse(action.payload!['id']!),
|
||||
// [XmppService.sendReadMarker] will check whether the *SHOULD* send
|
||||
// the marker, i.e. if the privacy settings allow it.
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
logger.warning(
|
||||
'Received unknown notification action key ${action.buttonKeyPressed}',
|
||||
@@ -110,8 +106,8 @@ class NotificationsService {
|
||||
final title =
|
||||
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
|
||||
final avatarPath = contactIntegrationEnabled
|
||||
? c.contactAvatarPath ?? c.avatarUrl
|
||||
: c.avatarUrl;
|
||||
? c.contactAvatarPath ?? c.avatarPath
|
||||
: c.avatarPath;
|
||||
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
@@ -131,7 +127,8 @@ class NotificationsService {
|
||||
'conversationJid': c.jid,
|
||||
'sid': m.sid,
|
||||
'title': title,
|
||||
'avatarUrl': avatarPath,
|
||||
'avatarPath': avatarPath,
|
||||
'messageId': m.id.toString(),
|
||||
},
|
||||
),
|
||||
actionButtons: [
|
||||
|
||||
@@ -1,213 +1,43 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' 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/moxxmpp/omemo.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/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.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 {
|
||||
/// Logger.
|
||||
final Logger _log = Logger('OmemoService');
|
||||
|
||||
/// Flag indicating whether we are initialized.
|
||||
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();
|
||||
|
||||
/// Queue for code that is waiting on the service initialization.
|
||||
final Queue<Completer<void>> _waitingForInitialization =
|
||||
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 {
|
||||
final done = await _lock.synchronized(() => _initialized);
|
||||
if (done) return;
|
||||
|
||||
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(),
|
||||
);
|
||||
/// Access the underlying [OmemoManager].
|
||||
Future<OmemoManager> getOmemoManager() async {
|
||||
await ensureInitialized();
|
||||
return _omemoManager;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
await _saveOmemoDeviceList(deviceMap);
|
||||
/// Creates or loads the [OmemoManager] for the JID [jid].
|
||||
Future<void> initializeIfNeeded(String jid) async {
|
||||
final done = await _lock.synchronized(() {
|
||||
// Do nothing if we're already initialized
|
||||
if (_initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lock the execution if we're not yet running.
|
||||
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...');
|
||||
}
|
||||
|
||||
final om = GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.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<void> commitDevice(OmemoDevice device) async {
|
||||
await _saveOmemoDevice(device);
|
||||
}
|
||||
|
||||
/// Requests our device list and checks if the current device is in it. If not, then
|
||||
/// it will be published.
|
||||
Future<Object?> publishDeviceIfNeeded() async {
|
||||
Future<moxxmpp.OmemoError?> publishDeviceIfNeeded() async {
|
||||
_log.finest('publishDeviceIfNeeded: Waiting for initialization...');
|
||||
await ensureInitialized();
|
||||
_log.finest('publishDeviceIfNeeded: Done');
|
||||
|
||||
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||
final omemo =
|
||||
conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
conn.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
|
||||
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||
final bareJid = conn.connectionSettings.jid.toBare();
|
||||
final device = await omemoManager.getDevice();
|
||||
final device = await _omemoManager.getDevice();
|
||||
|
||||
final bundlesRaw = await dm.discoItemsQuery(
|
||||
bareJid,
|
||||
@@ -256,7 +138,7 @@ class OmemoService {
|
||||
);
|
||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||
await omemo.publishBundle(await device.toBundle());
|
||||
return bundlesRaw.get<moxxmpp.DiscoError>();
|
||||
return null;
|
||||
}
|
||||
|
||||
final bundleIds = bundlesRaw
|
||||
@@ -285,469 +167,114 @@ class OmemoService {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
|
||||
final bareJid = jid.toBare().toString();
|
||||
final allDevicesRaw = await GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.retrieveDeviceBundles(jid);
|
||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
||||
final map = <int, String>{};
|
||||
final items = List<OmemoCacheTriple>.empty(growable: true);
|
||||
for (final device in allDevices) {
|
||||
final curveIk = await device.ik.toCurve25519();
|
||||
final fingerprint = HEX.encode(await curveIk.getBytes());
|
||||
map[device.id] = fingerprint;
|
||||
items.add(OmemoCacheTriple(bareJid, device.id, fingerprint));
|
||||
}
|
||||
|
||||
// Cache them in memory
|
||||
_fingerprintCache[bareJid] = map;
|
||||
|
||||
// Cache them in the database
|
||||
await _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 {
|
||||
Future<void> onNewConnection() async {
|
||||
await ensureInitialized();
|
||||
|
||||
// Get finger prints if we have to
|
||||
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
|
||||
|
||||
final keys = List<model.OmemoDevice>.empty(growable: true);
|
||||
final tm =
|
||||
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(jid);
|
||||
|
||||
if (!_fingerprintCache.containsKey(jid)) return [];
|
||||
for (final deviceId in _fingerprintCache[jid]!.keys) {
|
||||
keys.add(
|
||||
model.OmemoDevice(
|
||||
_fingerprintCache[jid]![deviceId]!,
|
||||
await tm.isTrusted(jid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
await tm.isEnabled(jid, deviceId),
|
||||
deviceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return keys;
|
||||
await _omemoManager.onNewConnection();
|
||||
}
|
||||
|
||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||
await _saveTrustCache(
|
||||
json['trust']! as Map<String, int>,
|
||||
);
|
||||
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 {
|
||||
Future<List<model.OmemoDevice>> getFingerprintsForJid(String jid) async {
|
||||
await ensureInitialized();
|
||||
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
||||
}
|
||||
final fingerprints = await _omemoManager.getFingerprintsForJid(jid) ?? [];
|
||||
var trust = <int, BTBVTrustData>{};
|
||||
|
||||
Future<void> removeAllSessions(String jid) async {
|
||||
await ensureInitialized();
|
||||
await omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
Future<int> getDeviceId() async {
|
||||
await ensureInitialized();
|
||||
return omemoManager.getDeviceId();
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Get fingerprints if we have to
|
||||
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(
|
||||
await _omemoManager.withTrustManager(
|
||||
jid,
|
||||
deviceId,
|
||||
BTBVTrustState.verified,
|
||||
(tm) async {
|
||||
trust = await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||
.getDevicesTrust(jid);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
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> _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,
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
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),
|
||||
);
|
||||
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);
|
||||
});
|
||||
|
||||
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<void> removeAllRatchets(String jid) async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
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<OmemoDevice> getDevice() async {
|
||||
await ensureInitialized();
|
||||
return _omemoManager.getDevice();
|
||||
}
|
||||
|
||||
Future<void> _saveTrustDeviceList(Map<String, List<int>> list) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
Future<model.OmemoDevice> regenerateDevice() async {
|
||||
await ensureInitialized();
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
final oldDeviceId = (await getDevice()).id;
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
// Generate the new device
|
||||
final newDevice = await _omemoManager.regenerateDevice();
|
||||
|
||||
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],
|
||||
// Remove the old device
|
||||
unawaited(
|
||||
GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!
|
||||
.deleteDevice(oldDeviceId),
|
||||
);
|
||||
|
||||
return rawItems.map((item) {
|
||||
return OmemoCacheTriple(
|
||||
jid,
|
||||
item['id']! as int,
|
||||
item['fingerprint']! as String,
|
||||
);
|
||||
}).toList();
|
||||
return model.OmemoDevice(
|
||||
await newDevice.getFingerprint(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
newDevice.id,
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a pseudo-message of type [type] to the chat with [conversationJid].
|
||||
/// Also sends an event to the UI.
|
||||
Future<void> addPseudoMessage(
|
||||
String conversationJid,
|
||||
PseudoMessageType type,
|
||||
int ratchetsAdded,
|
||||
int ratchetsReplaced,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final message = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
'',
|
||||
conversationJid,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
pseudoMessageType: type,
|
||||
pseudoMessageData: {
|
||||
'ratchetsAdded': ratchetsAdded,
|
||||
'ratchetsReplaced': ratchetsReplaced,
|
||||
},
|
||||
);
|
||||
sendEvent(
|
||||
MessageAddedEvent(
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
@@ -32,7 +31,7 @@ class RosterService {
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||
Future<RosterItem> addRosterItemFromData(
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@@ -47,7 +46,7 @@ class RosterService {
|
||||
// TODO(PapaTutuWawa): Handle groups
|
||||
final i = RosterItem(
|
||||
-1,
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
avatarHash,
|
||||
jid,
|
||||
title,
|
||||
@@ -76,7 +75,7 @@ class RosterService {
|
||||
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
|
||||
Future<RosterItem> updateRosterItem(
|
||||
int id, {
|
||||
String? avatarUrl,
|
||||
String? avatarPath,
|
||||
String? avatarHash,
|
||||
String? title,
|
||||
String? subscription,
|
||||
@@ -89,8 +88,8 @@ class RosterService {
|
||||
}) async {
|
||||
final i = <String, dynamic>{};
|
||||
|
||||
if (avatarUrl != null) {
|
||||
i['avatarUrl'] = avatarUrl;
|
||||
if (avatarPath != null) {
|
||||
i['avatarPath'] = avatarPath;
|
||||
}
|
||||
if (avatarHash != null) {
|
||||
i['avatarHash'] = avatarHash;
|
||||
@@ -197,7 +196,7 @@ class RosterService {
|
||||
/// and, if it was successful, create the database entry. Returns the
|
||||
/// [RosterItem] model object.
|
||||
Future<RosterItem> addToRosterWrapper(
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@@ -205,7 +204,7 @@ class RosterService {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactId = await css.getContactIdForJid(jid);
|
||||
final item = await addRosterItemFromData(
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
avatarHash,
|
||||
jid,
|
||||
title,
|
||||
@@ -217,14 +216,19 @@ class RosterService {
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
final result = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getRosterManager()!
|
||||
.addToRoster(jid, title);
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final result = await conn.getRosterManager()!.addToRoster(jid, title);
|
||||
if (!result) {
|
||||
// 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]));
|
||||
return item;
|
||||
}
|
||||
@@ -236,14 +240,14 @@ class RosterService {
|
||||
String jid, {
|
||||
bool unsubscribe = true,
|
||||
}) 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);
|
||||
if (result == RosterRemovalResult.okay ||
|
||||
result == RosterRemovalResult.itemNotFound) {
|
||||
if (unsubscribe) {
|
||||
GetIt.I
|
||||
.get<SubscriptionRequestService>()
|
||||
.sendUnsubscriptionRequest(jid);
|
||||
await pm.unsubscribe(JID.fromString(jid));
|
||||
}
|
||||
|
||||
_log.finest('Removing from roster maybe worked. Removing from database');
|
||||
@@ -253,4 +257,25 @@ class RosterService {
|
||||
|
||||
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/message.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/socket.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/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/share.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_state.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
@@ -175,11 +175,10 @@ Future<void> entrypoint() async {
|
||||
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||
GetIt.I.registerSingleton<StickersService>(StickersService());
|
||||
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
|
||||
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
||||
SubscriptionRequestService(),
|
||||
);
|
||||
GetIt.I.registerSingleton<FilesService>(FilesService());
|
||||
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
||||
GetIt.I.registerSingleton<StorageService>(StorageService());
|
||||
GetIt.I.registerSingleton<ShareService>(ShareService());
|
||||
final xmpp = XmppService();
|
||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||
|
||||
@@ -211,10 +210,14 @@ Future<void> entrypoint() async {
|
||||
StreamManagementNegotiator(),
|
||||
CSINegotiator(),
|
||||
RosterFeatureNegotiator(),
|
||||
PresenceNegotiator(),
|
||||
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
||||
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
||||
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
||||
SaslPlainNegotiator(),
|
||||
Sasl2Negotiator(),
|
||||
Bind2Negotiator(),
|
||||
FASTSaslNegotiator(),
|
||||
]);
|
||||
await connection.registerManagers([
|
||||
MoxxyStreamManagementManager(),
|
||||
@@ -222,7 +225,12 @@ Future<void> entrypoint() async {
|
||||
const Identity(category: 'client', type: 'phone', name: 'Moxxy'),
|
||||
]),
|
||||
RosterManager(MoxxyRosterStateManager()),
|
||||
MoxxyOmemoManager(),
|
||||
OmemoManager(
|
||||
GetIt.I.get<OmemoService>().getOmemoManager,
|
||||
(toJid, _) async => GetIt.I
|
||||
.get<ConversationService>()
|
||||
.shouldEncryptForConversation(toJid),
|
||||
),
|
||||
PingManager(const Duration(minutes: 3)),
|
||||
MessageManager(),
|
||||
PresenceManager(),
|
||||
@@ -230,7 +238,6 @@ Future<void> entrypoint() async {
|
||||
CSIManager(),
|
||||
CarbonsManager(),
|
||||
PubSubManager(),
|
||||
VCardManager(),
|
||||
UserAvatarManager(),
|
||||
StableIdManager(),
|
||||
MessageDeliveryReceiptManager(),
|
||||
@@ -249,6 +256,7 @@ Future<void> entrypoint() async {
|
||||
LastMessageCorrectionManager(),
|
||||
MessageReactionsManager(),
|
||||
StickersManager(),
|
||||
MessageProcessingHintManager(),
|
||||
]);
|
||||
|
||||
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/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.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;
|
||||
|
||||
class StickersService {
|
||||
final Map<String, StickerPack> _stickerPacks = {};
|
||||
final Logger _log = Logger('StickersService');
|
||||
|
||||
Future<StickerPack?> getStickerPackById(String id) async {
|
||||
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
|
||||
/// Computes the total amount of storage occupied by the stickers in the sticker
|
||||
/// 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 rawPack = await db.query(
|
||||
stickerPacksTable,
|
||||
@@ -59,13 +88,23 @@ SELECT
|
||||
fm.cipherTextHashes AS fm_cipherTextHashes,
|
||||
fm.filename AS fm_filename,
|
||||
fm.size AS fm_size
|
||||
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id;
|
||||
FROM
|
||||
(SELECT
|
||||
*
|
||||
FROM
|
||||
$stickersTable
|
||||
WHERE
|
||||
stickerPackId = ?
|
||||
) AS sticker
|
||||
JOIN
|
||||
$fileMetadataTable fm
|
||||
ON
|
||||
sticker.file_metadata_id = fm.id;
|
||||
''',
|
||||
[id],
|
||||
);
|
||||
|
||||
_stickerPacks[id] = StickerPack.fromDatabaseJson(
|
||||
final stickerPack = StickerPack.fromDatabaseJson(
|
||||
rawPack.first,
|
||||
rawStickers.map((sticker) {
|
||||
return Sticker.fromDatabaseJson(
|
||||
@@ -75,28 +114,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
).copyWith(
|
||||
size: await getStickerPackSizeById(id),
|
||||
);
|
||||
|
||||
return _stickerPacks[id]!;
|
||||
}
|
||||
|
||||
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();
|
||||
return stickerPack;
|
||||
}
|
||||
|
||||
Future<void> removeStickerPack(String id) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final pack = await getStickerPackById(id);
|
||||
assert(pack != null, 'The sticker pack must exist');
|
||||
|
||||
@@ -117,15 +143,17 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
}
|
||||
|
||||
// Remove from the database
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
await db.delete(
|
||||
stickersTable,
|
||||
where: 'stickerPackId = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await db.delete(
|
||||
stickerPacksTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
// Remove from the cache
|
||||
_stickerPacks.remove(id);
|
||||
|
||||
// Retract from PubSub
|
||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||
final result = await GetIt.I
|
||||
@@ -238,34 +266,35 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
);
|
||||
|
||||
// Get file metadata
|
||||
final fileMetadataRaw =
|
||||
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
sticker.fileMetadata.sourceUrls!,
|
||||
p.basename(stickerPath),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sticker.fileMetadata.plaintextHashes,
|
||||
null,
|
||||
sticker.fileMetadata.size,
|
||||
),
|
||||
sticker.fileMetadata.mimeType,
|
||||
sticker.fileMetadata.size,
|
||||
sticker.fileMetadata.width != null &&
|
||||
sticker.fileMetadata.height != null
|
||||
? Size(
|
||||
sticker.fileMetadata.width!.toDouble(),
|
||||
sticker.fileMetadata.height!.toDouble(),
|
||||
)
|
||||
: null,
|
||||
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||
null,
|
||||
null,
|
||||
path: stickerPath,
|
||||
);
|
||||
final fs = GetIt.I.get<FilesService>();
|
||||
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
sticker.fileMetadata.sourceUrls!,
|
||||
p.basename(stickerPath),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sticker.fileMetadata.plaintextHashes,
|
||||
null,
|
||||
sticker.fileMetadata.size,
|
||||
),
|
||||
sticker.fileMetadata.mimeType,
|
||||
sticker.fileMetadata.size,
|
||||
sticker.fileMetadata.width != null &&
|
||||
sticker.fileMetadata.height != null
|
||||
? Size(
|
||||
sticker.fileMetadata.width!.toDouble(),
|
||||
sticker.fileMetadata.height!.toDouble(),
|
||||
)
|
||||
: null,
|
||||
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||
null,
|
||||
null,
|
||||
path: stickerPath,
|
||||
);
|
||||
|
||||
if (!fileMetadataRaw.retrieved) {
|
||||
if (!fileMetadataRaw.retrieved &&
|
||||
fileMetadataRaw.fileMetadata.path == null) {
|
||||
final downloadStatusCode = await downloadFile(
|
||||
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
|
||||
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(
|
||||
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
remotePack.hashValue,
|
||||
sticker.desc,
|
||||
sticker.suggests,
|
||||
fileMetadataRaw.fileMetadata,
|
||||
fm,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -387,11 +425,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
pack.hashValue,
|
||||
pack.restricted,
|
||||
true,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
0,
|
||||
);
|
||||
await _addStickerPackFromData(stickerPack);
|
||||
|
||||
// Add all stickers
|
||||
var size = 0;
|
||||
final stickers = List<Sticker>.empty(growable: true);
|
||||
final fs = GetIt.I.get<FilesService>();
|
||||
for (final sticker in pack.stickers) {
|
||||
// Get the "path" to the sticker
|
||||
final stickerPath = await computeCachedPathForFile(
|
||||
@@ -404,39 +446,69 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||
.map((src) => src.url)
|
||||
.toList();
|
||||
final fileMetadataRaw = await GetIt.I
|
||||
.get<FilesService>()
|
||||
.createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
urlSources,
|
||||
p.basename(stickerPath),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sticker.metadata.hashes,
|
||||
null,
|
||||
sticker.metadata.size,
|
||||
),
|
||||
sticker.metadata.mediaType,
|
||||
sticker.metadata.size,
|
||||
sticker.metadata.width != null && sticker.metadata.height != null
|
||||
? Size(
|
||||
sticker.metadata.width!.toDouble(),
|
||||
sticker.metadata.height!.toDouble(),
|
||||
)
|
||||
: null,
|
||||
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||
null,
|
||||
null,
|
||||
path: stickerPath,
|
||||
);
|
||||
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
urlSources,
|
||||
p.basename(stickerPath),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
sticker.metadata.hashes,
|
||||
null,
|
||||
sticker.metadata.size,
|
||||
),
|
||||
sticker.metadata.mediaType,
|
||||
sticker.metadata.size,
|
||||
sticker.metadata.width != null && sticker.metadata.height != null
|
||||
? Size(
|
||||
sticker.metadata.width!.toDouble(),
|
||||
sticker.metadata.height!.toDouble(),
|
||||
)
|
||||
: null,
|
||||
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||
null,
|
||||
null,
|
||||
path: stickerPath,
|
||||
);
|
||||
|
||||
// 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!)!;
|
||||
await File(stickerPath).writeAsBytes(
|
||||
final file = File(stickerPath);
|
||||
await file.writeAsBytes(
|
||||
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(
|
||||
@@ -446,18 +518,16 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
pack.hashValue,
|
||||
sticker.metadata.desc!,
|
||||
sticker.suggests,
|
||||
fileMetadataRaw.fileMetadata,
|
||||
fm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final stickerPackWithStickers = stickerPack.copyWith(
|
||||
stickers: stickers,
|
||||
size: size,
|
||||
);
|
||||
|
||||
// Add it to the cache
|
||||
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
|
||||
|
||||
_log.info(
|
||||
'Sticker pack ${stickerPack.id} successfully added to the database',
|
||||
);
|
||||
@@ -466,4 +536,110 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
unawaited(_publishStickerPack(pack));
|
||||
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:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/shared/models/xmpp_state.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 {
|
||||
/// Persistent state around the connection, like the SM token, etc.
|
||||
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 {
|
||||
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:moxxyv2/shared/models/message.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.
|
||||
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:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
const unspecifiedError = -1;
|
||||
const noError = 0;
|
||||
const fileUploadFailedError = 1;
|
||||
const messageNotEncryptedForDevice = 2;
|
||||
const messageInvalidHMAC = 3;
|
||||
const messageNoDecryptionKey = 4;
|
||||
const messageInvalidAffixElements = 5;
|
||||
// const messageInvalidNumber = 6;
|
||||
const messageFailedToEncrypt = 7;
|
||||
const messageFailedToDecryptFile = 8;
|
||||
const messageContactDoesNotSupportOmemo = 9;
|
||||
const messageChatEncryptedButFileNot = 10;
|
||||
const messageFailedToEncryptFile = 11;
|
||||
const fileDownloadFailedError = 12;
|
||||
const messageServiceUnavailable = 13;
|
||||
const messageRemoteServerTimeout = 14;
|
||||
const messageRemoteServerNotFound = 15;
|
||||
enum ErrorType {
|
||||
unknown(-1),
|
||||
remoteServerNotFound(0),
|
||||
remoteServerTimeout(1);
|
||||
|
||||
int errorTypeFromException(dynamic exception) {
|
||||
if (exception == null) {
|
||||
return noError;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (exception is NoDecryptionKeyException) {
|
||||
return messageNoDecryptionKey;
|
||||
} else if (exception is InvalidMessageHMACException) {
|
||||
return messageInvalidHMAC;
|
||||
} else if (exception is NotEncryptedForDeviceException) {
|
||||
return messageNoDecryptionKey;
|
||||
} else if (exception is InvalidAffixElementsException) {
|
||||
return messageInvalidAffixElements;
|
||||
} else if (exception is EncryptionFailedException) {
|
||||
return messageFailedToEncrypt;
|
||||
} else if (exception is OmemoNotSupportedForContactException) {
|
||||
return messageContactDoesNotSupportOmemo;
|
||||
}
|
||||
|
||||
return unspecifiedError;
|
||||
/// The identifier value of this error type.
|
||||
final int value;
|
||||
}
|
||||
|
||||
String errorToTranslatableString(int error) {
|
||||
assert(
|
||||
error != noError,
|
||||
'Calling errorToTranslatableString with noError makes no sense',
|
||||
);
|
||||
enum MessageErrorType {
|
||||
unspecified(-1),
|
||||
// TODO(Unknown): Maybe remove
|
||||
noError(0),
|
||||
|
||||
switch (error) {
|
||||
case messageNotEncryptedForDevice:
|
||||
return t.errors.omemo.notEncryptedForDevice;
|
||||
case messageInvalidHMAC:
|
||||
return t.errors.omemo.invalidHmac;
|
||||
case messageNoDecryptionKey:
|
||||
return t.errors.omemo.noDecryptionKey;
|
||||
case messageInvalidAffixElements:
|
||||
return t.errors.omemo.messageInvalidAfixElement;
|
||||
case fileUploadFailedError:
|
||||
return t.errors.message.fileUploadFailed;
|
||||
case messageContactDoesNotSupportOmemo:
|
||||
return t.errors.message.contactDoesntSupportOmemo;
|
||||
case fileDownloadFailedError:
|
||||
return t.errors.message.fileDownloadFailed;
|
||||
case messageServiceUnavailable:
|
||||
return t.errors.message.serviceUnavailable;
|
||||
case messageRemoteServerTimeout:
|
||||
return t.errors.message.remoteServerTimeout;
|
||||
case messageRemoteServerNotFound:
|
||||
return t.errors.message.remoteServerNotFound;
|
||||
case messageFailedToEncrypt:
|
||||
return t.errors.message.failedToEncrypt;
|
||||
case messageFailedToDecryptFile:
|
||||
return t.errors.message.failedToDecryptFile;
|
||||
case messageChatEncryptedButFileNot:
|
||||
return t.errors.message.fileNotEncrypted;
|
||||
case messageFailedToEncryptFile:
|
||||
return t.errors.message.failedToEncryptFile;
|
||||
case unspecifiedError:
|
||||
return t.errors.message.unspecified;
|
||||
/// 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;
|
||||
}
|
||||
|
||||
assert(false, 'Invalid error code $error used');
|
||||
return '';
|
||||
static MessageErrorType? fromException(dynamic exception) {
|
||||
if (exception == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (exception is InvalidMessageHMACError) {
|
||||
return MessageErrorType.invalidHMAC;
|
||||
} else if (exception is NotEncryptedForDeviceError) {
|
||||
return MessageErrorType.noDecryptionKey;
|
||||
} else if (exception is InvalidAffixElementsException) {
|
||||
return MessageErrorType.invalidAffixElements;
|
||||
} else if (exception is EncryptionFailedException) {
|
||||
return MessageErrorType.failedToEncrypt;
|
||||
} else if (exception is OmemoNotSupportedForContactException) {
|
||||
return MessageErrorType.omemoNotSupported;
|
||||
}
|
||||
|
||||
return MessageErrorType.unspecified;
|
||||
}
|
||||
|
||||
/// The identifier representing the error.
|
||||
final int value;
|
||||
|
||||
String get translatableString {
|
||||
assert(
|
||||
this != MessageErrorType.noError,
|
||||
'Calling errorToTranslatableString with noError makes no sense',
|
||||
);
|
||||
|
||||
switch (this) {
|
||||
case MessageErrorType.notEncryptedForDevice:
|
||||
return t.errors.omemo.notEncryptedForDevice;
|
||||
case MessageErrorType.invalidHMAC:
|
||||
return t.errors.omemo.invalidHmac;
|
||||
case MessageErrorType.noDecryptionKey:
|
||||
return t.errors.omemo.noDecryptionKey;
|
||||
case MessageErrorType.invalidAffixElements:
|
||||
return t.errors.omemo.messageInvalidAfixElement;
|
||||
case MessageErrorType.fileUploadFailed:
|
||||
return t.errors.message.fileUploadFailed;
|
||||
case MessageErrorType.omemoNotSupported:
|
||||
return t.errors.message.contactDoesntSupportOmemo;
|
||||
case MessageErrorType.fileDownloadFailed:
|
||||
return t.errors.message.fileDownloadFailed;
|
||||
case MessageErrorType.serviceUnavailable:
|
||||
return t.errors.message.serviceUnavailable;
|
||||
case MessageErrorType.remoteServerTimeout:
|
||||
return t.errors.message.remoteServerTimeout;
|
||||
case MessageErrorType.remoteServerNotFound:
|
||||
return t.errors.message.remoteServerNotFound;
|
||||
case MessageErrorType.failedToEncrypt:
|
||||
return t.errors.message.failedToEncrypt;
|
||||
case MessageErrorType.failedToDecryptFile:
|
||||
return t.errors.message.failedToDecryptFile;
|
||||
case MessageErrorType.chatEncryptedButPlaintextFile:
|
||||
return t.errors.message.fileNotEncrypted;
|
||||
case MessageErrorType.failedToEncryptFile:
|
||||
return t.errors.message.failedToEncryptFile;
|
||||
// NOTE: This fallthrough is just here to make Dart happy
|
||||
case MessageErrorType.noError:
|
||||
case MessageErrorType.unspecified:
|
||||
return t.errors.message.unspecified;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
@@ -461,3 +462,15 @@ List<T> clampedListPrependAll<T>(List<T> list, List<T> items, int maxSize) {
|
||||
...list,
|
||||
].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
|
||||
ChatState fromJson(Map<String, dynamic> json) =>
|
||||
chatStateFromString(json['chatState'] as String);
|
||||
ChatState.fromName(json['chatState'] as String);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(ChatState state) => <String, String>{
|
||||
'chatState': chatStateToString(state),
|
||||
'chatState': state.toName(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,39 +40,90 @@ class ConversationMessageConverter
|
||||
}
|
||||
|
||||
enum ConversationType {
|
||||
@JsonValue('chat')
|
||||
chat,
|
||||
@JsonValue('note')
|
||||
note
|
||||
chat('chat'),
|
||||
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
|
||||
class Conversation with _$Conversation {
|
||||
factory Conversation(
|
||||
/// The title of the chat.
|
||||
String title,
|
||||
|
||||
// The newest message in the chat.
|
||||
@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,
|
||||
|
||||
// The number of unread messages.
|
||||
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
|
||||
int lastChangeTimestamp,
|
||||
// Indicates if the conversation should be shown on the homescreen
|
||||
|
||||
// Indicates if the conversation should be shown on the homescreen.
|
||||
bool open,
|
||||
// Indicates, if [jid] is a regular user, if the user is in the roster.
|
||||
bool inRoster,
|
||||
// The subscription state of the roster item
|
||||
String subscription,
|
||||
|
||||
/// Flag indicating whether the "add to roster" button should be shown.
|
||||
bool showAddToRoster,
|
||||
|
||||
// Whether the chat is muted (true = muted, false = not muted)
|
||||
bool muted,
|
||||
|
||||
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
|
||||
bool encrypted,
|
||||
|
||||
// The current chat state
|
||||
@ConversationChatStateConverter() ChatState chatState, {
|
||||
|
||||
// The id of the contact in the device's phonebook if it exists
|
||||
String? contactId,
|
||||
|
||||
// The path to the contact avatar, if available
|
||||
String? contactAvatarPath,
|
||||
|
||||
// The contact's display name, if it exists
|
||||
String? contactDisplayName,
|
||||
}) = _Conversation;
|
||||
@@ -85,16 +136,14 @@ class Conversation with _$Conversation {
|
||||
|
||||
factory Conversation.fromDatabaseJson(
|
||||
Map<String, dynamic> json,
|
||||
bool inRoster,
|
||||
String subscription,
|
||||
bool showAddToRoster,
|
||||
Message? lastMessage,
|
||||
) {
|
||||
return Conversation.fromJson({
|
||||
...json,
|
||||
'muted': intToBool(json['muted']! as int),
|
||||
'open': intToBool(json['open']! as int),
|
||||
'inRoster': inRoster,
|
||||
'subscription': subscription,
|
||||
'showAddToRoster': showAddToRoster,
|
||||
'encrypted': intToBool(json['encrypted']! as int),
|
||||
'chatState':
|
||||
const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||
@@ -107,8 +156,7 @@ class Conversation with _$Conversation {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('chatState')
|
||||
..remove('inRoster')
|
||||
..remove('subscription')
|
||||
..remove('showAddToRoster')
|
||||
..remove('lastMessage');
|
||||
|
||||
return {
|
||||
@@ -123,30 +171,47 @@ class Conversation with _$Conversation {
|
||||
/// True, when the chat state of the conversation indicates typing. False, if not.
|
||||
bool get isTyping => chatState == ChatState.composing;
|
||||
|
||||
/// The path to the avatar. This returns, if enabled, first the contact's avatar
|
||||
/// path, then the XMPP avatar's path. If not enabled, just returns the regular
|
||||
/// The path to the avatar. This returns, if [contactIntegration] is true, first the contact's avatar
|
||||
/// path, then the XMPP avatar's path. If [contactIntegration] is false, just returns the regular
|
||||
/// XMPP avatar's path.
|
||||
String? get avatarPathWithOptionalContact {
|
||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||
return contactAvatarPath ?? avatarUrl;
|
||||
String getAvatarPathWithOptionalContact(bool contactIntegration) {
|
||||
if (contactIntegration) {
|
||||
return contactAvatarPath ?? avatarPath;
|
||||
}
|
||||
|
||||
return avatarUrl;
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
/// The title of the chat. This returns, if enabled, first the contact's display
|
||||
/// name, then the XMPP chat title. If not enabled, just returns the XMPP chat
|
||||
/// This getter is a short-hand for [getAvatarPathWithOptionalContact] with the
|
||||
/// 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.
|
||||
String get titleWithOptionalContact {
|
||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||
String getTitleWithOptionalContact(bool contactIntegration) {
|
||||
if (contactIntegration) {
|
||||
return contactDisplayName ?? 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.
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@@ -9,19 +9,43 @@ import 'package:moxxyv2/shared/warning_types.dart';
|
||||
part 'message.freezed.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) {
|
||||
if (data == null) return <String, dynamic>{};
|
||||
/// Indicates that an existing device has been replaced.
|
||||
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;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
if (data.isEmpty) return null;
|
||||
/// A converter for converting between [PseudoMessageType] and [int].
|
||||
class PseudoMessageTypeConverter extends JsonConverter<PseudoMessageType, int> {
|
||||
const PseudoMessageTypeConverter();
|
||||
|
||||
return jsonEncode(data);
|
||||
@override
|
||||
PseudoMessageType fromJson(int json) {
|
||||
return PseudoMessageType.fromInt(json)!;
|
||||
}
|
||||
|
||||
@override
|
||||
int toJson(PseudoMessageType object) {
|
||||
return object.value;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -38,21 +62,31 @@ class Message with _$Message {
|
||||
bool encrypted,
|
||||
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||
bool containsNoStore, {
|
||||
int? errorType,
|
||||
@MessageErrorTypeConverter() MessageErrorType? errorType,
|
||||
int? warningType,
|
||||
FileMetadata? fileMetadata,
|
||||
@Default(false) bool isDownloading,
|
||||
@Default(false) bool isUploading,
|
||||
@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,
|
||||
|
||||
/// Specified whether the message has been acked using stream management, i.e. it was successfully sent to
|
||||
/// the server.
|
||||
@Default(false) bool acked,
|
||||
|
||||
/// Indicates whether the message has been retracted.
|
||||
@Default(false) bool isRetracted,
|
||||
|
||||
/// Indicates whether the message has been edited.
|
||||
@Default(false) bool isEdited,
|
||||
String? originId,
|
||||
Message? quotes,
|
||||
@Default([]) List<String> reactionsPreview,
|
||||
String? stickerPackId,
|
||||
int? pseudoMessageType,
|
||||
@PseudoMessageTypeConverter() PseudoMessageType? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}) = _Message;
|
||||
|
||||
@@ -82,8 +116,7 @@ class Message with _$Message {
|
||||
'isEdited': intToBool(json['isEdited']! as int),
|
||||
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||
'reactionsPreview': reactionsPreview,
|
||||
'pseudoMessageData':
|
||||
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
||||
'pseudoMessageData': (json['pseudoMessageData'] as String?)?.fromJson(),
|
||||
}).copyWith(
|
||||
quotes: quotes,
|
||||
fileMetadata: fileMetadata,
|
||||
@@ -113,12 +146,25 @@ class Message with _$Message {
|
||||
'isRetracted': boolToInt(isRetracted),
|
||||
'isEdited': boolToInt(isEdited),
|
||||
'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.
|
||||
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.
|
||||
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
|
||||
/// longpress menu.
|
||||
bool get errorMenuVisible {
|
||||
return hasError &&
|
||||
(errorType! < messageNotEncryptedForDevice ||
|
||||
errorType! > messageInvalidAffixElements);
|
||||
}
|
||||
bool get errorMenuVisible => hasError && !isOmemoError;
|
||||
|
||||
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
||||
/// images.
|
||||
@@ -201,9 +243,12 @@ class Message with _$Message {
|
||||
/// Returns true if the message can be copied to the clipboard.
|
||||
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;
|
||||
|
||||
/// True if the message is a media message
|
||||
/// True if the message is a media message.
|
||||
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 verified,
|
||||
bool enabled,
|
||||
int deviceId, {
|
||||
@Default(true) bool hasSessionWith,
|
||||
}) = _OmemoDevice;
|
||||
int deviceId,
|
||||
) = _OmemoDevice;
|
||||
|
||||
/// JSON
|
||||
factory OmemoDevice.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -8,7 +8,7 @@ part 'roster.g.dart';
|
||||
class RosterItem with _$RosterItem {
|
||||
factory RosterItem(
|
||||
int id,
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@@ -53,4 +53,24 @@ class RosterItem with _$RosterItem {
|
||||
'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,
|
||||
bool restricted,
|
||||
bool local,
|
||||
|
||||
/// The timestamp (milliseconds since epoch) when the sticker pack was added
|
||||
int addedTimestamp,
|
||||
|
||||
/// The size in bytes
|
||||
int size,
|
||||
) = _StickerPack;
|
||||
|
||||
const StickerPack._();
|
||||
@@ -34,6 +40,8 @@ class StickerPack with _$StickerPack {
|
||||
pack.hashValue,
|
||||
pack.restricted,
|
||||
local,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
/// JSON
|
||||
@@ -49,6 +57,7 @@ class StickerPack with _$StickerPack {
|
||||
'local': true,
|
||||
'restricted': intToBool(json['restricted']! as int),
|
||||
'stickers': <Sticker>[],
|
||||
'size': 0,
|
||||
});
|
||||
|
||||
return pack.copyWith(stickers: stickers);
|
||||
@@ -57,7 +66,8 @@ class StickerPack with _$StickerPack {
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
final json = toJson()
|
||||
..remove('local')
|
||||
..remove('stickers');
|
||||
..remove('stickers')
|
||||
..remove('size');
|
||||
|
||||
return {
|
||||
...json,
|
||||
|
||||
@@ -5,13 +5,27 @@ import 'package:moxxmpp/moxxmpp.dart';
|
||||
part 'xmpp_state.freezed.dart';
|
||||
part 'xmpp_state.g.dart';
|
||||
|
||||
extension StreamManagementStateToJson on StreamManagementState {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'c2s': c2s,
|
||||
's2c': s2c,
|
||||
'streamResumptionLocation': streamResumptionLocation,
|
||||
'streamResumptionId': streamResumptionId,
|
||||
};
|
||||
}
|
||||
|
||||
class StreamManagementStateConverter
|
||||
implements JsonConverter<StreamManagementState, Map<String, dynamic>> {
|
||||
const StreamManagementStateConverter();
|
||||
|
||||
@override
|
||||
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
|
||||
Map<String, dynamic> toJson(StreamManagementState state) => state.toJson();
|
||||
@@ -27,6 +41,7 @@ class XmppState with _$XmppState {
|
||||
String? displayName,
|
||||
String? password,
|
||||
String? lastRosterVersion,
|
||||
String? fastToken,
|
||||
@Default('') String avatarUrl,
|
||||
@Default('') String avatarHash,
|
||||
@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 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.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/sendfiles_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
|
||||
|
||||
part 'conversation_bloc.freezed.dart';
|
||||
part 'conversation_event.dart';
|
||||
@@ -47,8 +48,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
) async {
|
||||
final cb = GetIt.I.get<ConversationsBloc>();
|
||||
await cb.waitUntilInitialized();
|
||||
final conversation = firstWhereOrNull(
|
||||
cb.state.conversations,
|
||||
final conversation = cb.state.conversations.firstWhereOrNull(
|
||||
(Conversation c) => c.jid == event.jid,
|
||||
)!;
|
||||
emit(
|
||||
@@ -60,18 +60,22 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
),
|
||||
);
|
||||
|
||||
final arguments = ConversationPageArguments(
|
||||
event.jid,
|
||||
event.initialText,
|
||||
);
|
||||
final navEvent = event.removeUntilConversations
|
||||
? (PushedNamedAndRemoveUntilEvent(
|
||||
NavigationDestination(
|
||||
conversationRoute,
|
||||
arguments: event.jid,
|
||||
arguments: arguments,
|
||||
),
|
||||
ModalRoute.withName(conversationsRoute),
|
||||
))
|
||||
: (PushedNamedEvent(
|
||||
NavigationDestination(
|
||||
conversationRoute,
|
||||
arguments: event.jid,
|
||||
arguments: arguments,
|
||||
),
|
||||
));
|
||||
|
||||
@@ -102,7 +106,7 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversation: state.conversation!.copyWith(
|
||||
inRoster: true,
|
||||
showAddToRoster: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -22,12 +22,17 @@ class RequestedConversationEvent extends ConversationEvent {
|
||||
this.title,
|
||||
this.avatarUrl, {
|
||||
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 title;
|
||||
final String avatarUrl;
|
||||
final bool removeUntilConversations;
|
||||
|
||||
/// Initial value to put in the input field.
|
||||
final String? initialText;
|
||||
}
|
||||
|
||||
/// Triggered by the UI when a user should be blocked
|
||||
|
||||
@@ -21,6 +21,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
on<AvatarChangedEvent>(_onAvatarChanged);
|
||||
on<ConversationClosedEvent>(_onConversationClosed);
|
||||
on<ConversationMarkedAsReadEvent>(_onConversationMarkedAsRead);
|
||||
on<ConversationsSetEvent>(_onConversationsSet);
|
||||
}
|
||||
|
||||
// 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(
|
||||
displayName: event.displayName,
|
||||
jid: event.jid,
|
||||
avatarUrl: event.avatarUrl ?? '',
|
||||
avatarPath: event.avatarUrl ?? '',
|
||||
conversations: event.conversations..sort(compareConversation),
|
||||
),
|
||||
);
|
||||
@@ -118,7 +119,7 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
) async {
|
||||
return emit(
|
||||
state.copyWith(
|
||||
avatarUrl: event.path,
|
||||
avatarPath: event.path,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -154,4 +155,15 @@ class ConversationsBloc extends Bloc<ConversationsEvent, ConversationsState> {
|
||||
Conversation? getConversationByJid(String 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);
|
||||
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({
|
||||
@Default(<Conversation>[]) List<Conversation> conversations,
|
||||
@Default('') String displayName,
|
||||
@Default('') String avatarUrl,
|
||||
@Default('') String avatarPath,
|
||||
@Default('') String jid,
|
||||
}) = _ConversationsState;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
@@ -43,10 +43,11 @@ class NewConversationBloc
|
||||
final conversations = GetIt.I.get<ConversationsBloc>();
|
||||
|
||||
// Guard against an unneccessary roundtrip
|
||||
if (listContains(
|
||||
conversations.state.conversations,
|
||||
(Conversation c) => c.jid == event.jid,
|
||||
)) {
|
||||
final listContains = conversations.state.conversations.firstWhereOrNull(
|
||||
(Conversation c) => c.jid == event.jid,
|
||||
) !=
|
||||
null;
|
||||
if (listContains) {
|
||||
GetIt.I.get<conversation.ConversationBloc>().add(
|
||||
conversation.RequestedConversationEvent(
|
||||
event.jid,
|
||||
@@ -120,8 +121,7 @@ class NewConversationBloc
|
||||
if (event.removed.contains(item.jid)) continue;
|
||||
|
||||
// Handle modified items
|
||||
final modified = firstWhereOrNull(
|
||||
event.modified,
|
||||
final modified = event.modified.firstWhereOrNull(
|
||||
(RosterItem i) => i.id == item.id,
|
||||
);
|
||||
if (modified != null) {
|
||||
|
||||
@@ -87,17 +87,6 @@ class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
|
||||
RecreateSessionsCommand(jid: GetIt.I.get<UIDataService>().ownJid!),
|
||||
awaitable: false,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
keys: List.from(
|
||||
state.keys.map(
|
||||
(key) => key.copyWith(
|
||||
hasSessionWith: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
||||
}
|
||||
|
||||
@@ -90,16 +90,6 @@ class ProfileBloc extends Bloc<ProfileEvent, ProfileState> {
|
||||
SetSubscriptionStateEvent event,
|
||||
Emitter<ProfileState> emit,
|
||||
) 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(
|
||||
SetShareOnlineStatusCommand(jid: event.jid, share: event.shareStatus),
|
||||
awaitable: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
|
||||
part 'sendfiles_bloc.freezed.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
|
||||
/// been cancelled.
|
||||
Future<List<String>?> _pickFiles(SendFilesType type) async {
|
||||
final fileType =
|
||||
type == SendFilesType.image ? FileType.image : FileType.any;
|
||||
final result = await FilePicker.platform
|
||||
.pickFiles(type: fileType, allowMultiple: true);
|
||||
final result = await safePickFiles(
|
||||
type == SendFilesType.image ? FileType.image : FileType.any,
|
||||
);
|
||||
|
||||
if (result == null) return null;
|
||||
|
||||
|
||||
@@ -28,9 +28,11 @@ enum ShareSelectionType {
|
||||
class ShareListItem {
|
||||
const ShareListItem(
|
||||
this.avatarPath,
|
||||
this.avatarHash,
|
||||
this.jid,
|
||||
this.title,
|
||||
this.isConversation,
|
||||
this.conversationType,
|
||||
this.isEncrypted,
|
||||
this.pseudoRosterItem,
|
||||
this.contactId,
|
||||
@@ -38,9 +40,11 @@ class ShareListItem {
|
||||
this.contactDisplayName,
|
||||
);
|
||||
final String avatarPath;
|
||||
final String? avatarHash;
|
||||
final String jid;
|
||||
final String title;
|
||||
final bool isConversation;
|
||||
final ConversationType? conversationType;
|
||||
final bool isEncrypted;
|
||||
final bool pseudoRosterItem;
|
||||
final String? contactId;
|
||||
@@ -79,10 +83,12 @@ class ShareSelectionBloc
|
||||
final items = List<ShareListItem>.from(
|
||||
conversations.map((c) {
|
||||
return ShareListItem(
|
||||
c.avatarUrl,
|
||||
c.avatarPath,
|
||||
c.avatarHash,
|
||||
c.jid,
|
||||
c.title,
|
||||
true,
|
||||
c.type,
|
||||
c.encrypted,
|
||||
false,
|
||||
c.contactId,
|
||||
@@ -100,10 +106,12 @@ class ShareSelectionBloc
|
||||
if (index == -1) {
|
||||
items.add(
|
||||
ShareListItem(
|
||||
rosterItem.avatarUrl,
|
||||
rosterItem.avatarPath,
|
||||
rosterItem.avatarHash,
|
||||
rosterItem.jid,
|
||||
rosterItem.title,
|
||||
false,
|
||||
null,
|
||||
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
||||
rosterItem.pseudoRosterItem,
|
||||
rosterItem.contactId,
|
||||
@@ -113,10 +121,12 @@ class ShareSelectionBloc
|
||||
);
|
||||
} else {
|
||||
items[index] = ShareListItem(
|
||||
rosterItem.avatarUrl,
|
||||
rosterItem.avatarPath,
|
||||
rosterItem.avatarHash,
|
||||
rosterItem.jid,
|
||||
rosterItem.title,
|
||||
false,
|
||||
null,
|
||||
items[index].isEncrypted,
|
||||
items[index].pseudoRosterItem,
|
||||
items[index].contactId,
|
||||
@@ -187,7 +197,7 @@ class ShareSelectionBloc
|
||||
SendMessageCommand(
|
||||
recipients: _getRecipients(),
|
||||
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:get_it/get_it.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
|
||||
part 'addcontact_bloc.freezed.dart';
|
||||
part 'addcontact_event.dart';
|
||||
part 'addcontact_state.dart';
|
||||
part 'startchat_bloc.freezed.dart';
|
||||
part 'startchat_event.dart';
|
||||
part 'startchat_state.dart';
|
||||
|
||||
class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
AddContactBloc() : super(AddContactState()) {
|
||||
class StartChatBloc extends Bloc<StartChatEvent, StartChatState> {
|
||||
StartChatBloc() : super(StartChatState()) {
|
||||
on<AddedContactEvent>(_onContactAdded);
|
||||
on<JidChangedEvent>(_onJidChanged);
|
||||
on<PageResetEvent>(_onPageReset);
|
||||
@@ -21,7 +23,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
|
||||
Future<void> _onContactAdded(
|
||||
AddedContactEvent event,
|
||||
Emitter<AddContactState> emit,
|
||||
Emitter<StartChatState> emit,
|
||||
) async {
|
||||
final validation = validateJidString(state.jid);
|
||||
if (validation != null) {
|
||||
@@ -41,31 +43,54 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
AddContactCommand(
|
||||
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);
|
||||
|
||||
if (result.conversation != null) {
|
||||
if (result.added) {
|
||||
final addResult = result! as AddContactResultEvent;
|
||||
if (addResult.conversation != null) {
|
||||
if (addResult.added) {
|
||||
GetIt.I.get<ConversationsBloc>().add(
|
||||
ConversationsAddedEvent(result.conversation!),
|
||||
ConversationsAddedEvent(addResult.conversation!),
|
||||
);
|
||||
} else {
|
||||
GetIt.I.get<ConversationsBloc>().add(
|
||||
ConversationsUpdatedEvent(result.conversation!),
|
||||
ConversationsUpdatedEvent(addResult.conversation!),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert(
|
||||
result.conversation != null,
|
||||
addResult.conversation != null,
|
||||
'RequestedConversationEvent must contain a not null conversation',
|
||||
);
|
||||
GetIt.I.get<ConversationBloc>().add(
|
||||
RequestedConversationEvent(
|
||||
result.conversation!.jid,
|
||||
result.conversation!.title,
|
||||
result.conversation!.avatarUrl,
|
||||
addResult.conversation!.jid,
|
||||
addResult.conversation!.title,
|
||||
addResult.conversation!.avatarPath,
|
||||
removeUntilConversations: true,
|
||||
),
|
||||
);
|
||||
@@ -73,7 +98,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
|
||||
Future<void> _onJidChanged(
|
||||
JidChangedEvent event,
|
||||
Emitter<AddContactState> emit,
|
||||
Emitter<StartChatState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -84,7 +109,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
|
||||
Future<void> _onPageReset(
|
||||
PageResetEvent event,
|
||||
Emitter<AddContactState> emit,
|
||||
Emitter<StartChatState> emit,
|
||||
) async {
|
||||
emit(
|
||||
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
|
||||
class AddedContactEvent extends AddContactEvent {}
|
||||
class AddedContactEvent extends StartChatEvent {}
|
||||
|
||||
/// Triggered by the UI when the JID input field is changed
|
||||
class JidChangedEvent extends AddContactEvent {
|
||||
class JidChangedEvent extends StartChatEvent {
|
||||
JidChangedEvent(this.jid);
|
||||
final String jid;
|
||||
}
|
||||
|
||||
/// 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:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
@@ -44,15 +43,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
||||
);
|
||||
|
||||
// Apply
|
||||
final stickerPack = firstWhereOrNull(
|
||||
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
|
||||
(StickerPack pack) => pack.id == event.stickerPackId,
|
||||
final stickerPackResult =
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
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(
|
||||
state.copyWith(
|
||||
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
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PoppedRouteEvent(),
|
||||
);
|
||||
// Leave the page
|
||||
GetIt.I.get<NavigationBloc>().add(
|
||||
PoppedRouteEvent(),
|
||||
);
|
||||
|
||||
// Notify on failure
|
||||
if (result is! StickerPackInstallSuccessEvent) {
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.stickerPack.fetchingFailure,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
@@ -176,13 +174,22 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
||||
StickerPackRequested event,
|
||||
Emitter<StickerPackState> emit,
|
||||
) async {
|
||||
// Find out if the sticker pack is locally available or not
|
||||
final stickerPack = firstWhereOrNull(
|
||||
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
|
||||
(StickerPack pack) => pack.id == event.stickerPackId,
|
||||
emit(
|
||||
state.copyWith(
|
||||
isWorking: true,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
RemoteStickerPackRequested(
|
||||
event.stickerPackId,
|
||||
@@ -191,9 +198,11 @@ class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
||||
emit,
|
||||
);
|
||||
} else {
|
||||
await _onLocalStickerPackRequested(
|
||||
LocallyAvailableStickerPackRequested(event.stickerPackId),
|
||||
emit,
|
||||
emit(
|
||||
state.copyWith(
|
||||
isWorking: false,
|
||||
stickerPack: stickerPackResult.stickerPack,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/controller/sticker_pack_controller.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
|
||||
part 'stickers_bloc.freezed.dart';
|
||||
part 'stickers_event.dart';
|
||||
@@ -19,60 +16,20 @@ part 'stickers_state.dart';
|
||||
|
||||
class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
StickersBloc() : super(StickersState()) {
|
||||
on<StickersSetEvent>(_onStickersSet);
|
||||
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
|
||||
on<StickerPackImportedEvent>(_onStickerPackImported);
|
||||
on<StickerPackAddedEvent>(_onStickerPackAdded);
|
||||
}
|
||||
|
||||
Future<void> _onStickersSet(
|
||||
StickersSetEvent event,
|
||||
Emitter<StickersState> emit,
|
||||
) async {
|
||||
// Also store a mapping of (pack Id, sticker Id) -> Sticker to allow fast lookup
|
||||
// of the sticker in the UI.
|
||||
final map = <StickerKey, Sticker>{};
|
||||
for (final pack in event.stickerPacks) {
|
||||
for (final sticker in pack.stickers) {
|
||||
if (!sticker.isImage) continue;
|
||||
|
||||
map[StickerKey(pack.id, sticker.id)] = sticker;
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
stickerPacks: event.stickerPacks,
|
||||
stickerMap: map,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onStickerPackRemoved(
|
||||
StickerPackRemovedEvent event,
|
||||
Emitter<StickersState> emit,
|
||||
) async {
|
||||
final stickerPack = firstWhereOrNull(
|
||||
state.stickerPacks,
|
||||
(StickerPack sp) => sp.id == event.stickerPackId,
|
||||
)!;
|
||||
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
||||
for (final sticker in stickerPack.stickers) {
|
||||
sm.remove(StickerKey(stickerPack.id, sticker.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,
|
||||
),
|
||||
// Remove from the UI
|
||||
BidirectionalStickerPackController.instance?.removeItem(
|
||||
(stickerPack) => stickerPack.id == event.stickerPackId,
|
||||
);
|
||||
|
||||
// Notify the backend
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
RemoveStickerPackCommand(
|
||||
stickerPackId: event.stickerPackId,
|
||||
@@ -85,8 +42,11 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
StickerPackImportedEvent event,
|
||||
Emitter<StickersState> emit,
|
||||
) async {
|
||||
final file = await FilePicker.platform.pickFiles();
|
||||
if (file == null) return;
|
||||
final pickerResult = await safePickFiles(
|
||||
FileType.any,
|
||||
allowMultiple: false,
|
||||
);
|
||||
if (pickerResult == null) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -96,40 +56,23 @@ class StickersBloc extends Bloc<StickersEvent, StickersState> {
|
||||
|
||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
ImportStickerPackCommand(
|
||||
path: file.files.single.path!,
|
||||
path: pickerResult.files.single.path!,
|
||||
),
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
isImportRunning: false,
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
state.copyWith(
|
||||
stickerPacks: List<StickerPack>.from([
|
||||
...state.stickerPacks,
|
||||
result.stickerPack,
|
||||
]),
|
||||
stickerMap: sm,
|
||||
isImportRunning: false,
|
||||
),
|
||||
);
|
||||
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.stickers.importSuccess,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
toastLength: Toast.LENGTH_SHORT,
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isImportRunning: false,
|
||||
),
|
||||
);
|
||||
|
||||
await Fluttertoast.showToast(
|
||||
msg: t.pages.settings.stickers.importFailure,
|
||||
gravity: ToastGravity.SNACKBAR,
|
||||
@@ -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 {}
|
||||
|
||||
class StickersSetEvent extends StickersEvent {
|
||||
StickersSetEvent(
|
||||
this.stickerPacks,
|
||||
);
|
||||
final List<StickerPack> stickerPacks;
|
||||
}
|
||||
|
||||
/// Triggered by the UI when a sticker pack has been removed
|
||||
class StickerPackRemovedEvent extends StickersEvent {
|
||||
StickerPackRemovedEvent(this.stickerPackId);
|
||||
@@ -17,9 +10,3 @@ class StickerPackRemovedEvent extends StickersEvent {
|
||||
|
||||
/// Triggered by the UI when a sticker pack has been imported
|
||||
class StickerPackImportedEvent extends StickersEvent {}
|
||||
|
||||
/// Triggered by the UI when a sticker pack has been imported
|
||||
class StickerPackAddedEvent extends StickersEvent {
|
||||
StickerPackAddedEvent(this.stickerPack);
|
||||
final StickerPack stickerPack;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ class StickerKey {
|
||||
@freezed
|
||||
class StickersState with _$StickersState {
|
||||
factory StickersState({
|
||||
@Default([]) List<StickerPack> stickerPacks,
|
||||
@Default({}) Map<StickerKey, Sticker> stickerMap,
|
||||
@Default(false) bool isImportRunning,
|
||||
}) = _StickersState;
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ const Color reactionColorSent = Color(0xff2993FB);
|
||||
/// The color of the skim when a message is highlighted.
|
||||
const Color highlightSkimColor = Color(0xff000000);
|
||||
|
||||
/// The width of the bar used to indicate a legacy quote.
|
||||
const double textMessageQuoteBarWidth = 3;
|
||||
|
||||
/// Navigation constants
|
||||
const String cropRoute = '/crop';
|
||||
const String introRoute = '/intro';
|
||||
@@ -157,6 +160,9 @@ const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
|
||||
const String conversationSettingsRoute = '$settingsRoute/conversation';
|
||||
const String appearanceRoute = '$settingsRoute/appearance';
|
||||
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 shareSelectionRoute = '/share_selection';
|
||||
const String serverInfoRoute = '$profileRoute/server_info';
|
||||
|
||||
@@ -98,11 +98,7 @@ class BidirectionalController<T> {
|
||||
hasOlderData = data.length >= pageSize;
|
||||
|
||||
// Don't trigger an update if we fetched nothing
|
||||
if (data.isEmpty) {
|
||||
_setIsFetching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
_setIsFetching(false);
|
||||
_cache.insertAll(0, data);
|
||||
|
||||
// Evict items from the cache if we overstep the maximum
|
||||
@@ -217,6 +213,22 @@ class BidirectionalController<T> {
|
||||
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.
|
||||
void animateToBottom() {
|
||||
_controller.animateTo(
|
||||
|
||||
@@ -68,8 +68,11 @@ class RecordingData {
|
||||
|
||||
class BidirectionalConversationController
|
||||
extends BidirectionalController<Message> {
|
||||
BidirectionalConversationController(this.conversationJid)
|
||||
: assert(
|
||||
BidirectionalConversationController(
|
||||
this.conversationJid,
|
||||
this.focusNode, {
|
||||
String? initialText,
|
||||
}) : assert(
|
||||
BidirectionalConversationController.currentController == null,
|
||||
'There can only be one BidirectionalConversationController',
|
||||
),
|
||||
@@ -78,6 +81,9 @@ class BidirectionalConversationController
|
||||
maxPageAmount: maxMessagePages,
|
||||
) {
|
||||
_textController.addListener(_handleTextChanged);
|
||||
if (initialText != null) {
|
||||
_textController.text = initialText;
|
||||
}
|
||||
|
||||
BidirectionalConversationController.currentController = this;
|
||||
|
||||
@@ -95,6 +101,10 @@ class BidirectionalConversationController
|
||||
final TextEditingController _textController = TextEditingController();
|
||||
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
|
||||
final StreamController<conversation.SendButtonState>
|
||||
_sendButtonStreamController = StreamController();
|
||||
@@ -313,10 +323,6 @@ class BidirectionalConversationController
|
||||
assert(text.isNotEmpty, 'Cannot send empty text messages');
|
||||
_textController.text = '';
|
||||
|
||||
// Reset the message editing state
|
||||
final wasEditing = _messageEditingState != null;
|
||||
_messageEditingState = null;
|
||||
|
||||
// Add message to the database and send it
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
@@ -324,7 +330,7 @@ class BidirectionalConversationController
|
||||
recipients: [conversationJid],
|
||||
body: text,
|
||||
quotedMessage: _quotedMessage,
|
||||
chatState: chatStateToString(ChatState.active),
|
||||
chatState: ChatState.active.toName(),
|
||||
editId: _messageEditingState?.id,
|
||||
editSid: _messageEditingState?.sid,
|
||||
currentConversationJid: conversationJid,
|
||||
@@ -332,6 +338,10 @@ class BidirectionalConversationController
|
||||
awaitable: true,
|
||||
) as MessageAddedEvent;
|
||||
|
||||
// Reset the message editing state
|
||||
final wasEditing = _messageEditingState != null;
|
||||
_messageEditingState = null;
|
||||
|
||||
// Reset the quote
|
||||
removeQuote();
|
||||
|
||||
@@ -413,6 +423,8 @@ class BidirectionalConversationController
|
||||
int id,
|
||||
String sid,
|
||||
) {
|
||||
_log.fine('Beginning editing for id: $id, sid: $sid');
|
||||
|
||||
_messageEditingState = MessageEditingState(
|
||||
id,
|
||||
sid,
|
||||
@@ -426,6 +438,9 @@ class BidirectionalConversationController
|
||||
|
||||
_sendButtonStreamController
|
||||
.add(conversation.SendButtonState.cancelCorrection);
|
||||
|
||||
// Focus the textfield.
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Exit the "edit mode" for a message.
|
||||
|
||||
@@ -25,7 +25,7 @@ class BidirectionalSharedMediaController
|
||||
static BidirectionalSharedMediaController? currentController;
|
||||
|
||||
/// The JID of the conversation we want to get shared media of.
|
||||
final String conversationJid;
|
||||
final String? conversationJid;
|
||||
|
||||
@override
|
||||
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:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/awaitabledatasender.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.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/newconversation_bloc.dart' as new_conversation;
|
||||
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/prestart.dart';
|
||||
import 'package:moxxyv2/ui/service/avatars.dart';
|
||||
import 'package:moxxyv2/ui/service/progress.dart';
|
||||
|
||||
void setupEventHandler() {
|
||||
@@ -33,7 +33,10 @@ void setupEventHandler() {
|
||||
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
|
||||
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
|
||||
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
|
||||
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
|
||||
EventTypeMatcher<StreamNegotiationsCompletedEvent>(
|
||||
onStreamNegotiationsDone,
|
||||
),
|
||||
EventTypeMatcher<AvatarUpdatedEvent>(onAvatarUpdated),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||
@@ -173,16 +176,21 @@ Future<void> onNotificationTappend(
|
||||
conversation.RequestedConversationEvent(
|
||||
event.conversationJid,
|
||||
event.title,
|
||||
event.avatarUrl,
|
||||
event.avatarPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onStickerPackAdded(
|
||||
StickerPackAddedEvent event, {
|
||||
Future<void> onStreamNegotiationsDone(
|
||||
StreamNegotiationsCompletedEvent event, {
|
||||
dynamic extra,
|
||||
}) async {
|
||||
GetIt.I.get<stickers.StickersBloc>().add(
|
||||
stickers.StickerPackAddedEvent(event.stickerPack),
|
||||
);
|
||||
if (!event.resumed) {
|
||||
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/pages/util/qrcode.dart';
|
||||
import 'package:moxxyv2/ui/redirects.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
/// Shows a dialog asking the user if they are sure that they want to proceed with an
|
||||
/// action. Resolves to true if the user pressed the confirm button. Returns false if
|
||||
/// 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(
|
||||
String title,
|
||||
String body,
|
||||
BuildContext context,
|
||||
) async {
|
||||
BuildContext context, {
|
||||
String? affirmativeText,
|
||||
bool destructive = false,
|
||||
}) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
@@ -41,7 +50,10 @@ Future<bool> showConfirmationDialog(
|
||||
actions: [
|
||||
TextButton(
|
||||
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(
|
||||
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.
|
||||
/// The Future either resolves to null if the user cancels the action or
|
||||
/// the actual image data.
|
||||
Future<Uint8List?> pickAndCropImage(BuildContext context) async {
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.image,
|
||||
withData: true,
|
||||
);
|
||||
final result =
|
||||
await safePickFiles(FileType.image, allowMultiple: false, withData: true);
|
||||
|
||||
if (result != null) {
|
||||
return GetIt.I
|
||||
@@ -199,9 +246,15 @@ Color getTileColor(BuildContext context) {
|
||||
String localeCodeToLanguageName(String localeCode) {
|
||||
switch (localeCode) {
|
||||
case 'de':
|
||||
return 'Deutsch';
|
||||
return AppLocale.de.build().language;
|
||||
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':
|
||||
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/typing_indicator.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/widgets/chat/bubbles/bubbles.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/combined_picker.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(
|
||||
Message message,
|
||||
Message? lastMessage,
|
||||
@@ -49,12 +61,16 @@ int getMessageMenuOptionCount(
|
||||
class ConversationPage extends StatefulWidget {
|
||||
const ConversationPage({
|
||||
required this.conversationJid,
|
||||
this.initialText,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The JID of the current conversation
|
||||
final String conversationJid;
|
||||
|
||||
/// The optional initial text to put in the input field.
|
||||
final String? initialText;
|
||||
|
||||
@override
|
||||
ConversationPageState createState() => ConversationPageState();
|
||||
}
|
||||
@@ -88,6 +104,8 @@ class ConversationPageState extends State<ConversationPage>
|
||||
// Setup message paging
|
||||
_conversationController = BidirectionalConversationController(
|
||||
widget.conversationJid,
|
||||
_textfieldFocusNode,
|
||||
initialText: widget.initialText,
|
||||
);
|
||||
_conversationController.fetchOlderData();
|
||||
|
||||
@@ -232,10 +250,7 @@ class ConversationPageState extends State<ConversationPage>
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: maxWidth,
|
||||
),
|
||||
child: NewDeviceBubble(
|
||||
data: item.pseudoMessageData!,
|
||||
title: state.conversation!.title,
|
||||
),
|
||||
child: bubbleFromPseudoMessageType(context, item),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -294,6 +309,9 @@ class ConversationPageState extends State<ConversationPage>
|
||||
sentBySelf,
|
||||
);
|
||||
|
||||
// Dismiss the soft-keyboard
|
||||
dismissSoftKeyboard(context);
|
||||
|
||||
// Start the actual animation
|
||||
_selectionController.selectMessage(
|
||||
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;
|
||||
}
|
||||
|
||||
// Clear the read marker cache
|
||||
GetIt.I.get<UIReadMarkerService>().clear();
|
||||
return true;
|
||||
},
|
||||
child: KeyboardReplacerScaffold(
|
||||
@@ -448,9 +475,12 @@ class ConversationPageState extends State<ConversationPage>
|
||||
children: [
|
||||
BlocBuilder<ConversationBloc, ConversationState>(
|
||||
buildWhen: (prev, next) =>
|
||||
prev.conversation?.inRoster != next.conversation?.inRoster,
|
||||
prev.conversation?.showAddToRoster !=
|
||||
next.conversation?.showAddToRoster,
|
||||
builder: (context, state) {
|
||||
if ((state.conversation?.inRoster ?? false) ||
|
||||
final showAddToRoster =
|
||||
state.conversation?.showAddToRoster ?? false;
|
||||
if (!showAddToRoster ||
|
||||
state.conversation?.type == ConversationType.note) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ class KeyboardReplacerWidget extends StatelessWidget {
|
||||
|
||||
/// The child to show or not show.
|
||||
final Widget child;
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder<KeyboardReplacerData>(
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.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:moxxyv2/i18n/strings.g.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/warning_types.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/widgets/chat/chatbubble.dart';
|
||||
@@ -213,7 +211,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.canRetract(sentBySelf))
|
||||
ContextMenuItem(
|
||||
icon: Icons.delete,
|
||||
@@ -233,16 +230,7 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
// TODO(Unknown): Also allow correcting older messages
|
||||
if (message.canEdit(sentBySelf) &&
|
||||
GetIt.I
|
||||
.get<ConversationBloc>()
|
||||
.state
|
||||
.conversation
|
||||
?.lastMessage
|
||||
?.id ==
|
||||
message.id)
|
||||
if (message.canEdit(sentBySelf))
|
||||
ContextMenuItem(
|
||||
icon: Icons.edit,
|
||||
text: t.pages.conversation.edit,
|
||||
@@ -256,7 +244,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.errorMenuVisible)
|
||||
ContextMenuItem(
|
||||
icon: Icons.info_outline,
|
||||
@@ -264,22 +251,19 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
||||
onPressed: () {
|
||||
showInfoDialog(
|
||||
t.errors.conversation.messageErrorDialogTitle,
|
||||
errorToTranslatableString(
|
||||
message.errorType!,
|
||||
),
|
||||
message.errorType!.translatableString,
|
||||
context,
|
||||
);
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.hasWarning)
|
||||
ContextMenuItem(
|
||||
icon: Icons.warning,
|
||||
text: t.pages.conversation.showWarning,
|
||||
onPressed: () {
|
||||
showInfoDialog(
|
||||
'Warning',
|
||||
t.pages.conversation.warning,
|
||||
warningToTranslatableString(
|
||||
message.warningType!,
|
||||
),
|
||||
@@ -288,22 +272,24 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.isCopyable)
|
||||
ContextMenuItem(
|
||||
icon: Icons.content_copy,
|
||||
text: t.pages.conversation.copy,
|
||||
onPressed: () {
|
||||
// TODO(Unknown): Show a toast saying the message has been copied
|
||||
Clipboard.setData(
|
||||
ClipboardData(
|
||||
text: message.body,
|
||||
),
|
||||
);
|
||||
selectionController.dismiss();
|
||||
|
||||
// Show an informative toast
|
||||
Fluttertoast.showToast(
|
||||
msg: t.pages.conversation.messageCopied,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
if (message.isQuotable && message.conversationJid != '')
|
||||
ContextMenuItem(
|
||||
icon: Icons.forward,
|
||||
@@ -316,7 +302,6 @@ class SelectedMessageContextMenu extends StatelessWidget {
|
||||
selectionController.dismiss();
|
||||
},
|
||||
),
|
||||
|
||||
if (message.isQuotable)
|
||||
ContextMenuItem(
|
||||
icon: Icons.reply,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user