Compare commits
236 Commits
6a109fe03d
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 241a8b4d53 | |||
| 25d193e930 | |||
| e6924cc02d | |||
| 60985c6b37 | |||
| a015399b57 | |||
| 4b6c7998f3 | |||
| 26312e313f | |||
| b63b5d7fd2 | |||
| ca2943a94d | |||
| 32a4cd9361 | |||
| 2320e4ed17 | |||
| dee479a918 | |||
| 6895ef1e32 | |||
| 5c51eefa3e | |||
| 0d7ae321a7 | |||
| b4063a64e0 | |||
| 65154f2f5c | |||
| 19a22bd0d1 | |||
| a7da7baf5a | |||
| a344a94112 | |||
| f44861fead | |||
| 1c4a30ebb4 | |||
| 70e2ca3d3e | |||
| 0d4aee1625 | |||
| ad6aa33b7c | |||
| 284b5fa4df | |||
| b9aac0c3d7 | |||
| 6ce90e08ef | |||
| 5ac80d8d60 | |||
| 56e1fa52d8 | |||
| 3ae1b7d168 | |||
| d8f654c81c | |||
| cbcbd4d6dc | |||
| be899b5611 | |||
| 361bbe8d85 | |||
| 1e017af277 | |||
| c4c22a36bb | |||
| 84924b480b | |||
| 358074f4ee | |||
| 084314fbcf | |||
| c42f301ae0 | |||
| c8cd37e451 | |||
| 9f8f3a5407 | |||
| 6f1493808f | |||
| c9d32694db | |||
| 8632a2fc81 | |||
| 46a09d5b62 | |||
| b7e5bbc7d2 | |||
| ed264f0c16 | |||
| f1820575ad | |||
| d2e42d0a3c | |||
| 842cf5aaaa | |||
| c8f727e982 | |||
| fd3c9190de | |||
| 69439d2b13 | |||
| 6d41fee73f | |||
| 0de99adeed | |||
| f71fd7c82c | |||
| 0a6b0b8fa5 | |||
| 5e0ce8f098 | |||
| 9fc5989bd4 | |||
| cbe81861a5 | |||
| 76a03cc2fa | |||
| 3774760548 | |||
| 4b1942b949 | |||
|
|
2f03c02b58 | ||
|
|
639143934f | ||
|
|
81bbbcd8e4 | ||
|
|
bedd46756d | ||
|
|
bb6b342d82 | ||
| b6eb12cf30 | |||
| 80f8129011 | |||
| 86daad2455 | |||
| e71cbd5ba9 | |||
| c0fb9beef7 | |||
| db4b69a24a | |||
| 7746784949 | |||
| 024bd48aba | |||
| cb13c9faa4 | |||
| 009ec759a3 | |||
| 6ba16ad020 | |||
| 43b0b34cdd | |||
| 94e6eb2d10 | |||
| 578eea5d9f | |||
| 724450e049 | |||
| 1759baebad | |||
| 896ef50b9a | |||
| c4d52b6687 | |||
| 5c611a59aa | |||
| 7068b989ef | |||
| 820fda78e7 | |||
| d758423ec6 | |||
| 5472f097a4 | |||
| e373f5cffe | |||
| f04729261b | |||
| b6c8778aec | |||
| 8dfe8d55a0 | |||
| 36b7d5ce42 | |||
| 8d780c3252 | |||
| a841d5de2d | |||
| fdd8d306f7 | |||
| 9510a0fced | |||
| c3ec9dfb11 | |||
| 82c136b684 | |||
| ea4bb752b9 | |||
| bac673df99 | |||
| df2c2f5e4b | |||
| 8c3863f970 | |||
| bc49e31164 | |||
| ce4c54b0d5 | |||
| 7b09cdeefd | |||
| 39dc96ab7a | |||
| 2d13ff328e | |||
| 53dd598547 | |||
| 40b4a540a8 | |||
| 33ae53c199 | |||
| 97e9b0636b | |||
| b0b21e9d53 | |||
| 53d5402502 | |||
| a190a9564e | |||
| 7846520788 | |||
| 3444683983 | |||
| 00118ddafe | |||
| 525ba293e3 | |||
| 071f6c08fd | |||
| da70236a45 | |||
| cfdda2d293 | |||
| aba265d787 | |||
| bbcb37bc4e | |||
| eff7d7493d | |||
| 730916758e | |||
| 9acfe2751e | |||
| 386569d7cf | |||
| 39a7e1eb19 | |||
| f492845235 | |||
| ab42fc8b57 | |||
| a5a9fce330 | |||
| a70286dda4 | |||
| 2b3e587be4 | |||
| ebfac9730b | |||
| fbd3c6ca92 | |||
| 1cd3dabcea | |||
| eba17880d0 | |||
| c168f910a9 | |||
| 98dd704fda | |||
| 4ecebe8982 | |||
| 8f1d17636e | |||
| fb1c202586 | |||
| d7a4ce022e | |||
| 64c3796429 | |||
| 80a517beaa | |||
| cec31550f8 | |||
| bee760adf5 | |||
| 155d5747f8 | |||
| fd531a360e | |||
| c3884a460d | |||
| 5f5c30673d | |||
| f423cd5611 | |||
| 7e059e13ef | |||
| d965fbd57e | |||
| 55854ec586 | |||
| 8886c8e695 | |||
| d58f5f9a01 | |||
| e060b0f549 | |||
| 73913c4ae6 | |||
| 21878ae135 | |||
| a08a110ef6 | |||
| f723c43603 | |||
| d88876c928 | |||
| f15a3e6bf4 | |||
| 4852237bf8 | |||
| 9a0bc87636 | |||
| d73d27dccc | |||
| 6fa5e73226 | |||
| 1ff9ea256b | |||
| 7fca7e0246 | |||
| 846270b714 | |||
| 50e7c5683f | |||
| 6883a9570f | |||
| 8f34bc001d | |||
| 2f95e5452b | |||
| 59a6307a21 | |||
| c8d52e6c41 | |||
| 044766bf8a | |||
| 1f7c851228 | |||
| ca90c658ff | |||
| 19de68e4f0 | |||
| dc17d7d304 | |||
| 2372dbf6b3 | |||
| 3382e35447 | |||
| a9cc4f55b8 | |||
| 63fbf7ebe4 | |||
| d1d6b67fd6 | |||
| 87160e8648 | |||
| e5553699c5 | |||
| adcfdc1a73 | |||
| 70464a2b71 | |||
| 0852a75d9f | |||
| 9affa0e89a | |||
| cc13078ec5 | |||
| 35285343b1 | |||
| cb6bce0c56 | |||
| 5c1eda72c3 | |||
| 4542accc33 | |||
| 8f5470076b | |||
| 3427c3c761 | |||
| 0cc8d0947b | |||
| c3795450a9 | |||
| 868d924836 | |||
| d8f634d67c | |||
| 09b97ab4c5 | |||
| 8b4d7dd569 | |||
| a63205d5e1 | |||
| e7a4e93366 | |||
| 2c23f40415 | |||
| daf4ee79f2 | |||
| 38a73d2890 | |||
| b004d8364c | |||
| 2498e23bd5 | |||
| 3a80d50cf5 | |||
| 8c12eb47ce | |||
| cfec6afc7d | |||
| a3bdabca3c | |||
| f094a326ac | |||
| d24cab9c1a | |||
| c7d1ecce35 | |||
| 306fd99b84 | |||
| ab63bc44a6 | |||
| ef15f15458 | |||
| db86136aa8 | |||
| c344aed471 | |||
| 79b0a4ba7a | |||
| 4ce5e29a81 | |||
| 524dec0991 | |||
| 05074ed4f0 | |||
| 4e4ed58605 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ko_fi: papatutuwawa
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,3 +60,6 @@ lib/i18n/*.dart
|
|||||||
|
|
||||||
# Android artifacts
|
# Android artifacts
|
||||||
.android
|
.android
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
release/
|
||||||
|
|||||||
2
.gitlint
2
.gitlint
@@ -7,7 +7,7 @@ line-length=72
|
|||||||
[title-trailing-punctuation]
|
[title-trailing-punctuation]
|
||||||
[title-hard-tab]
|
[title-hard-tab]
|
||||||
[title-match-regex]
|
[title-match-regex]
|
||||||
regex=^(feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\): .*$
|
regex=^((feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\)|release): .*$
|
||||||
|
|
||||||
|
|
||||||
[body-trailing-whitespace]
|
[body-trailing-whitespace]
|
||||||
|
|||||||
@@ -46,3 +46,9 @@ See `./LICENSE`.
|
|||||||
## Special Thanks
|
## Special Thanks
|
||||||
|
|
||||||
- New logo designed by [Synoh](https://twitter.com/synoh_manda)
|
- New logo designed by [Synoh](https://twitter.com/synoh_manda)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you like what I do and you want to support me, feel free to donate to me on Ko-Fi.
|
||||||
|
|
||||||
|
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/assets/repo/kofi.png" height="36" style="height: 36px; border: 0px;"></img>](https://ko-fi.com/papatutuwawa)
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -27,13 +27,40 @@
|
|||||||
"warningChannelDescription": "Warnings related to Moxxy"
|
"warningChannelDescription": "Warnings related to Moxxy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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": {
|
"messages": {
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"file": "File",
|
"file": "File",
|
||||||
|
"sticker": "Sticker",
|
||||||
"retracted": "The message has been retracted",
|
"retracted": "The message has been retracted",
|
||||||
"retractedFallback": "A previous message has been retracted but your client does not support it"
|
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||||
|
"you": "You"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"omemo": {
|
"omemo": {
|
||||||
@@ -41,7 +68,13 @@
|
|||||||
"notEncryptedForDevice": "This message was not encrypted for this device",
|
"notEncryptedForDevice": "This message was not encrypted for this device",
|
||||||
"invalidHmac": "Could not decrypt message",
|
"invalidHmac": "Could not decrypt message",
|
||||||
"noDecryptionKey": "No decryption key available",
|
"noDecryptionKey": "No decryption key available",
|
||||||
"messageInvalidAfixElement": "Invalid encrypted message"
|
"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": {
|
"connection": {
|
||||||
"connectionTimeout": "Could not connect to server"
|
"connectionTimeout": "Could not connect to server"
|
||||||
@@ -64,11 +97,19 @@
|
|||||||
"failedToEncryptFile": "The file could not be encrypted",
|
"failedToEncryptFile": "The file could not be encrypted",
|
||||||
"failedToDecryptFile": "The file could not be decrypted",
|
"failedToDecryptFile": "The file could not be decrypted",
|
||||||
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"message": {
|
"message": {
|
||||||
"integrityCheckFailed": "Could not verify file integrity"
|
"integrityCheckFailed": "Could not verify file integrity"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "Hold button longer to record a voice message"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@@ -100,6 +141,7 @@
|
|||||||
"closeChat": "Close chat",
|
"closeChat": "Close chat",
|
||||||
"closeChatConfirmTitle": "Close chat",
|
"closeChatConfirmTitle": "Close chat",
|
||||||
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
||||||
|
"blockShort": "Block",
|
||||||
"blockUser": "Block user",
|
"blockUser": "Block user",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"retract": "Retract message",
|
"retract": "Retract message",
|
||||||
@@ -107,7 +149,21 @@
|
|||||||
"forward": "Forward",
|
"forward": "Forward",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"quote": "Quote",
|
"quote": "Quote",
|
||||||
"copy": "Copy content"
|
"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": {
|
"addcontact": {
|
||||||
"title": "Add new contact",
|
"title": "Add new contact",
|
||||||
@@ -129,15 +185,14 @@
|
|||||||
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
"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": {
|
"profile": {
|
||||||
"self": {
|
"general": {
|
||||||
"devices": "Devices"
|
"omemo": "Security"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"muteChatTooltip": "Mute chat",
|
"notifications": "Notifications",
|
||||||
"unmuteChatTooltip": "Unmute chat",
|
"notificationsMuted": "Muted",
|
||||||
"muteChat": "Mute",
|
"notificationsEnabled": "Enabled",
|
||||||
"unmuteChat": "Unmute",
|
"sharedMedia": "Media"
|
||||||
"devices": "Devices"
|
|
||||||
},
|
},
|
||||||
"owndevices": {
|
"owndevices": {
|
||||||
"title": "Own Devices",
|
"title": "Own Devices",
|
||||||
@@ -168,6 +223,18 @@
|
|||||||
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
||||||
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
"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": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -177,12 +244,17 @@
|
|||||||
"signOutConfirmTitle": "Sign Out",
|
"signOutConfirmTitle": "Sign Out",
|
||||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||||
"miscellaneousSection": "Miscellaneous",
|
"miscellaneousSection": "Miscellaneous",
|
||||||
"debuggingSection": "Debugging"
|
"debuggingSection": "Debugging",
|
||||||
|
"general": "General"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
"licensed": "Licensed under GPL3",
|
"licensed": "Licensed under GPL3",
|
||||||
"viewSourceCode": "View source code"
|
"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": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
@@ -205,7 +277,10 @@
|
|||||||
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
||||||
"newChatsSection": "New Conversations",
|
"newChatsSection": "New Conversations",
|
||||||
"newChatsMuteByDefault": "Mute new chats by default",
|
"newChatsMuteByDefault": "Mute new chats by default",
|
||||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental"
|
"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": {
|
"debugging": {
|
||||||
"title": "Debugging options",
|
"title": "Debugging options",
|
||||||
@@ -224,6 +299,7 @@
|
|||||||
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
||||||
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
||||||
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
||||||
|
"automaticDownloadAlways": "Always",
|
||||||
"wifi": "Wifi",
|
"wifi": "Wifi",
|
||||||
"mobileData": "Mobile data"
|
"mobileData": "Mobile data"
|
||||||
},
|
},
|
||||||
@@ -249,7 +325,20 @@
|
|||||||
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
"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",
|
"urlEmpty": "URL cannot be empty",
|
||||||
"urlInvalid": "Invalid URL",
|
"urlInvalid": "Invalid URL",
|
||||||
"redirectDialogTitle": "$serviceName Redirect"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,13 +27,40 @@
|
|||||||
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"image": "Bild",
|
"image": "Bild",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"file": "Datei",
|
"file": "Datei",
|
||||||
|
"sticker": "Sticker",
|
||||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
|
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||||
|
"you": "Du"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"omemo": {
|
"omemo": {
|
||||||
@@ -41,7 +68,13 @@
|
|||||||
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
||||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht"
|
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||||
|
|
||||||
|
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||||
|
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||||
|
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||||
|
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
|
||||||
|
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"connectionTimeout": "Verbindung zum Server nicht möglich"
|
"connectionTimeout": "Verbindung zum Server nicht möglich"
|
||||||
@@ -64,11 +97,19 @@
|
|||||||
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
|
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
|
||||||
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
|
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
|
||||||
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"message": {
|
"message": {
|
||||||
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
|
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@@ -100,6 +141,7 @@
|
|||||||
"closeChat": "Chat schließen",
|
"closeChat": "Chat schließen",
|
||||||
"closeChatConfirmTitle": "Chat schließen",
|
"closeChatConfirmTitle": "Chat schließen",
|
||||||
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
|
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
|
||||||
|
"blockShort": "Blockieren",
|
||||||
"blockUser": "Nutzer blockieren",
|
"blockUser": "Nutzer blockieren",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"retract": "Nachricht löschen",
|
"retract": "Nachricht löschen",
|
||||||
@@ -107,7 +149,21 @@
|
|||||||
"forward": "Weiterleiten",
|
"forward": "Weiterleiten",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"quote": "Zitieren",
|
"quote": "Zitieren",
|
||||||
"copy": "Inhalt kopieren"
|
"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": {
|
"addcontact": {
|
||||||
"title": "Neuen Kontakt hinzufügen",
|
"title": "Neuen Kontakt hinzufügen",
|
||||||
@@ -129,15 +185,14 @@
|
|||||||
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
|
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"self": {
|
"general": {
|
||||||
"devices": "Geräte"
|
"omemo": "Sicherheit"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"muteChatTooltip": "Chat stummschalten",
|
"notifications": "Benachrichtigungen",
|
||||||
"unmuteChatTooltip": "Chat lautstellen",
|
"notificationsMuted": "Stumm",
|
||||||
"muteChat": "Stummschalten",
|
"notificationsEnabled": "Eingeschaltet",
|
||||||
"unmuteChat": "Lautstellen",
|
"sharedMedia": "Medien"
|
||||||
"devices": "Geräte"
|
|
||||||
},
|
},
|
||||||
"owndevices": {
|
"owndevices": {
|
||||||
"title": "Eigene Geräte",
|
"title": "Eigene Geräte",
|
||||||
@@ -153,10 +208,10 @@
|
|||||||
"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?"
|
"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": {
|
"devices": {
|
||||||
"title": "Devices",
|
"title": "Geräte",
|
||||||
"recreateSessions": "Rebuild sessions",
|
"recreateSessions": "Sessions zurücksetzen",
|
||||||
"recreateSessionsConfirmTitle": "Rebuild sessions?",
|
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
|
||||||
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors."
|
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"blocklist": {
|
"blocklist": {
|
||||||
@@ -168,6 +223,18 @@
|
|||||||
"unblockJidConfirmTitle": "${jid} entblocken?",
|
"unblockJidConfirmTitle": "${jid} entblocken?",
|
||||||
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
|
"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": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
@@ -177,12 +244,17 @@
|
|||||||
"signOutConfirmTitle": "Abmelden",
|
"signOutConfirmTitle": "Abmelden",
|
||||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||||
"miscellaneousSection": "Unterschiedlich",
|
"miscellaneousSection": "Unterschiedlich",
|
||||||
"debuggingSection": "Debugging"
|
"debuggingSection": "Debugging",
|
||||||
|
"general": "Generell"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "Über",
|
"title": "Über",
|
||||||
"licensed": "Lizensiert unter GPL3",
|
"licensed": "Lizensiert unter GPL3",
|
||||||
"viewSourceCode": "Quellcode anschauen"
|
"version": "Version ${version}",
|
||||||
|
"viewSourceCode": "Quellcode anschauen",
|
||||||
|
"nMoreToGo": "Noch ${n}...",
|
||||||
|
"debugMenuShown": "Du bist jetzt ein Entwickler!",
|
||||||
|
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Aussehen",
|
"title": "Aussehen",
|
||||||
@@ -205,7 +277,10 @@
|
|||||||
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
||||||
"newChatsSection": "Neue Chats",
|
"newChatsSection": "Neue Chats",
|
||||||
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
||||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell"
|
"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": {
|
"debugging": {
|
||||||
"title": "Debuggingoptionen",
|
"title": "Debuggingoptionen",
|
||||||
@@ -224,6 +299,7 @@
|
|||||||
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
|
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
|
||||||
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
||||||
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
||||||
|
"automaticDownloadAlways": "Immer",
|
||||||
"wifi": "Wifi",
|
"wifi": "Wifi",
|
||||||
"mobileData": "Mobile Daten"
|
"mobileData": "Mobile Daten"
|
||||||
},
|
},
|
||||||
@@ -249,7 +325,20 @@
|
|||||||
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
||||||
"urlEmpty": "URL kann nicht leer sein",
|
"urlEmpty": "URL kann nicht leer sein",
|
||||||
"urlInvalid": "Ungültige URL",
|
"urlInvalid": "Ungültige URL",
|
||||||
"redirectDialogTitle": "${serviceName}weiterleitung"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/repo/kofi.png
Normal file
BIN
assets/repo/kofi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
43
docs/stickerpacks.md
Normal file
43
docs/stickerpacks.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Sticker Packs
|
||||||
|
|
||||||
|
Moxxy supports sending and receiving sticker packs using XEP-0449 version 0.1.1. Sticker
|
||||||
|
packs can also be imported using a Moxxy specific format.
|
||||||
|
|
||||||
|
## File Format
|
||||||
|
|
||||||
|
A Moxxy sticker pack is a flat tar archive that contains the following files:
|
||||||
|
|
||||||
|
- `urn.xmpp.stickers.0.xml`
|
||||||
|
- The sticker files
|
||||||
|
|
||||||
|
### `urn.xmpp.stickers.0.xml`
|
||||||
|
|
||||||
|
This file is the sticker pack's metadata file. It describes the sticker pack the same
|
||||||
|
way as the examples in XEP-0449 do. There are, however, some differences:
|
||||||
|
|
||||||
|
- Each `<file />` element must contain a `<name />` element that matches with a file in the tar archive
|
||||||
|
- Each sticker MUST contain at least one HTTP(s) source
|
||||||
|
- The `<hash />` of the `<pack />` element is ignored as Moxxy computes it itself, so it can be omitted
|
||||||
|
|
||||||
|
An example for the metadata file is the following:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<pack xmlns='urn:xmpp:stickers:0'>
|
||||||
|
<name>Example</name>
|
||||||
|
<summary>Example sticker pack.</summary>
|
||||||
|
<item>
|
||||||
|
<file xmlns='urn:xmpp:file:metadata:0'>
|
||||||
|
<media-type>image/png</media-type>
|
||||||
|
<desc>:some-sticker:</desc>
|
||||||
|
<name>suprise.png</name>
|
||||||
|
<size>531910</size>
|
||||||
|
<dimensions>1030x1030</dimensions>
|
||||||
|
<hash xmlns='urn:xmpp:hashes:2' algo='sha-256'>1Ha4okUGNRAA04KibwWUmklqqBqdhg7+20dfsr/wLik=</hash>
|
||||||
|
</file>
|
||||||
|
<sources xmlns='urn:xmpp:sfs:0'>
|
||||||
|
<url-data xmlns='http://jabber.org/protocol/url-data' target='...' />
|
||||||
|
</sources>
|
||||||
|
</item>
|
||||||
|
<!-- ... -->
|
||||||
|
</pack>
|
||||||
|
```
|
||||||
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
7
fastlane/metadata/android/en-US/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
|
||||||
@@ -10,12 +10,14 @@ Currently supported features include:
|
|||||||
<li>Typing indicators and message markers</li>
|
<li>Typing indicators and message markers</li>
|
||||||
<li>Chat backgrounds</li>
|
<li>Chat backgrounds</li>
|
||||||
<li>Runs in the background without Push Notifications</li>
|
<li>Runs in the background without Push Notifications</li>
|
||||||
|
<li>OMEMO (Currently not compatible with most apps)</li>
|
||||||
|
<li>Stickers</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
For the best experience, I recommend a server that:
|
For the best experience, I recommend a server that:
|
||||||
<ul>
|
<ul>
|
||||||
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
||||||
<li>Supports SCRAM-SHA-1 or SCRAM-SHA-256</li>
|
<li>Supports SCRAM-SHA-1, SCRAM-SHA-256 or SCRAM-SHA-512</li>
|
||||||
<li>Supports HTTP File Upload</li>
|
<li>Supports HTTP File Upload</li>
|
||||||
<li>Supports Stream Management</li>
|
<li>Supports Stream Management</li>
|
||||||
<li>Supports Client State Indication</li>
|
<li>Supports Client State Indication</li>
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ files:
|
|||||||
roster:
|
roster:
|
||||||
type: List<RosterItem>?
|
type: List<RosterItem>?
|
||||||
deserialise: true
|
deserialise: true
|
||||||
|
stickers:
|
||||||
|
type: List<StickerPack>?
|
||||||
|
deserialise: true
|
||||||
# Returned by [GetMessagesForJidCommand]
|
# Returned by [GetMessagesForJidCommand]
|
||||||
- name: MessagesResultEvent
|
- name: MessagesResultEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
@@ -103,6 +106,13 @@ files:
|
|||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
|
# Triggered in response to a [GetBlocklistCommand]
|
||||||
|
- name: GetBlocklistResultEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
entries: List<String>
|
||||||
# Triggered by DownloadService or UploadService.
|
# Triggered by DownloadService or UploadService.
|
||||||
- name: ProgressEvent
|
- name: ProgressEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
@@ -163,6 +173,7 @@ files:
|
|||||||
supportsCsi: bool
|
supportsCsi: bool
|
||||||
supportsUserBlocking: bool
|
supportsUserBlocking: bool
|
||||||
supportsHttpFileUpload: bool
|
supportsHttpFileUpload: bool
|
||||||
|
supportsCarbons: bool
|
||||||
# Returned by [SignOutCommand]
|
# Returned by [SignOutCommand]
|
||||||
- name: SignedOutEvent
|
- name: SignedOutEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
@@ -207,6 +218,53 @@ files:
|
|||||||
conversationJid: String
|
conversationJid: String
|
||||||
title: String
|
title: String
|
||||||
avatarUrl: String
|
avatarUrl: String
|
||||||
|
- name: StickerPackImportSuccessEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
- name: StickerPackImportFailureEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
- name: FetchStickerPackSuccessResult
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
- name: FetchStickerPackFailureResult
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
- name: StickerPackInstallSuccessEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
- name: StickerPackInstallFailureEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
- name: StickerPackAddedEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
builder_name: "Event"
|
builder_name: "Event"
|
||||||
builder_baseclass: "BackgroundEvent"
|
builder_baseclass: "BackgroundEvent"
|
||||||
@@ -259,6 +317,8 @@ files:
|
|||||||
quotedMessage:
|
quotedMessage:
|
||||||
type: Message?
|
type: Message?
|
||||||
deserialise: true
|
deserialise: true
|
||||||
|
editSid: String?
|
||||||
|
editId: int?
|
||||||
- name: SendFilesCommand
|
- name: SendFilesCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@@ -416,6 +476,69 @@ files:
|
|||||||
conversationJid: String
|
conversationJid: String
|
||||||
sid: String
|
sid: String
|
||||||
newUnreadCounter: int
|
newUnreadCounter: int
|
||||||
|
- name: AddReactionToMessageCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messageId: int
|
||||||
|
conversationJid: String
|
||||||
|
emoji: String
|
||||||
|
- name: RemoveReactionFromMessageCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messageId: int
|
||||||
|
conversationJid: String
|
||||||
|
emoji: String
|
||||||
|
- name: MarkOmemoDeviceAsVerifiedCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
deviceId: int
|
||||||
|
jid: String
|
||||||
|
- name: ImportStickerPackCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
path: String
|
||||||
|
- name: RemoveStickerPackCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPackId: String
|
||||||
|
- name: SendStickerCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPackId: String
|
||||||
|
stickerHashKey: String
|
||||||
|
recipient: String
|
||||||
|
- name: FetchStickerPackCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPackId: String
|
||||||
|
jid: String
|
||||||
|
- name: InstallStickerPackCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
- name: GetBlocklistCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
# get${builder_Name}FromJson
|
# get${builder_Name}FromJson
|
||||||
builder_name: "Command"
|
builder_name: "Command"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
|||||||
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sharedmedia_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';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
import 'package:moxxyv2/ui/events.dart';
|
import 'package:moxxyv2/ui/events.dart';
|
||||||
/*
|
/*
|
||||||
@@ -55,14 +57,17 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
|
|||||||
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
||||||
import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||||
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||||
import 'package:moxxyv2/ui/service/data.dart';
|
import 'package:moxxyv2/ui/service/data.dart';
|
||||||
import 'package:moxxyv2/ui/service/progress.dart';
|
import 'package:moxxyv2/ui/service/progress.dart';
|
||||||
|
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||||
import 'package:moxxyv2/ui/theme.dart';
|
import 'package:moxxyv2/ui/theme.dart';
|
||||||
import 'package:page_transition/page_transition.dart';
|
import 'package:page_transition/page_transition.dart';
|
||||||
import 'package:share_handler/share_handler.dart';
|
|
||||||
|
|
||||||
void setupLogging() {
|
void setupLogging() {
|
||||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||||
@@ -76,6 +81,7 @@ void setupLogging() {
|
|||||||
Future<void> setupUIServices() async {
|
Future<void> setupUIServices() async {
|
||||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||||
|
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||||
@@ -83,7 +89,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
|||||||
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
||||||
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
||||||
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
||||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc()); GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||||
|
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||||
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
||||||
@@ -94,11 +101,10 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
|||||||
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
|
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
|
||||||
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
|
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
|
||||||
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
|
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
|
||||||
|
GetIt.I.registerSingleton<StickersBloc>(StickersBloc());
|
||||||
|
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
|
|
||||||
// Padding(padding: ..., child: Column(children: [ ... ]))
|
|
||||||
// TODO(Unknown): Theme the switches
|
|
||||||
void main() async {
|
void main() async {
|
||||||
setupLogging();
|
setupLogging();
|
||||||
await setupUIServices();
|
await setupUIServices();
|
||||||
@@ -164,6 +170,12 @@ void main() async {
|
|||||||
BlocProvider<OwnDevicesBloc>(
|
BlocProvider<OwnDevicesBloc>(
|
||||||
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
|
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
|
||||||
),
|
),
|
||||||
|
BlocProvider<StickersBloc>(
|
||||||
|
create: (_) => GetIt.I.get<StickersBloc>(),
|
||||||
|
),
|
||||||
|
BlocProvider<StickerPackBloc>(
|
||||||
|
create: (_) => GetIt.I.get<StickerPackBloc>(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: TranslationProvider(
|
child: TranslationProvider(
|
||||||
child: MyApp(navKey),
|
child: MyApp(navKey),
|
||||||
@@ -173,7 +185,6 @@ void main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends StatefulWidget {
|
||||||
|
|
||||||
const MyApp(this.navigationKey, { super.key });
|
const MyApp(this.navigationKey, { super.key });
|
||||||
final GlobalKey<NavigatorState> navigationKey;
|
final GlobalKey<NavigatorState> navigationKey;
|
||||||
|
|
||||||
@@ -187,46 +198,18 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async "version" of initState()
|
||||||
|
Future<void> _initState() async {
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
_setupSharingHandler();
|
// Set up receiving share intents
|
||||||
|
await GetIt.I.get<UISharingService>().initialize();
|
||||||
|
|
||||||
// Lift the UI block
|
// Lift the UI block
|
||||||
GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSharedMedia(SharedMedia media) async {
|
|
||||||
final attachments = media.attachments ?? [];
|
|
||||||
GetIt.I.get<ShareSelectionBloc>().add(
|
|
||||||
ShareSelectionRequestedEvent(
|
|
||||||
attachments.map((a) => a!.path).toList(),
|
|
||||||
media.content,
|
|
||||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _setupSharingHandler() async {
|
|
||||||
final handler = ShareHandlerPlatform.instance;
|
|
||||||
final media = await handler.getInitialSharedMedia();
|
|
||||||
|
|
||||||
// Shared while the app was closed
|
|
||||||
if (media != null) {
|
|
||||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
|
||||||
await _handleSharedMedia(media);
|
|
||||||
}
|
|
||||||
|
|
||||||
await handler.resetInitialSharedMedia();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared while the app is stil running
|
|
||||||
handler.sharedMediaStream.listen((SharedMedia media) async {
|
|
||||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
|
||||||
await _handleSharedMedia(media);
|
|
||||||
}
|
|
||||||
|
|
||||||
await handler.resetInitialSharedMedia();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -299,6 +282,11 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
case devicesRoute: return DevicesPage.route;
|
case devicesRoute: return DevicesPage.route;
|
||||||
case ownDevicesRoute: return OwnDevicesPage.route;
|
case ownDevicesRoute: return OwnDevicesPage.route;
|
||||||
case appearanceRoute: return AppearanceSettingsPage.route;
|
case appearanceRoute: return AppearanceSettingsPage.route;
|
||||||
|
case qrCodeScannerRoute: return QrCodeScanningPage.getRoute(
|
||||||
|
settings.arguments! as QrCodeScanningArguments,
|
||||||
|
);
|
||||||
|
case stickersRoute: return StickersSettingsPage.route;
|
||||||
|
case stickerPackRoute: return StickerPackPage.route;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import 'dart:io';
|
|||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
import 'package:image_size_getter/image_size_getter.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
@@ -14,6 +12,7 @@ import 'package:moxxyv2/service/service.dart';
|
|||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/shared/avatar.dart';
|
import 'package:moxxyv2/shared/avatar.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
|
||||||
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
||||||
/// avatar data. Returns the cleaned version.
|
/// avatar data. Returns the cleaned version.
|
||||||
@@ -26,56 +25,48 @@ String _cleanBase64String(String original) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AvatarData {
|
||||||
|
const _AvatarData(this.data, this.id);
|
||||||
|
final List<int> data;
|
||||||
|
final String id;
|
||||||
|
}
|
||||||
|
|
||||||
class AvatarService {
|
class AvatarService {
|
||||||
|
final Logger _log = Logger('AvatarService');
|
||||||
|
|
||||||
AvatarService() : _log = Logger('AvatarService');
|
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
||||||
final Logger _log;
|
await updateAvatarForJid(
|
||||||
|
event.jid,
|
||||||
|
event.hash,
|
||||||
|
base64Decode(_cleanBase64String(event.base64)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
UserAvatarManager _getUserAvatarManager() => GetIt.I.get<XmppConnection>().getManagerById<UserAvatarManager>(userAvatarManager)!;
|
Future<void> updateAvatarForJid(String jid, String hash, List<int> data) async {
|
||||||
|
|
||||||
DiscoManager _getDiscoManager() => GetIt.I.get<XmppConnection>().getManagerById<DiscoManager>(discoManager)!;
|
|
||||||
|
|
||||||
Future<void> updateAvatarForJid(String jid, String hash, String base64) async {
|
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final rs = GetIt.I.get<RosterService>();
|
final rs = GetIt.I.get<RosterService>();
|
||||||
final originalConversation = await cs.getConversationByJid(jid);
|
final originalConversation = await cs.getConversationByJid(jid);
|
||||||
var saved = false;
|
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||||
|
|
||||||
|
if (originalConversation == null && originalRoster == null) return;
|
||||||
|
|
||||||
// Clean the raw data. Since this may arrive by chunks, those chunks may contain
|
|
||||||
// weird data pieces.
|
|
||||||
final base64Data = base64Decode(_cleanBase64String(base64));
|
|
||||||
if (originalConversation != null) {
|
|
||||||
final avatarPath = await saveAvatarInCache(
|
final avatarPath = await saveAvatarInCache(
|
||||||
base64Data,
|
data,
|
||||||
hash,
|
hash,
|
||||||
jid,
|
jid,
|
||||||
originalConversation.avatarUrl,
|
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
||||||
);
|
);
|
||||||
saved = true;
|
|
||||||
|
if (originalConversation != null) {
|
||||||
final conv = await cs.updateConversation(
|
final conv = await cs.updateConversation(
|
||||||
originalConversation.id,
|
originalConversation.id,
|
||||||
avatarUrl: avatarPath,
|
avatarUrl: avatarPath,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(ConversationUpdatedEvent(conversation: conv));
|
sendEvent(ConversationUpdatedEvent(conversation: conv));
|
||||||
} else {
|
|
||||||
_log.warning('Failed to get conversation');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
|
||||||
if (originalRoster != null) {
|
if (originalRoster != null) {
|
||||||
var avatarPath = '';
|
|
||||||
if (saved) {
|
|
||||||
avatarPath = await getAvatarPath(jid, hash);
|
|
||||||
} else {
|
|
||||||
avatarPath = await saveAvatarInCache(
|
|
||||||
base64Data,
|
|
||||||
hash,
|
|
||||||
jid,
|
|
||||||
originalRoster.avatarUrl,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final roster = await rs.updateRosterItem(
|
final roster = await rs.updateRosterItem(
|
||||||
originalRoster.id,
|
originalRoster.id,
|
||||||
avatarUrl: avatarPath,
|
avatarUrl: avatarPath,
|
||||||
@@ -86,65 +77,72 @@ class AvatarService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
|
||||||
final response = await _getDiscoManager().discoItemsQuery(jid);
|
final am = GetIt.I.get<XmppConnection>()
|
||||||
final items = response.isType<DiscoError>() ?
|
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
<DiscoItem>[] :
|
final idResult = await am.getAvatarId(jid);
|
||||||
response.get<List<DiscoItem>>();
|
if (idResult.isType<AvatarError>()) {
|
||||||
final itemNodes = items.map((i) => i.node);
|
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final id = idResult.get<String>();
|
||||||
|
if (id == oldHash) return null;
|
||||||
|
|
||||||
_log.finest('Disco items for $jid:');
|
final avatarResult = await am.getUserAvatar(jid);
|
||||||
for (final item in itemNodes) {
|
if (avatarResult.isType<AvatarError>()) {
|
||||||
_log.finest('- $item');
|
_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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var base64 = '';
|
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
|
||||||
var hash = '';
|
|
||||||
if (listContains<DiscoItem>(items, (item) => item.node == userAvatarDataXmlns)) {
|
|
||||||
final avatar = _getUserAvatarManager();
|
|
||||||
final pubsubHash = await avatar.getAvatarId(jid);
|
|
||||||
|
|
||||||
// Don't request if we already have the newest avatar
|
|
||||||
if (pubsubHash == oldHash) return;
|
|
||||||
|
|
||||||
// Query via PubSub
|
|
||||||
final data = await avatar.getUserAvatar(jid);
|
|
||||||
if (data == null) return;
|
|
||||||
|
|
||||||
base64 = data.base64;
|
|
||||||
hash = data.hash;
|
|
||||||
} else {
|
|
||||||
// Query the vCard
|
// Query the vCard
|
||||||
final vm = GetIt.I.get<XmppConnection>().getManagerById<VCardManager>(vcardManager)!;
|
final vm = GetIt.I.get<XmppConnection>()
|
||||||
final vcard = await vm.requestVCard(jid);
|
.getManagerById<VCardManager>(vcardManager)!;
|
||||||
if (vcard != null) {
|
final vcardResult = await vm.requestVCard(jid);
|
||||||
final binval = vcard.photo?.binval;
|
if (vcardResult.isType<VCardError>()) return null;
|
||||||
if (binval != null) {
|
|
||||||
// Clean the raw data. Since this may arrive by chunks, those chunks may contain
|
|
||||||
// weird data pieces.
|
|
||||||
base64 = _cleanBase64String(binval);
|
|
||||||
|
|
||||||
final rawHash = await Sha1().hash(base64Decode(base64));
|
final binval = vcardResult.get<VCard>().photo?.binval;
|
||||||
hash = HEX.encode(rawHash.bytes);
|
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);
|
vm.setLastHash(jid, hash);
|
||||||
} else {
|
|
||||||
return;
|
return _AvatarData(
|
||||||
}
|
data,
|
||||||
} else {
|
hash,
|
||||||
return;
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateAvatarForJid(jid, hash, base64);
|
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 {
|
Future<bool> subscribeJid(String jid) async {
|
||||||
return _getUserAvatarManager().subscribe(jid);
|
return (await GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||||
|
.subscribe(jid)).isType<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> unsubscribeJid(String jid) async {
|
Future<bool> unsubscribeJid(String jid) async {
|
||||||
return _getUserAvatarManager().unsubscribe(jid);
|
return (await GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||||
|
.unsubscribe(jid)).isType<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes the data at [path] as an avatar with PubSub ID
|
/// Publishes the data at [path] as an avatar with PubSub ID
|
||||||
@@ -158,59 +156,80 @@ class AvatarService {
|
|||||||
final public = prefs.isAvatarPublic;
|
final public = prefs.isAvatarPublic;
|
||||||
|
|
||||||
// Read the image metadata
|
// Read the image metadata
|
||||||
final imageSize = ImageSizeGetter.getSize(MemoryInput(bytes));
|
final imageSize = (await getImageSizeFromData(bytes))!;
|
||||||
|
|
||||||
// Publish data and metadata
|
// Publish data and metadata
|
||||||
final manager = _getUserAvatarManager();
|
final am = GetIt.I.get<XmppConnection>()
|
||||||
await manager.publishUserAvatar(
|
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
|
|
||||||
|
_log.finest('Publishing avatar...');
|
||||||
|
final dataResult = await am.publishUserAvatar(
|
||||||
base64,
|
base64,
|
||||||
hash,
|
hash,
|
||||||
public,
|
public,
|
||||||
);
|
);
|
||||||
await manager.publishUserAvatarMetadata(
|
if (dataResult.isType<AvatarError>()) {
|
||||||
|
_log.finest('Avatar data publishing failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(Unknown): Make sure that the image is not too large.
|
||||||
|
final metadataResult = await am.publishUserAvatarMetadata(
|
||||||
UserAvatarMetadata(
|
UserAvatarMetadata(
|
||||||
hash,
|
hash,
|
||||||
bytes.length,
|
bytes.length,
|
||||||
imageSize.width,
|
imageSize.width.toInt(),
|
||||||
imageSize.height,
|
imageSize.height.toInt(),
|
||||||
// TODO(PapaTutuWawa): Maybe do a check here
|
// TODO(PapaTutuWawa): Maybe do a check here
|
||||||
'image/png',
|
'image/png',
|
||||||
),
|
),
|
||||||
public,
|
public,
|
||||||
);
|
);
|
||||||
|
if (metadataResult.isType<AvatarError>()) {
|
||||||
|
_log.finest('Avatar metadata publishing failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.finest('Avatar publishing done');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> requestOwnAvatar() async {
|
Future<void> requestOwnAvatar() async {
|
||||||
final avatar = _getUserAvatarManager();
|
final am = GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
final xmpp = GetIt.I.get<XmppService>();
|
final xmpp = GetIt.I.get<XmppService>();
|
||||||
final state = await xmpp.getXmppState();
|
final state = await xmpp.getXmppState();
|
||||||
final jid = state.jid!;
|
final jid = state.jid!;
|
||||||
final id = await avatar.getAvatarId(jid);
|
final idResult = await am.getAvatarId(jid);
|
||||||
|
if (idResult.isType<AvatarError>()) {
|
||||||
|
_log.info('Error while getting latest avatar id for own avatar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final id = idResult.get<String>();
|
||||||
|
|
||||||
if (id == state.avatarHash) return;
|
if (id == state.avatarHash) return;
|
||||||
|
|
||||||
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself');
|
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself');
|
||||||
final data = await avatar.getUserAvatar(jid);
|
final avatarDataResult = await am.getUserAvatar(jid);
|
||||||
if (data == null) {
|
if (avatarDataResult.isType<AvatarError>()) {
|
||||||
_log.severe('Failed to fetch our avatar');
|
_log.severe('Failed to fetch our avatar');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||||
|
|
||||||
_log.info('Received data for our own avatar');
|
_log.info('Received data for our own avatar');
|
||||||
|
|
||||||
final avatarPath = await saveAvatarInCache(
|
final avatarPath = await saveAvatarInCache(
|
||||||
base64Decode(_cleanBase64String(data.base64)),
|
base64Decode(_cleanBase64String(avatarData.base64)),
|
||||||
data.hash,
|
avatarData.hash,
|
||||||
jid,
|
jid,
|
||||||
state.avatarUrl,
|
state.avatarUrl,
|
||||||
);
|
);
|
||||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
await xmpp.modifyXmppState((state) => state.copyWith(
|
||||||
avatarUrl: avatarPath,
|
avatarUrl: avatarPath,
|
||||||
avatarHash: data.hash,
|
avatarHash: avatarData.hash,
|
||||||
),);
|
),);
|
||||||
|
|
||||||
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: data.hash));
|
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
|
||||||
@@ -9,35 +12,93 @@ enum BlockPushType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class BlocklistService {
|
class BlocklistService {
|
||||||
|
BlocklistService();
|
||||||
|
List<String>? _blocklist;
|
||||||
|
bool _requested = false;
|
||||||
|
bool? _supported;
|
||||||
|
final Logger _log = Logger('BlocklistService');
|
||||||
|
|
||||||
BlocklistService() :
|
void onNewConnection() {
|
||||||
_blocklistCache = List.empty(growable: true),
|
// Invalidate the caches
|
||||||
_requestedBlocklist = false;
|
_blocklist = null;
|
||||||
final List<String> _blocklistCache;
|
_requested = false;
|
||||||
bool _requestedBlocklist;
|
_supported = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<String>> _requestBlocklist() async {
|
Future<bool> _checkSupport() async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
return _supported ??= await GetIt.I.get<XmppConnection>()
|
||||||
_blocklistCache
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
..clear()
|
.isSupported();
|
||||||
..addAll(await manager.getBlocklist());
|
}
|
||||||
_requestedBlocklist = true;
|
|
||||||
return _blocklistCache;
|
Future<void> _requestBlocklist() async {
|
||||||
|
assert(_blocklist != null, 'The blocklist must be loaded from the database before requesting');
|
||||||
|
|
||||||
|
// Check if blocking is supported
|
||||||
|
if (!(await _checkSupport())) {
|
||||||
|
_log.warning('Blocklist requested but server does not support it.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final blocklist = await GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
.getBlocklist();
|
||||||
|
|
||||||
|
// Diff the received blocklist with the cache
|
||||||
|
final newItems = List<String>.empty(growable: true);
|
||||||
|
final removedItems = List<String>.empty(growable: true);
|
||||||
|
final db = GetIt.I.get<DatabaseService>();
|
||||||
|
for (final item in blocklist) {
|
||||||
|
if (!_blocklist!.contains(item)) {
|
||||||
|
await db.addBlocklistEntry(item);
|
||||||
|
_blocklist!.add(item);
|
||||||
|
newItems.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Diff the cache with the received blocklist
|
||||||
|
for (final item in _blocklist!) {
|
||||||
|
if (!blocklist.contains(item)) {
|
||||||
|
await db.removeBlocklistEntry(item);
|
||||||
|
_blocklist!.remove(item);
|
||||||
|
removedItems.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_requested = true;
|
||||||
|
|
||||||
|
// Trigger an UI event if we have anything to tell the UI
|
||||||
|
if (newItems.isNotEmpty || removedItems.isNotEmpty) {
|
||||||
|
sendEvent(
|
||||||
|
BlocklistPushEvent(
|
||||||
|
added: newItems,
|
||||||
|
removed: removedItems,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the blocklist from the database
|
/// Returns the blocklist from the database
|
||||||
Future<List<String>> getBlocklist() async {
|
Future<List<String>> getBlocklist() async {
|
||||||
if (!_requestedBlocklist) {
|
if (_blocklist == null) {
|
||||||
_blocklistCache
|
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries();
|
||||||
..clear()
|
|
||||||
..addAll(await _requestBlocklist());
|
if (!_requested) {
|
||||||
|
unawaited(_requestBlocklist());
|
||||||
}
|
}
|
||||||
|
|
||||||
return _blocklistCache;
|
return _blocklist!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_requested) {
|
||||||
|
unawaited(_requestBlocklist());
|
||||||
|
}
|
||||||
|
|
||||||
|
return _blocklist!;
|
||||||
}
|
}
|
||||||
|
|
||||||
void onUnblockAllPush() {
|
void onUnblockAllPush() {
|
||||||
_blocklistCache.clear();
|
_blocklist = List<String>.empty(growable: true);
|
||||||
sendEvent(
|
sendEvent(
|
||||||
BlocklistUnblockAllEvent(),
|
BlocklistUnblockAllEvent(),
|
||||||
);
|
);
|
||||||
@@ -45,21 +106,25 @@ class BlocklistService {
|
|||||||
|
|
||||||
Future<void> onBlocklistPush(BlockPushType type, List<String> items) async {
|
Future<void> onBlocklistPush(BlockPushType type, List<String> items) async {
|
||||||
// We will fetch it later when getBlocklist is called
|
// We will fetch it later when getBlocklist is called
|
||||||
if (!_requestedBlocklist) return;
|
if (!_requested) return;
|
||||||
|
|
||||||
final newBlocks = List<String>.empty(growable: true);
|
final newBlocks = List<String>.empty(growable: true);
|
||||||
final removedBlocks = List<String>.empty(growable: true);
|
final removedBlocks = List<String>.empty(growable: true);
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case BlockPushType.block: {
|
case BlockPushType.block: {
|
||||||
if (_blocklistCache.contains(item)) continue;
|
if (_blocklist!.contains(item)) continue;
|
||||||
_blocklistCache.add(item);
|
_blocklist!.add(item);
|
||||||
newBlocks.add(item);
|
newBlocks.add(item);
|
||||||
|
|
||||||
|
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case BlockPushType.unblock: {
|
case BlockPushType.unblock: {
|
||||||
_blocklistCache.removeWhere((i) => i == item);
|
_blocklist!.removeWhere((i) => i == item);
|
||||||
removedBlocks.add(item);
|
removedBlocks.add(item);
|
||||||
|
|
||||||
|
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -74,17 +139,47 @@ class BlocklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> blockJid(String jid) async {
|
Future<bool> blockJid(String jid) async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
// Check if blocking is supported
|
||||||
return manager.block([ jid ]);
|
if (!(await _checkSupport())) {
|
||||||
|
_log.warning('Blocking $jid requested but server does not support it.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blocklist!.add(jid);
|
||||||
|
await GetIt.I.get<DatabaseService>()
|
||||||
|
.addBlocklistEntry(jid);
|
||||||
|
return GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
.block([jid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> unblockJid(String jid) async {
|
Future<bool> unblockJid(String jid) async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
// Check if blocking is supported
|
||||||
return manager.unblock([ jid ]);
|
if (!(await _checkSupport())) {
|
||||||
|
_log.warning('Unblocking $jid requested but server does not support it.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blocklist!.remove(jid);
|
||||||
|
await GetIt.I.get<DatabaseService>()
|
||||||
|
.removeBlocklistEntry(jid);
|
||||||
|
return GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
.unblock([jid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> unblockAll() async {
|
Future<bool> unblockAll() async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
// Check if blocking is supported
|
||||||
return manager.unblockAll();
|
if (!(await _checkSupport())) {
|
||||||
|
_log.warning('Unblocking all JIDs requested but server does not support it.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blocklist!.clear();
|
||||||
|
await GetIt.I.get<DatabaseService>()
|
||||||
|
.removeAllBlocklistEntries();
|
||||||
|
return GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
.unblockAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
300
lib/service/contacts.dart
Normal file
300
lib/service/contacts.dart
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
|
class ContactWrapper {
|
||||||
|
const ContactWrapper(this.id, this.jid, this.displayName, this.thumbnail);
|
||||||
|
final String id;
|
||||||
|
final String jid;
|
||||||
|
final String displayName;
|
||||||
|
final Uint8List? thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ContactsService {
|
||||||
|
ContactsService() {
|
||||||
|
// NOTE: Apparently, this means that if false, contacts that are in 0 groups
|
||||||
|
// are not returned.
|
||||||
|
FlutterContacts.config.includeNonVisibleOnAndroid = true;
|
||||||
|
}
|
||||||
|
final Logger _log = Logger('ContactsService');
|
||||||
|
|
||||||
|
/// JID -> Id
|
||||||
|
Map<String, String>? _contactIds;
|
||||||
|
/// Contact ID -> Display name from the contact or null if we cached that there is
|
||||||
|
/// none
|
||||||
|
final Map<String, String?> _contactDisplayNames = {};
|
||||||
|
|
||||||
|
Future<void> init() async {
|
||||||
|
if (await _canUseContactIntegration()) {
|
||||||
|
enableDatabaseListener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable listening to contact database events
|
||||||
|
void enableDatabaseListener() {
|
||||||
|
FlutterContacts.addListener(_onContactsDatabaseUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Disable listening to contact database events
|
||||||
|
void disableDatabaseListener() {
|
||||||
|
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onContactsDatabaseUpdate() async {
|
||||||
|
_log.finest('Got contacts database update');
|
||||||
|
await scanContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries the contact list for contacts that include a XMPP URI.
|
||||||
|
Future<List<ContactWrapper>> _fetchContactsWithJabber() async {
|
||||||
|
final contacts = await FlutterContacts.getContacts(
|
||||||
|
withProperties: true,
|
||||||
|
withThumbnail: true,
|
||||||
|
);
|
||||||
|
_log.finest('Got ${contacts.length} contacts');
|
||||||
|
|
||||||
|
final jabberContacts = List<ContactWrapper>.empty(growable: true);
|
||||||
|
for (final c in contacts) {
|
||||||
|
final index = c.socialMedias
|
||||||
|
.indexWhere((s) => s.label == SocialMediaLabel.jabber);
|
||||||
|
if (index == -1) continue;
|
||||||
|
|
||||||
|
jabberContacts.add(
|
||||||
|
ContactWrapper(
|
||||||
|
c.id,
|
||||||
|
c.socialMedias[index].userName,
|
||||||
|
c.displayName,
|
||||||
|
c.thumbnail,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_log.finest('${jabberContacts.length} contacts have an XMPP address');
|
||||||
|
|
||||||
|
return jabberContacts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks whether the contact integration is enabled by the user in the preferences.
|
||||||
|
/// Returns true if that is the case. If not, returns false.
|
||||||
|
Future<bool> isContactIntegrationEnabled() async {
|
||||||
|
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
|
return prefs.enableContactIntegration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if we a) have the permission to access the contact list and b) if the
|
||||||
|
/// user wants to use this integration.
|
||||||
|
/// Returns true if we can proceed with accessing the contact list. False, if not.
|
||||||
|
Future<bool> _canUseContactIntegration() async {
|
||||||
|
if (!(await isContactIntegrationEnabled())) {
|
||||||
|
_log.finest('_canUseContactIntegration: Returning false since enableContactIntegration is false');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final permission = await Permission.contacts.status;
|
||||||
|
if (permission == PermissionStatus.denied) {
|
||||||
|
_log.finest("_canUseContactIntegration: Returning false since we don't have the contacts permission");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries the database for the mapping of JID -> Contact ID. The result is
|
||||||
|
/// cached after the first call.
|
||||||
|
Future<Map<String, String>> _getContactIds() async {
|
||||||
|
if (_contactIds != null) return _contactIds!;
|
||||||
|
|
||||||
|
_contactIds = await GetIt.I.get<DatabaseService>().getContactIds();
|
||||||
|
return _contactIds!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Queries the contact list, if enabled and allowed, and returns the contact's
|
||||||
|
/// display name.
|
||||||
|
///
|
||||||
|
/// [id] is the id of the contact. A null value indicates that there is no
|
||||||
|
/// contact and null will be returned immediately.
|
||||||
|
Future<String?> getContactDisplayName(String? id) async {
|
||||||
|
if (id == null ||
|
||||||
|
!(await _canUseContactIntegration())) return null;
|
||||||
|
if (_contactDisplayNames.containsKey(id)) return _contactDisplayNames[id];
|
||||||
|
|
||||||
|
final result = await FlutterContacts.getContact(
|
||||||
|
id,
|
||||||
|
withThumbnail: false,
|
||||||
|
);
|
||||||
|
_contactDisplayNames[id] = result?.displayName;
|
||||||
|
return result?.displayName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the contact Id for the JID [jid]. If either the contact integration is
|
||||||
|
/// disabled, not possible (due to missing permissions) or there is no contact with
|
||||||
|
/// [jid] as their Jabber attribute, returns null.
|
||||||
|
Future<String?> getContactIdForJid(String jid) async {
|
||||||
|
if (!(await _canUseContactIntegration())) return null;
|
||||||
|
|
||||||
|
return (await _getContactIds())[jid];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the avatar file for the contact with JID [jid] as their
|
||||||
|
/// Jabber attribute. If either the contact integration is disabled, not possible
|
||||||
|
/// (due to missing permissions) or there is no contact with [jid] as their Jabber
|
||||||
|
/// attribute, returns null.
|
||||||
|
Future<String?> getProfilePicturePathForJid(String jid) async {
|
||||||
|
final id = await getContactIdForJid(jid);
|
||||||
|
if (id == null) return null;
|
||||||
|
|
||||||
|
final avatarPath = await getContactProfilePicturePath(id);
|
||||||
|
return File(avatarPath).existsSync() ?
|
||||||
|
avatarPath :
|
||||||
|
null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> scanContacts() async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>();
|
||||||
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
final rs = GetIt.I.get<RosterService>();
|
||||||
|
final contacts = await _fetchContactsWithJabber();
|
||||||
|
// JID -> Id
|
||||||
|
final knownContactIds = await _getContactIds();
|
||||||
|
// Id -> JID
|
||||||
|
final knownContactIdsReverse = knownContactIds
|
||||||
|
.map((key, value) => MapEntry(value, key));
|
||||||
|
final modifiedRosterItems = List<RosterItem>.empty(growable: true);
|
||||||
|
final addedRosterItems = List<RosterItem>.empty(growable: true);
|
||||||
|
final removedRosterItems = List<String>.empty(growable: true);
|
||||||
|
|
||||||
|
for (final id in List<String>.from(knownContactIds.values)) {
|
||||||
|
final index = contacts.indexWhere((c) => c.id == id);
|
||||||
|
if (index != -1) continue;
|
||||||
|
|
||||||
|
final jid = knownContactIdsReverse[id]!;
|
||||||
|
await db.removeContactId(id);
|
||||||
|
_contactIds!.remove(knownContactIdsReverse[id]);
|
||||||
|
|
||||||
|
// Remove the avatar file, if it existed
|
||||||
|
final avatarPath = await getContactProfilePicturePath(id);
|
||||||
|
final avatarFile = File(avatarPath);
|
||||||
|
if (avatarFile.existsSync()) {
|
||||||
|
unawaited(avatarFile.delete());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the contact attributes from the conversation, if it existed
|
||||||
|
final c = await cs.getConversationByJid(jid);
|
||||||
|
if (c != null) {
|
||||||
|
final newConv = await cs.updateConversation(
|
||||||
|
c.id,
|
||||||
|
contactId: null,
|
||||||
|
contactAvatarPath: null,
|
||||||
|
contactDisplayName: null,
|
||||||
|
);
|
||||||
|
sendEvent(
|
||||||
|
ConversationUpdatedEvent(
|
||||||
|
conversation: newConv,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the contact attributes from the roster item, if it existed
|
||||||
|
final r = await rs.getRosterItemByJid(jid);
|
||||||
|
if (r != null) {
|
||||||
|
if (r.pseudoRosterItem) {
|
||||||
|
_log.finest('Removing pseudo roster item $jid');
|
||||||
|
await rs.removeRosterItem(r.id);
|
||||||
|
removedRosterItems.add(jid);
|
||||||
|
} else {
|
||||||
|
final newRosterItem = await rs.updateRosterItem(
|
||||||
|
r.id,
|
||||||
|
contactId: null,
|
||||||
|
contactAvatarPath: null,
|
||||||
|
contactDisplayName: null,
|
||||||
|
);
|
||||||
|
modifiedRosterItems.add(newRosterItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final contact in contacts) {
|
||||||
|
// Add the ID to the cache and the database if it does not already exist
|
||||||
|
if (!knownContactIds.containsKey(contact.jid)) {
|
||||||
|
await db.addContactId(contact.id, contact.jid);
|
||||||
|
_contactIds![contact.jid] = contact.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the avatar image
|
||||||
|
// NOTE: We do not check if the file already exists since this function may also
|
||||||
|
// be triggered by the contact database listener. That listener fires when
|
||||||
|
// a change happened, without telling us exactly what happened. So, we
|
||||||
|
// just overwrite it.
|
||||||
|
final contactAvatarPath = await getContactProfilePicturePath(contact.id);
|
||||||
|
if (contact.thumbnail != null) {
|
||||||
|
final file = File(contactAvatarPath);
|
||||||
|
await file.writeAsBytes(contact.thumbnail!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a possibly existing conversation
|
||||||
|
final c = await cs.getConversationByJid(contact.jid);
|
||||||
|
if (c != null) {
|
||||||
|
final newConv = await cs.updateConversation(
|
||||||
|
c.id,
|
||||||
|
contactId: contact.id,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contact.displayName,
|
||||||
|
);
|
||||||
|
sendEvent(
|
||||||
|
ConversationUpdatedEvent(
|
||||||
|
conversation: newConv,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update a possibly existing roster item
|
||||||
|
final r = await rs.getRosterItemByJid(contact.jid);
|
||||||
|
if (r != null) {
|
||||||
|
final newRosterItem = await rs.updateRosterItem(
|
||||||
|
r.id,
|
||||||
|
contactId: contact.id,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contact.displayName,
|
||||||
|
);
|
||||||
|
modifiedRosterItems.add(newRosterItem);
|
||||||
|
} else {
|
||||||
|
final newRosterItem = await rs.addRosterItemFromData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
contact.jid,
|
||||||
|
contact.jid.split('@').first,
|
||||||
|
'none',
|
||||||
|
'none',
|
||||||
|
true,
|
||||||
|
contact.id,
|
||||||
|
contactAvatarPath,
|
||||||
|
contact.displayName,
|
||||||
|
);
|
||||||
|
addedRosterItems.add(newRosterItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedRosterItems.isNotEmpty ||
|
||||||
|
modifiedRosterItems.isNotEmpty ||
|
||||||
|
removedRosterItems.isNotEmpty) {
|
||||||
|
sendEvent(
|
||||||
|
RosterDiffEvent(
|
||||||
|
added: addedRosterItems,
|
||||||
|
modified: modifiedRosterItems,
|
||||||
|
removed: removedRosterItems,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:get_it/get_it.dart';
|
|||||||
import 'package:moxlib/moxlib.dart';
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/shared/cache.dart';
|
import 'package:moxxyv2/shared/cache.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
@@ -64,6 +65,9 @@ class ConversationService {
|
|||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
bool? muted,
|
bool? muted,
|
||||||
bool? encrypted,
|
bool? encrypted,
|
||||||
|
Object? contactId = notSpecified,
|
||||||
|
Object? contactAvatarPath = notSpecified,
|
||||||
|
Object? contactDisplayName = notSpecified,
|
||||||
}) async {
|
}) async {
|
||||||
final conversation = (await _getConversationById(id))!;
|
final conversation = (await _getConversationById(id))!;
|
||||||
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
||||||
@@ -76,6 +80,9 @@ class ConversationService {
|
|||||||
chatState: conversation.chatState,
|
chatState: conversation.chatState,
|
||||||
muted: muted,
|
muted: muted,
|
||||||
encrypted: encrypted,
|
encrypted: encrypted,
|
||||||
|
contactId: contactId,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Copy over the old lastMessage if a new one was not set
|
// Copy over the old lastMessage if a new one was not set
|
||||||
@@ -98,6 +105,9 @@ class ConversationService {
|
|||||||
bool open,
|
bool open,
|
||||||
bool muted,
|
bool muted,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
|
String? contactId,
|
||||||
|
String? contactAvatarPath,
|
||||||
|
String? contactDisplayName,
|
||||||
) async {
|
) async {
|
||||||
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
||||||
title,
|
title,
|
||||||
@@ -109,6 +119,9 @@ class ConversationService {
|
|||||||
open,
|
open,
|
||||||
muted,
|
muted,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
contactId,
|
||||||
|
contactAvatarPath,
|
||||||
|
contactDisplayName,
|
||||||
);
|
);
|
||||||
|
|
||||||
_conversationCache.cache(newConversation.id, newConversation);
|
_conversationCache.cache(newConversation.id, newConversation);
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ const omemoRatchetsTable = 'OmemoSessions';
|
|||||||
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
||||||
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
||||||
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
||||||
|
const omemoFingerprintCache = 'OmemoFingerprintCache';
|
||||||
const xmppStateTable = 'XmppState';
|
const xmppStateTable = 'XmppState';
|
||||||
|
const contactsTable = 'Contacts';
|
||||||
|
const stickersTable = 'Stickers';
|
||||||
|
const stickerPacksTable = 'StickerPacks';
|
||||||
|
const blocklistTable = 'Blocklist';
|
||||||
|
|
||||||
const typeString = 0;
|
const typeString = 0;
|
||||||
const typeInt = 1;
|
const typeInt = 1;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:moxxyv2/service/database/constants.dart';
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/preference.dart';
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
@@ -52,6 +53,13 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
isUploading INTEGER NOT NULL,
|
isUploading INTEGER NOT NULL,
|
||||||
mediaSize INTEGER,
|
mediaSize INTEGER,
|
||||||
isRetracted INTEGER,
|
isRetracted INTEGER,
|
||||||
|
isEdited INTEGER NOT NULL,
|
||||||
|
reactions TEXT NOT NULL,
|
||||||
|
containsNoStore INTEGER NOT NULL,
|
||||||
|
stickerPackId TEXT,
|
||||||
|
stickerHashKey TEXT,
|
||||||
|
pseudoMessageType INTEGER,
|
||||||
|
pseudoMessageData TEXT,
|
||||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
@@ -69,11 +77,25 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
open INTEGER NOT NULL,
|
open INTEGER NOT NULL,
|
||||||
muted INTEGER NOT NULL,
|
muted INTEGER NOT NULL,
|
||||||
encrypted INTEGER NOT NULL,
|
encrypted INTEGER NOT NULL,
|
||||||
lastMessageId INTEGER NOT NULL,
|
lastMessageId INTEGER,
|
||||||
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id)
|
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
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Contacts
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $contactsTable (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
jid TEXT NOT NULL
|
||||||
|
)'''
|
||||||
|
);
|
||||||
|
|
||||||
// Shared media
|
// Shared media
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
@@ -99,10 +121,56 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
avatarUrl TEXT NOT NULL,
|
avatarUrl TEXT NOT NULL,
|
||||||
avatarHash TEXT NOT NULL,
|
avatarHash TEXT NOT NULL,
|
||||||
subscription TEXT NOT NULL,
|
subscription TEXT NOT NULL,
|
||||||
ask TEXT NOT NULL
|
ask TEXT NOT NULL,
|
||||||
|
contactId TEXT,
|
||||||
|
contactAvatarPath TEXT,
|
||||||
|
contactDisplayName TEXT,
|
||||||
|
pseudoRosterItem INTEGER NOT NULL,
|
||||||
|
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||||
|
ON DELETE SET NULL
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stickers
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $stickersTable (
|
||||||
|
hashKey TEXT PRIMARY KEY,
|
||||||
|
mediaType TEXT NOT NULL,
|
||||||
|
desc TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
hashes TEXT NOT NULL,
|
||||||
|
urlSources TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
stickerPackId TEXT NOT NULL,
|
||||||
|
suggests TEXT NOT NULL,
|
||||||
|
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $stickerPacksTable (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
hashAlgorithm TEXT NOT NULL,
|
||||||
|
hashValue TEXT NOT NULL,
|
||||||
|
restricted INTEGER NOT NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Blocklist
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $blocklistTable (
|
||||||
|
jid TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
|
||||||
// OMEMO
|
// OMEMO
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
@@ -165,6 +233,15 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
PRIMARY KEY (jid, id)
|
PRIMARY KEY (jid, id)
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoFingerprintCache (
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (jid, id)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -335,4 +412,28 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
'default',
|
'default',
|
||||||
).toDatabaseJson(),
|
).toDatabaseJson(),
|
||||||
);
|
);
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'enableContactIntegration',
|
||||||
|
typeBool,
|
||||||
|
'false',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'isStickersNodePublic',
|
||||||
|
typeBool,
|
||||||
|
'true',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'showDebugMenu',
|
||||||
|
typeBool,
|
||||||
|
boolToString(false),
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,45 @@ import 'package:moxxmpp/moxxmpp.dart';
|
|||||||
import 'package:moxxyv2/service/database/constants.dart';
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/creation.dart';
|
import 'package:moxxyv2/service/database/creation.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_blocklist.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_avatar.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_contacts_integration_pseudo.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_conversations.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_conversations.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_conversations2.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_conversations3.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_language.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_language.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_lmc.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_omemo_fingerprint_cache.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_pseudo_messages.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_reactions.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_reactions_store_hint.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_retraction.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_retraction_conversation.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_retraction_conversation.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_shared_media.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_shared_media.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_stickers.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_stickers_hash_key.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_stickers_hash_key2.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes2.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_stickers_missing_attributes3.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0000_stickers_privacy.dart';
|
||||||
import 'package:moxxyv2/service/database/migrations/0000_xmpp_state.dart';
|
import 'package:moxxyv2/service/database/migrations/0000_xmpp_state.dart';
|
||||||
|
import 'package:moxxyv2/service/database/migrations/0001_debug_menu.dart';
|
||||||
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
import 'package:moxxyv2/service/not_specified.dart';
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
|
import 'package:moxxyv2/service/omemo/types.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/state.dart';
|
import 'package:moxxyv2/service/state.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
||||||
|
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
import 'package:random_string/random_string.dart';
|
import 'package:random_string/random_string.dart';
|
||||||
@@ -61,7 +83,7 @@ class DatabaseService {
|
|||||||
_db = await openDatabase(
|
_db = await openDatabase(
|
||||||
dbPath,
|
dbPath,
|
||||||
password: key,
|
password: key,
|
||||||
version: 9,
|
version: 26,
|
||||||
onCreate: createDatabase,
|
onCreate: createDatabase,
|
||||||
onConfigure: (db) async {
|
onConfigure: (db) async {
|
||||||
// In order to do schema changes during database upgrades, we disable foreign
|
// In order to do schema changes during database upgrades, we disable foreign
|
||||||
@@ -106,6 +128,74 @@ class DatabaseService {
|
|||||||
_log.finest('Running migration for database version 9');
|
_log.finest('Running migration for database version 9');
|
||||||
await upgradeFromV8ToV9(db);
|
await upgradeFromV8ToV9(db);
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 10) {
|
||||||
|
_log.finest('Running migration for database version 10');
|
||||||
|
await upgradeFromV9ToV10(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 11) {
|
||||||
|
_log.finest('Running migration for database version 11');
|
||||||
|
await upgradeFromV10ToV11(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 12) {
|
||||||
|
_log.finest('Running migration for database version 12');
|
||||||
|
await upgradeFromV11ToV12(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 13) {
|
||||||
|
_log.finest('Running migration for database version 13');
|
||||||
|
await upgradeFromV12ToV13(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 14) {
|
||||||
|
_log.finest('Running migration for database version 14');
|
||||||
|
await upgradeFromV13ToV14(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 15) {
|
||||||
|
_log.finest('Running migration for database version 15');
|
||||||
|
await upgradeFromV14ToV15(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 16) {
|
||||||
|
_log.finest('Running migration for database version 16');
|
||||||
|
await upgradeFromV15ToV16(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 17) {
|
||||||
|
_log.finest('Running migration for database version 17');
|
||||||
|
await upgradeFromV16ToV17(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 18) {
|
||||||
|
_log.finest('Running migration for database version 18');
|
||||||
|
await upgradeFromV17ToV18(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 19) {
|
||||||
|
_log.finest('Running migration for database version 19');
|
||||||
|
await upgradeFromV18ToV19(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 20) {
|
||||||
|
_log.finest('Running migration for database version 20');
|
||||||
|
await upgradeFromV19ToV20(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 21) {
|
||||||
|
_log.finest('Running migration for database version 21');
|
||||||
|
await upgradeFromV20ToV21(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 22) {
|
||||||
|
_log.finest('Running migration for database version 22');
|
||||||
|
await upgradeFromV21ToV22(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 23) {
|
||||||
|
_log.finest('Running migration for database version 23');
|
||||||
|
await upgradeFromV22ToV23(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 24) {
|
||||||
|
_log.finest('Running migration for database version 24');
|
||||||
|
await upgradeFromV23ToV24(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 25) {
|
||||||
|
_log.finest('Running migration for database version 25');
|
||||||
|
await upgradeFromV24ToV25(db);
|
||||||
|
}
|
||||||
|
if (oldVersion < 26) {
|
||||||
|
_log.finest('Running migration for database version 26');
|
||||||
|
await upgradeFromV25ToV26(db);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -139,7 +229,7 @@ class DatabaseService {
|
|||||||
tmp.add(
|
tmp.add(
|
||||||
Conversation.fromDatabaseJson(
|
Conversation.fromDatabaseJson(
|
||||||
c,
|
c,
|
||||||
rosterItem != null,
|
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||||
rosterItem?.subscription ?? 'none',
|
rosterItem?.subscription ?? 'none',
|
||||||
sharedMediaRaw,
|
sharedMediaRaw,
|
||||||
lastMessage,
|
lastMessage,
|
||||||
@@ -187,6 +277,9 @@ class DatabaseService {
|
|||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
bool? muted,
|
bool? muted,
|
||||||
bool? encrypted,
|
bool? encrypted,
|
||||||
|
Object? contactId = notSpecified,
|
||||||
|
Object? contactAvatarPath = notSpecified,
|
||||||
|
Object? contactDisplayName = notSpecified,
|
||||||
}) async {
|
}) async {
|
||||||
final cd = (await _db.query(
|
final cd = (await _db.query(
|
||||||
'Conversations',
|
'Conversations',
|
||||||
@@ -223,6 +316,15 @@ class DatabaseService {
|
|||||||
if (encrypted != null) {
|
if (encrypted != null) {
|
||||||
c['encrypted'] = boolToInt(encrypted);
|
c['encrypted'] = boolToInt(encrypted);
|
||||||
}
|
}
|
||||||
|
if (contactId != notSpecified) {
|
||||||
|
c['contactId'] = contactId as String?;
|
||||||
|
}
|
||||||
|
if (contactAvatarPath != notSpecified) {
|
||||||
|
c['contactAvatarPath'] = contactAvatarPath as String?;
|
||||||
|
}
|
||||||
|
if (contactDisplayName != notSpecified) {
|
||||||
|
c['contactDisplayName'] = contactDisplayName as String?;
|
||||||
|
}
|
||||||
|
|
||||||
await _db.update(
|
await _db.update(
|
||||||
'Conversations',
|
'Conversations',
|
||||||
@@ -253,6 +355,9 @@ class DatabaseService {
|
|||||||
bool open,
|
bool open,
|
||||||
bool muted,
|
bool muted,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
|
String? contactId,
|
||||||
|
String? contactAvatarPath,
|
||||||
|
String? contactDisplayName,
|
||||||
) async {
|
) async {
|
||||||
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||||
final conversation = Conversation(
|
final conversation = Conversation(
|
||||||
@@ -265,11 +370,14 @@ class DatabaseService {
|
|||||||
<SharedMedium>[],
|
<SharedMedium>[],
|
||||||
-1,
|
-1,
|
||||||
open,
|
open,
|
||||||
rosterItem != null,
|
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||||
rosterItem?.subscription ?? 'none',
|
rosterItem?.subscription ?? 'none',
|
||||||
muted,
|
muted,
|
||||||
encrypted,
|
encrypted,
|
||||||
ChatState.gone,
|
ChatState.gone,
|
||||||
|
contactId: contactId,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
);
|
);
|
||||||
|
|
||||||
return conversation.copyWith(
|
return conversation.copyWith(
|
||||||
@@ -312,6 +420,7 @@ class DatabaseService {
|
|||||||
String sid,
|
String sid,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
|
bool containsNoStore,
|
||||||
{
|
{
|
||||||
String? srcUrl,
|
String? srcUrl,
|
||||||
String? key,
|
String? key,
|
||||||
@@ -332,6 +441,10 @@ class DatabaseService {
|
|||||||
bool isDownloading = false,
|
bool isDownloading = false,
|
||||||
bool isUploading = false,
|
bool isUploading = false,
|
||||||
int? mediaSize,
|
int? mediaSize,
|
||||||
|
String? stickerPackId,
|
||||||
|
String? stickerHashKey,
|
||||||
|
int? pseudoMessageType,
|
||||||
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
}
|
}
|
||||||
) async {
|
) async {
|
||||||
var m = Message(
|
var m = Message(
|
||||||
@@ -344,6 +457,7 @@ class DatabaseService {
|
|||||||
isMedia,
|
isMedia,
|
||||||
isFileUploadNotification,
|
isFileUploadNotification,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
containsNoStore,
|
||||||
errorType: errorType,
|
errorType: errorType,
|
||||||
warningType: warningType,
|
warningType: warningType,
|
||||||
mediaUrl: mediaUrl,
|
mediaUrl: mediaUrl,
|
||||||
@@ -365,6 +479,10 @@ class DatabaseService {
|
|||||||
isUploading: isUploading,
|
isUploading: isUploading,
|
||||||
isDownloading: isDownloading,
|
isDownloading: isDownloading,
|
||||||
mediaSize: mediaSize,
|
mediaSize: mediaSize,
|
||||||
|
stickerPackId: stickerPackId,
|
||||||
|
stickerHashKey: stickerHashKey,
|
||||||
|
pseudoMessageType: pseudoMessageType,
|
||||||
|
pseudoMessageData: pseudoMessageData,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (quoteId != null) {
|
if (quoteId != null) {
|
||||||
@@ -451,6 +569,8 @@ class DatabaseService {
|
|||||||
Object? sid = notSpecified,
|
Object? sid = notSpecified,
|
||||||
bool? isRetracted,
|
bool? isRetracted,
|
||||||
Object? thumbnailData = notSpecified,
|
Object? thumbnailData = notSpecified,
|
||||||
|
bool? isEdited,
|
||||||
|
Object? reactions = notSpecified,
|
||||||
}) async {
|
}) async {
|
||||||
final md = (await _db.query(
|
final md = (await _db.query(
|
||||||
'Messages',
|
'Messages',
|
||||||
@@ -460,6 +580,9 @@ class DatabaseService {
|
|||||||
)).first;
|
)).first;
|
||||||
final m = Map<String, dynamic>.from(md);
|
final m = Map<String, dynamic>.from(md);
|
||||||
|
|
||||||
|
if (body != notSpecified) {
|
||||||
|
m['body'] = body as String?;
|
||||||
|
}
|
||||||
if (mediaUrl != notSpecified) {
|
if (mediaUrl != notSpecified) {
|
||||||
m['mediaUrl'] = mediaUrl as String?;
|
m['mediaUrl'] = mediaUrl as String?;
|
||||||
}
|
}
|
||||||
@@ -526,6 +649,17 @@ class DatabaseService {
|
|||||||
if (thumbnailData != notSpecified) {
|
if (thumbnailData != notSpecified) {
|
||||||
m['thumbnailData'] = thumbnailData as String?;
|
m['thumbnailData'] = thumbnailData as String?;
|
||||||
}
|
}
|
||||||
|
if (isEdited != null) {
|
||||||
|
m['isEdited'] = boolToInt(isEdited);
|
||||||
|
}
|
||||||
|
if (reactions != notSpecified) {
|
||||||
|
assert(reactions != null, 'Cannot set reactions to null');
|
||||||
|
m['reactions'] = jsonEncode(
|
||||||
|
(reactions! as List<Reaction>)
|
||||||
|
.map((r) => r.toJson())
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await _db.update(
|
await _db.update(
|
||||||
'Messages',
|
'Messages',
|
||||||
@@ -566,6 +700,10 @@ class DatabaseService {
|
|||||||
String title,
|
String title,
|
||||||
String subscription,
|
String subscription,
|
||||||
String ask,
|
String ask,
|
||||||
|
bool pseudoRosterItem,
|
||||||
|
String? contactId,
|
||||||
|
String? contactAvatarPath,
|
||||||
|
String? contactDisplayName,
|
||||||
{
|
{
|
||||||
List<String> groups = const [],
|
List<String> groups = const [],
|
||||||
}
|
}
|
||||||
@@ -579,7 +717,11 @@ class DatabaseService {
|
|||||||
title,
|
title,
|
||||||
subscription,
|
subscription,
|
||||||
ask,
|
ask,
|
||||||
|
pseudoRosterItem,
|
||||||
<String>[],
|
<String>[],
|
||||||
|
contactId: contactId,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
);
|
);
|
||||||
|
|
||||||
return i.copyWith(
|
return i.copyWith(
|
||||||
@@ -595,11 +737,15 @@ class DatabaseService {
|
|||||||
String? title,
|
String? title,
|
||||||
String? subscription,
|
String? subscription,
|
||||||
String? ask,
|
String? ask,
|
||||||
|
Object pseudoRosterItem = notSpecified,
|
||||||
List<String>? groups,
|
List<String>? groups,
|
||||||
|
Object? contactId = notSpecified,
|
||||||
|
Object? contactAvatarPath = notSpecified,
|
||||||
|
Object? contactDisplayName = notSpecified,
|
||||||
}
|
}
|
||||||
) async {
|
) async {
|
||||||
final id_ = (await _db.query(
|
final id_ = (await _db.query(
|
||||||
'RosterItems',
|
rosterTable,
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
limit: 1,
|
limit: 1,
|
||||||
@@ -626,9 +772,21 @@ class DatabaseService {
|
|||||||
if (ask != null) {
|
if (ask != null) {
|
||||||
i['ask'] = ask;
|
i['ask'] = ask;
|
||||||
}
|
}
|
||||||
|
if (contactId != notSpecified) {
|
||||||
|
i['contactId'] = contactId as String?;
|
||||||
|
}
|
||||||
|
if (contactAvatarPath != notSpecified) {
|
||||||
|
i['contactAvatarPath'] = contactAvatarPath as String?;
|
||||||
|
}
|
||||||
|
if (contactDisplayName != notSpecified) {
|
||||||
|
i['contactDisplayName'] = contactDisplayName as String?;
|
||||||
|
}
|
||||||
|
if (pseudoRosterItem != notSpecified) {
|
||||||
|
i['pseudoRosterItem'] = boolToInt(pseudoRosterItem as bool);
|
||||||
|
}
|
||||||
|
|
||||||
await _db.update(
|
await _db.update(
|
||||||
'RosterItems',
|
rosterTable,
|
||||||
i,
|
i,
|
||||||
where: 'id = ?',
|
where: 'id = ?',
|
||||||
whereArgs: [id],
|
whereArgs: [id],
|
||||||
@@ -881,7 +1039,7 @@ class DatabaseService {
|
|||||||
await batch.commit();
|
await batch.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> saveOmemoDevice(Device device) async {
|
Future<void> saveOmemoDevice(OmemoDevice device) async {
|
||||||
await _db.insert(
|
await _db.insert(
|
||||||
omemoDeviceTable,
|
omemoDeviceTable,
|
||||||
{
|
{
|
||||||
@@ -893,7 +1051,7 @@ class DatabaseService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Device?> loadOmemoDevice(String jid) async {
|
Future<OmemoDevice?> loadOmemoDevice(String jid) async {
|
||||||
final data = await _db.query(
|
final data = await _db.query(
|
||||||
omemoDeviceTable,
|
omemoDeviceTable,
|
||||||
where: 'jid = ?',
|
where: 'jid = ?',
|
||||||
@@ -916,7 +1074,7 @@ class DatabaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
deviceJson['opks'] = opks;
|
deviceJson['opks'] = opks;
|
||||||
return Device.fromJson(deviceJson);
|
return OmemoDevice.fromJson(deviceJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, List<int>>> loadOmemoDeviceList() async {
|
Future<Map<String, List<int>>> loadOmemoDeviceList() async {
|
||||||
@@ -968,4 +1126,190 @@ class DatabaseService {
|
|||||||
|
|
||||||
await batch.commit();
|
await batch.commit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> addFingerprintsToCache(List<OmemoCacheTriple> items) async {
|
||||||
|
final batch = _db.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 _db.query(
|
||||||
|
omemoFingerprintCache,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rawItems
|
||||||
|
.map((item) {
|
||||||
|
return OmemoCacheTriple(
|
||||||
|
jid,
|
||||||
|
item['id']! as int,
|
||||||
|
item['fingerprint']! as String,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> getContactIds() async {
|
||||||
|
return Map<String, String>.fromEntries(
|
||||||
|
(await _db.query(contactsTable))
|
||||||
|
.map((item) => MapEntry(
|
||||||
|
item['jid']! as String,
|
||||||
|
item['id']! as String,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addContactId(String id, String jid) async {
|
||||||
|
await _db.insert(
|
||||||
|
contactsTable,
|
||||||
|
<String, String>{
|
||||||
|
'id': id,
|
||||||
|
'jid': jid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeContactId(String id) async {
|
||||||
|
await _db.delete(
|
||||||
|
contactsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addStickerPackFromData(sticker_pack.StickerPack pack) async {
|
||||||
|
await _db.insert(
|
||||||
|
stickerPacksTable,
|
||||||
|
pack.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<sticker.Sticker> addStickerFromData(
|
||||||
|
String mediaType,
|
||||||
|
String desc,
|
||||||
|
int size,
|
||||||
|
int? width,
|
||||||
|
int? height,
|
||||||
|
Map<String, String> hashes,
|
||||||
|
List<String> urlSources,
|
||||||
|
String path,
|
||||||
|
String stickerPackId,
|
||||||
|
Map<String, String> suggests,
|
||||||
|
) async {
|
||||||
|
final s = sticker.Sticker(
|
||||||
|
getStickerHashKey(hashes),
|
||||||
|
mediaType,
|
||||||
|
desc,
|
||||||
|
size,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
hashes,
|
||||||
|
urlSources,
|
||||||
|
path,
|
||||||
|
stickerPackId,
|
||||||
|
suggests,
|
||||||
|
);
|
||||||
|
|
||||||
|
await _db.insert(stickersTable, s.toDatabaseJson());
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<sticker_pack.StickerPack>> loadStickerPacks() async {
|
||||||
|
final rawPacks = await _db.query(stickerPacksTable);
|
||||||
|
final stickerPacks = List<sticker_pack.StickerPack>.empty(growable: true);
|
||||||
|
for (final pack in rawPacks) {
|
||||||
|
final rawStickers = await _db.query(
|
||||||
|
stickersTable,
|
||||||
|
where: 'stickerPackId = ?',
|
||||||
|
whereArgs: [pack['id']! as String],
|
||||||
|
);
|
||||||
|
|
||||||
|
stickerPacks.add(
|
||||||
|
sticker_pack.StickerPack.fromDatabaseJson(
|
||||||
|
pack,
|
||||||
|
rawStickers
|
||||||
|
.map(sticker.Sticker.fromDatabaseJson)
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return stickerPacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeStickerPackById(String id) async {
|
||||||
|
await _db.delete(
|
||||||
|
stickerPacksTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<sticker_pack.StickerPack?> getStickerPackById(String id) async {
|
||||||
|
final rawPack = await _db.query(
|
||||||
|
stickerPacksTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rawPack.isEmpty) return null;
|
||||||
|
|
||||||
|
final rawStickers = await _db.query(
|
||||||
|
stickersTable,
|
||||||
|
where: 'stickerPackId = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return sticker_pack.StickerPack.fromDatabaseJson(
|
||||||
|
rawPack.first,
|
||||||
|
rawStickers
|
||||||
|
.map(sticker.Sticker.fromDatabaseJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addBlocklistEntry(String jid) async {
|
||||||
|
await _db.insert(
|
||||||
|
blocklistTable,
|
||||||
|
{
|
||||||
|
'jid': jid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeBlocklistEntry(String jid) async {
|
||||||
|
await _db.delete(
|
||||||
|
blocklistTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeAllBlocklistEntries() async {
|
||||||
|
await _db.delete(
|
||||||
|
blocklistTable,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> getBlocklistEntries() async {
|
||||||
|
final result = await _db.query(blocklistTable);
|
||||||
|
|
||||||
|
return result
|
||||||
|
.map((m) => m['jid']! as String)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
lib/service/database/migrations/0000_blocklist.dart
Normal file
13
lib/service/database/migrations/0000_blocklist.dart
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV22ToV23(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $blocklistTable (
|
||||||
|
jid TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV13ToV14(Database db) async {
|
||||||
|
// Create the new table
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $contactsTable (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
jid TEXT NOT NULL
|
||||||
|
)'''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migrate the conversations
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE ${conversationsTable}_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
avatarUrl 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,
|
||||||
|
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
|
||||||
|
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute('INSERT INTO ${conversationsTable}_new SELECT *, NULL from $conversationsTable');
|
||||||
|
await db.execute('DROP TABLE $conversationsTable;');
|
||||||
|
await db.execute('ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;');
|
||||||
|
|
||||||
|
// Migrate the roster items
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE ${rosterTable}_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
avatarUrl TEXT NOT NULL,
|
||||||
|
avatarHash TEXT NOT NULL,
|
||||||
|
subscription TEXT NOT NULL,
|
||||||
|
ask TEXT NOT NULL,
|
||||||
|
contactId TEXT,
|
||||||
|
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||||
|
ON DELETE SET NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute('INSERT INTO ${rosterTable}_new SELECT *, NULL from $rosterTable');
|
||||||
|
await db.execute('DROP TABLE $rosterTable;');
|
||||||
|
await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable;');
|
||||||
|
|
||||||
|
// Introduce the new preference key
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'enableContactIntegration',
|
||||||
|
typeBool,
|
||||||
|
'false',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV14ToV15(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $conversationsTable ADD COLUMN contactAvatarPath TEXT DEFAULT NULL;',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $rosterTable ADD COLUMN contactAvatarPath TEXT DEFAULT NULL;',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $conversationsTable ADD COLUMN contactDisplayName TEXT DEFAULT NULL;',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $rosterTable ADD COLUMN contactDisplayName TEXT DEFAULT NULL;',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV15ToV16(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $rosterTable ADD COLUMN pseudoRosterItem INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,5 +9,4 @@ Future<void> upgradeFromV6ToV7(Database db) async {
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
"ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';"
|
"ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';"
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
lib/service/database/migrations/0000_lmc.dart
Normal file
11
lib/service/database/migrations/0000_lmc.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV9ToV10(Database db) async {
|
||||||
|
// Mark all messages as not edited
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN isEdited INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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 (
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (jid, id)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
}
|
||||||
12
lib/service/database/migrations/0000_pseudo_messages.dart
Normal file
12
lib/service/database/migrations/0000_pseudo_messages.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV23ToV24(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageType INTEGER;',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN pseudoMessageData TEXT;',
|
||||||
|
);
|
||||||
|
}
|
||||||
8
lib/service/database/migrations/0000_reactions.dart
Normal file
8
lib/service/database/migrations/0000_reactions.dart
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV10ToV11(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE $messagesTable ADD COLUMN reactions TEXT NOT NULL DEFAULT '[]';"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV11ToV12(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN containsNoStore INTEGER NOT NULL DEFAULT ${boolToInt(false)};'
|
||||||
|
);
|
||||||
|
}
|
||||||
59
lib/service/database/migrations/0000_stickers.dart
Normal file
59
lib/service/database/migrations/0000_stickers.dart
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV16ToV17(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $stickersTable (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
mediaType TEXT NOT NULL,
|
||||||
|
desc TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
hashes TEXT NOT NULL,
|
||||||
|
urlSources TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
stickerPackId TEXT NOT NULL,
|
||||||
|
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $stickerPacksTable (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
hashAlgorithm TEXT NOT NULL,
|
||||||
|
hashValue TEXT NOT NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the sticker attributes to Messages
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN stickerPackId TEXT;',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN stickerId INTEGER;',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the new preferences
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'enableStickers',
|
||||||
|
typeBool,
|
||||||
|
'true',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'autoDownloadStickersFromContacts',
|
||||||
|
typeBool,
|
||||||
|
'true',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
49
lib/service/database/migrations/0000_stickers_hash_key.dart
Normal file
49
lib/service/database/migrations/0000_stickers_hash_key.dart
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV17ToV18(Database db) async {
|
||||||
|
// Update messages
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable DROP COLUMN stickerId;',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN stickerHashKey TEXT;',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop stickers
|
||||||
|
await db.execute(
|
||||||
|
'DROP TABLE $stickerPacksTable;'
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'DROP TABLE $stickersTable;'
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $stickersTable (
|
||||||
|
hashKey TEXT PRIMARY KEY,
|
||||||
|
mediaType TEXT NOT NULL,
|
||||||
|
desc TEXT NOT NULL,
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
hashes TEXT NOT NULL,
|
||||||
|
urlSources TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
stickerPackId TEXT NOT NULL,
|
||||||
|
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $stickerPacksTable (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL,
|
||||||
|
hashAlgorithm TEXT NOT NULL,
|
||||||
|
hashValue TEXT NOT NULL,
|
||||||
|
stickerHashKey TEXT NOT NULL
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV18ToV19(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickerPacksTable DROP COLUMN stickerHashKey;',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV19ToV20(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickerPacksTable ADD COLUMN restricted DEFAULT ${boolToInt(false)};',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickersTable ADD COLUMN suggests DEFAULT "";',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV20ToV21(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickerPacksTable DROP COLUMN restricted;',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickersTable DROP COLUMN suggests;',
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickerPacksTable ADD COLUMN restricted INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickersTable ADD COLUMN suggests TEXT NOT NULL DEFAULT "";',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV21ToV22(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickersTable DROP COLUMN suggests;',
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $stickersTable ADD COLUMN suggests TEXT NOT NULL DEFAULT "{}";',
|
||||||
|
);
|
||||||
|
}
|
||||||
14
lib/service/database/migrations/0000_stickers_privacy.dart
Normal file
14
lib/service/database/migrations/0000_stickers_privacy.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV24ToV25(Database db) async {
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'isStickersNodePublic',
|
||||||
|
typeBool,
|
||||||
|
'true',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV25ToV26(Database db) async {
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'showDebugMenu',
|
||||||
|
typeBool,
|
||||||
|
boolToString(false),
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import 'package:moxxmpp/moxxmpp.dart';
|
|||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/service/avatars.dart';
|
import 'package:moxxyv2/service/avatars.dart';
|
||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/helpers.dart';
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
@@ -23,12 +24,16 @@ import 'package:moxxyv2/service/preferences.dart';
|
|||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/state.dart';
|
import 'package:moxxyv2/service/state.dart';
|
||||||
|
import 'package:moxxyv2/service/stickers.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
||||||
|
import 'package:moxxyv2/shared/models/sticker_pack.dart' as sticker_pack;
|
||||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
@@ -66,6 +71,15 @@ void setupBackgroundEventHandler() {
|
|||||||
EventTypeMatcher<RetractMessageCommentCommand>(performMessageRetraction),
|
EventTypeMatcher<RetractMessageCommentCommand>(performMessageRetraction),
|
||||||
EventTypeMatcher<MarkConversationAsReadCommand>(performMarkConversationAsRead),
|
EventTypeMatcher<MarkConversationAsReadCommand>(performMarkConversationAsRead),
|
||||||
EventTypeMatcher<MarkMessageAsReadCommand>(performMarkMessageAsRead),
|
EventTypeMatcher<MarkMessageAsReadCommand>(performMarkMessageAsRead),
|
||||||
|
EventTypeMatcher<AddReactionToMessageCommand>(performAddMessageReaction),
|
||||||
|
EventTypeMatcher<RemoveReactionFromMessageCommand>(performRemoveMessageReaction),
|
||||||
|
EventTypeMatcher<MarkOmemoDeviceAsVerifiedCommand>(performMarkDeviceVerified),
|
||||||
|
EventTypeMatcher<ImportStickerPackCommand>(performImportStickerPack),
|
||||||
|
EventTypeMatcher<SendStickerCommand>(performSendSticker),
|
||||||
|
EventTypeMatcher<RemoveStickerPackCommand>(performRemoveStickerPack),
|
||||||
|
EventTypeMatcher<FetchStickerPackCommand>(performFetchStickerPack),
|
||||||
|
EventTypeMatcher<InstallStickerPackCommand>(performStickerPackInstall),
|
||||||
|
EventTypeMatcher<GetBlocklistCommand>(performGetBlocklist),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@@ -140,6 +154,7 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
|
|||||||
preferences: preferences,
|
preferences: preferences,
|
||||||
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
|
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
|
||||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||||
|
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,6 +202,7 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
|||||||
final updatedConversation = await cs.updateConversation(
|
final updatedConversation = await cs.updateConversation(
|
||||||
conversation.id,
|
conversation.id,
|
||||||
open: true,
|
open: true,
|
||||||
|
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
@@ -204,17 +220,22 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
|
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
|
final contactId = await css.getContactIdForJid(command.jid);
|
||||||
final conversation = await cs.addConversationFromData(
|
final conversation = await cs.addConversationFromData(
|
||||||
command.title,
|
command.title,
|
||||||
null,
|
null,
|
||||||
command.avatarUrl,
|
command.avatarUrl,
|
||||||
command.jid,
|
command.jid,
|
||||||
0,
|
0,
|
||||||
-1,
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
true,
|
true,
|
||||||
// TODO(PapaTutuWawa): Take as an argument
|
preferences.defaultMuteState,
|
||||||
false,
|
preferences.enableOmemoByDefault,
|
||||||
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
|
contactId,
|
||||||
|
await css.getProfilePicturePathForJid(command.jid),
|
||||||
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
@@ -247,7 +268,23 @@ Future<void> performSetOpenConversation(SetOpenConversationCommand command, { dy
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async {
|
Future<void> performSendMessage(SendMessageCommand command, { dynamic extra }) async {
|
||||||
await GetIt.I.get<XmppService>().sendMessage(
|
final xs = GetIt.I.get<XmppService>();
|
||||||
|
if (command.editSid != null && command.editId != null) {
|
||||||
|
assert(command.recipients.length == 1, 'Edits must not be sent to multiple recipients');
|
||||||
|
|
||||||
|
await xs.sendMessageCorrection(
|
||||||
|
command.editId!,
|
||||||
|
command.body,
|
||||||
|
command.editSid!,
|
||||||
|
command.recipients.first,
|
||||||
|
command.chatState.isNotEmpty
|
||||||
|
? chatStateFromString(command.chatState)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await xs.sendMessage(
|
||||||
body: command.body,
|
body: command.body,
|
||||||
recipients: command.recipients,
|
recipients: command.recipients,
|
||||||
chatState: command.chatState.isNotEmpty
|
chatState: command.chatState.isNotEmpty
|
||||||
@@ -287,7 +324,9 @@ Future<void> performSetCSIState(SetCSIStateCommand command, { dynamic extra }) a
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async {
|
Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extra }) async {
|
||||||
await GetIt.I.get<PreferencesService>().modifyPreferences((_) => command.preferences);
|
final ps = GetIt.I.get<PreferencesService>();
|
||||||
|
final oldPrefs = await ps.getPreferences();
|
||||||
|
await ps.modifyPreferences((_) => command.preferences);
|
||||||
|
|
||||||
// Set the logging mode
|
// Set the logging mode
|
||||||
if (!kDebugMode) {
|
if (!kDebugMode) {
|
||||||
@@ -295,6 +334,52 @@ Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extr
|
|||||||
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
|
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scan all contacts if the setting is enabled or disable the database callback
|
||||||
|
// if it is disabled.
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
|
if (command.preferences.enableContactIntegration) {
|
||||||
|
if (!oldPrefs.enableContactIntegration) {
|
||||||
|
css.enableDatabaseListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(css.scanContacts());
|
||||||
|
} else {
|
||||||
|
if (oldPrefs.enableContactIntegration) {
|
||||||
|
css.disableDatabaseListener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(Unknown): Maybe handle this in StickersService
|
||||||
|
// If sticker visibility was changed, apply the settings to the PubSub node
|
||||||
|
final pm = GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<PubSubManager>(pubsubManager)!;
|
||||||
|
final ownJid = (await GetIt.I.get<XmppService>().getXmppState()).jid!;
|
||||||
|
if (command.preferences.isStickersNodePublic && !oldPrefs.isStickersNodePublic) {
|
||||||
|
// Set to open
|
||||||
|
unawaited(
|
||||||
|
pm.configure(
|
||||||
|
ownJid,
|
||||||
|
stickersXmlns,
|
||||||
|
const PubSubPublishOptions(
|
||||||
|
accessModel: 'open',
|
||||||
|
maxItems: 'max',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (!command.preferences.isStickersNodePublic && oldPrefs.isStickersNodePublic) {
|
||||||
|
// Set to presence
|
||||||
|
unawaited(
|
||||||
|
pm.configure(
|
||||||
|
ownJid,
|
||||||
|
stickersXmlns,
|
||||||
|
const PubSubPublishOptions(
|
||||||
|
accessModel: 'presence',
|
||||||
|
maxItems: 'max',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Set the locale
|
// Set the locale
|
||||||
final locale = command.preferences.languageLocaleCode == 'default' ?
|
final locale = command.preferences.languageLocaleCode == 'default' ?
|
||||||
GetIt.I.get<LanguageService>().defaultLocale :
|
GetIt.I.get<LanguageService>().defaultLocale :
|
||||||
@@ -321,6 +406,7 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
|||||||
final c = await cs.updateConversation(
|
final c = await cs.updateConversation(
|
||||||
conversation.id,
|
conversation.id,
|
||||||
open: true,
|
open: true,
|
||||||
|
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
@@ -328,17 +414,22 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
|||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
|
final contactId = await css.getContactIdForJid(jid);
|
||||||
|
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
final c = await cs.addConversationFromData(
|
final c = await cs.addConversationFromData(
|
||||||
jid.split('@')[0],
|
jid.split('@')[0],
|
||||||
null,
|
null,
|
||||||
'',
|
'',
|
||||||
jid,
|
jid,
|
||||||
0,
|
0,
|
||||||
-1,
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
true,
|
true,
|
||||||
// TODO(PapaTutuWawa): Take as an argument
|
prefs.defaultMuteState,
|
||||||
false,
|
prefs.enableOmemoByDefault,
|
||||||
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
|
contactId,
|
||||||
|
await css.getProfilePicturePathForJid(jid),
|
||||||
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
sendEvent(
|
sendEvent(
|
||||||
AddContactResultEvent(conversation: c, added: true),
|
AddContactResultEvent(conversation: c, added: true),
|
||||||
@@ -459,12 +550,14 @@ Future<void> performGetFeatures(GetFeaturesCommand command, { dynamic extra }) a
|
|||||||
final csi = conn.getNegotiatorById<CSINegotiator>(csiNegotiator)!;
|
final csi = conn.getNegotiatorById<CSINegotiator>(csiNegotiator)!;
|
||||||
final httpFileUpload = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
final httpFileUpload = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
||||||
final userBlocking = conn.getManagerById<BlockingManager>(blockingManager)!;
|
final userBlocking = conn.getManagerById<BlockingManager>(blockingManager)!;
|
||||||
|
final carbons = conn.getManagerById<CarbonsManager>(carbonsManager)!;
|
||||||
sendEvent(
|
sendEvent(
|
||||||
GetFeaturesEvent(
|
GetFeaturesEvent(
|
||||||
supportsStreamManagement: sm.isSupported,
|
supportsStreamManagement: sm.isSupported,
|
||||||
supportsCsi: csi.isSupported,
|
supportsCsi: csi.isSupported,
|
||||||
supportsHttpFileUpload: await httpFileUpload.isSupported(),
|
supportsHttpFileUpload: await httpFileUpload.isSupported(),
|
||||||
supportsUserBlocking: await userBlocking.isSupported(),
|
supportsUserBlocking: await userBlocking.isSupported(),
|
||||||
|
supportsCarbons: await carbons.isSupported(),
|
||||||
),
|
),
|
||||||
id: id,
|
id: id,
|
||||||
);
|
);
|
||||||
@@ -527,9 +620,8 @@ Future<void> performRecreateSessions(RecreateSessionsCommand command, { dynamic
|
|||||||
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid);
|
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid);
|
||||||
|
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
await conn.getManagerById<OmemoManager>(omemoManager)!.sendEmptyMessage(
|
await conn.getManagerById<BaseOmemoManager>(omemoManager)!.sendOmemoHeartbeat(
|
||||||
JID.fromString(command.jid),
|
command.jid,
|
||||||
findNewSessions: true,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +653,7 @@ Future<void> performGetOwnOmemoFingerprints(GetOwnOmemoFingerprintsCommand comma
|
|||||||
|
|
||||||
Future<void> performRemoveOwnDevice(RemoveOwnDeviceCommand command, { dynamic extra }) async {
|
Future<void> performRemoveOwnDevice(RemoveOwnDeviceCommand command, { dynamic extra }) async {
|
||||||
await GetIt.I.get<XmppConnection>()
|
await GetIt.I.get<XmppConnection>()
|
||||||
.getManagerById<OmemoManager>(omemoManager)!
|
.getManagerById<BaseOmemoManager>(omemoManager)!
|
||||||
.deleteDevice(command.deviceId);
|
.deleteDevice(command.deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,3 +728,208 @@ Future<void> performMarkMessageAsRead(MarkMessageAsReadCommand command, { dynami
|
|||||||
command.sid,
|
command.sid,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> performAddMessageReaction(AddReactionToMessageCommand command, { dynamic extra }) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
final msg = await ms.getMessageById(command.conversationJid, command.messageId);
|
||||||
|
assert(msg != null, 'The message must be found');
|
||||||
|
|
||||||
|
// Update the state
|
||||||
|
final reactions = List<Reaction>.from(msg!.reactions);
|
||||||
|
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
|
||||||
|
if (i == -1) {
|
||||||
|
reactions.add(Reaction([], command.emoji, true));
|
||||||
|
} else {
|
||||||
|
reactions[i] = reactions[i].copyWith(reactedBySelf: true);
|
||||||
|
}
|
||||||
|
await ms.updateMessage(msg.id, reactions: reactions);
|
||||||
|
|
||||||
|
// Collect all our reactions
|
||||||
|
final ownReactions = reactions
|
||||||
|
.where((r) => r.reactedBySelf)
|
||||||
|
.map((r) => r.emoji)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Send the reaction
|
||||||
|
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||||
|
MessageDetails(
|
||||||
|
to: command.conversationJid,
|
||||||
|
messageReactions: MessageReactions(
|
||||||
|
msg.originId ?? msg.sid,
|
||||||
|
ownReactions,
|
||||||
|
),
|
||||||
|
requestChatMarkers: false,
|
||||||
|
messageProcessingHints: !msg.containsNoStore ?
|
||||||
|
[MessageProcessingHint.store] :
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performRemoveMessageReaction(RemoveReactionFromMessageCommand command, { dynamic extra }) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
final msg = await ms.getMessageById(command.conversationJid, command.messageId);
|
||||||
|
assert(msg != null, 'The message must be found');
|
||||||
|
|
||||||
|
// Update the state
|
||||||
|
final reactions = List<Reaction>.from(msg!.reactions);
|
||||||
|
final i = reactions.indexWhere((r) => r.emoji == command.emoji);
|
||||||
|
assert(i >= -1, 'The reaction must be found');
|
||||||
|
if (reactions[i].senders.isEmpty) {
|
||||||
|
reactions.removeAt(i);
|
||||||
|
} else {
|
||||||
|
reactions[i] = reactions[i].copyWith(reactedBySelf: false);
|
||||||
|
}
|
||||||
|
await ms.updateMessage(msg.id, reactions: reactions);
|
||||||
|
|
||||||
|
// Collect all our reactions
|
||||||
|
final ownReactions = reactions
|
||||||
|
.where((r) => r.reactedBySelf)
|
||||||
|
.map((r) => r.emoji)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Send the reaction
|
||||||
|
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||||
|
MessageDetails(
|
||||||
|
to: command.conversationJid,
|
||||||
|
messageReactions: MessageReactions(
|
||||||
|
msg.originId ?? msg.sid,
|
||||||
|
ownReactions,
|
||||||
|
),
|
||||||
|
requestChatMarkers: false,
|
||||||
|
messageProcessingHints: !msg.containsNoStore ?
|
||||||
|
[MessageProcessingHint.store] :
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performMarkDeviceVerified(MarkOmemoDeviceAsVerifiedCommand command, { dynamic extra }) async {
|
||||||
|
await GetIt.I.get<OmemoService>().verifyDevice(
|
||||||
|
command.deviceId,
|
||||||
|
command.jid,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performImportStickerPack(ImportStickerPackCommand command, { dynamic extra }) async {
|
||||||
|
final id = extra as String;
|
||||||
|
final result = await GetIt.I.get<StickersService>().importFromFile(command.path);
|
||||||
|
if (result != null) {
|
||||||
|
sendEvent(
|
||||||
|
StickerPackImportSuccessEvent(
|
||||||
|
stickerPack: result,
|
||||||
|
),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sendEvent(
|
||||||
|
StickerPackImportFailureEvent(),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performSendSticker(SendStickerCommand command, { dynamic extra }) async {
|
||||||
|
final xs = GetIt.I.get<XmppService>();
|
||||||
|
final ss = GetIt.I.get<StickersService>();
|
||||||
|
|
||||||
|
final sticker = await ss.getStickerByHashKey(
|
||||||
|
command.stickerPackId,
|
||||||
|
command.stickerHashKey,
|
||||||
|
);
|
||||||
|
assert(sticker != null, 'Sticker not found');
|
||||||
|
|
||||||
|
await xs.sendMessage(
|
||||||
|
body: sticker!.desc,
|
||||||
|
recipients: [command.recipient],
|
||||||
|
sticker: sticker,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performRemoveStickerPack(RemoveStickerPackCommand command, { dynamic extra }) async {
|
||||||
|
await GetIt.I.get<StickersService>().removeStickerPack(
|
||||||
|
command.stickerPackId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performFetchStickerPack(FetchStickerPackCommand command, { dynamic extra }) async {
|
||||||
|
final id = extra as String;
|
||||||
|
|
||||||
|
final result = await GetIt.I.get<XmppConnection>()
|
||||||
|
.getManagerById<StickersManager>(stickersManager)!
|
||||||
|
.fetchStickerPack(JID.fromString(command.jid), command.stickerPackId);
|
||||||
|
|
||||||
|
if (result.isType<PubSubError>()) {
|
||||||
|
sendEvent(
|
||||||
|
FetchStickerPackFailureResult(),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final stickerPack = result.get<StickerPack>();
|
||||||
|
sendEvent(
|
||||||
|
FetchStickerPackSuccessResult(
|
||||||
|
stickerPack: sticker_pack.StickerPack(
|
||||||
|
command.stickerPackId,
|
||||||
|
stickerPack.name,
|
||||||
|
stickerPack.summary,
|
||||||
|
stickerPack.stickers
|
||||||
|
.map((s) => sticker.Sticker(
|
||||||
|
'',
|
||||||
|
s.metadata.mediaType!,
|
||||||
|
s.metadata.desc!,
|
||||||
|
s.metadata.size!,
|
||||||
|
s.metadata.width,
|
||||||
|
s.metadata.height,
|
||||||
|
s.metadata.hashes,
|
||||||
|
s.sources
|
||||||
|
.whereType<StatelessFileSharingUrlSource>()
|
||||||
|
.map((src) => src.url)
|
||||||
|
.toList(),
|
||||||
|
'',
|
||||||
|
command.stickerPackId,
|
||||||
|
s.suggests,
|
||||||
|
),).toList(),
|
||||||
|
stickerPack.hashAlgorithm.toName(),
|
||||||
|
stickerPack.hashValue,
|
||||||
|
stickerPack.restricted,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performStickerPackInstall(InstallStickerPackCommand command, { dynamic extra }) async {
|
||||||
|
final id = extra as String;
|
||||||
|
|
||||||
|
final ss = GetIt.I.get<StickersService>();
|
||||||
|
final pack = await ss.installFromPubSub(command.stickerPack);
|
||||||
|
if (pack != null) {
|
||||||
|
sendEvent(
|
||||||
|
StickerPackInstallSuccessEvent(
|
||||||
|
stickerPack: pack,
|
||||||
|
),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sendEvent(
|
||||||
|
StickerPackInstallFailureEvent(),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> performGetBlocklist(GetBlocklistCommand command, { dynamic extra }) async {
|
||||||
|
final id = extra as String;
|
||||||
|
|
||||||
|
final result = await GetIt.I.get<BlocklistService>().getBlocklist();
|
||||||
|
sendEvent(
|
||||||
|
GetBlocklistResultEvent(
|
||||||
|
entries: result,
|
||||||
|
),
|
||||||
|
id: id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,3 +73,28 @@ String xmppErrorToTranslatableString(XmppError error) {
|
|||||||
|
|
||||||
return t.errors.login.unspecified;
|
return t.errors.login.unspecified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getStickerHashKeyType(Map<String, String> hashes) {
|
||||||
|
if (hashes.containsKey('blake2b-512')) {
|
||||||
|
return 'blake2b-512';
|
||||||
|
} else if (hashes.containsKey('blake2b-512')) {
|
||||||
|
return 'blake2b-256';
|
||||||
|
} else if (hashes.containsKey('sha3-512')) {
|
||||||
|
return 'sha3-512';
|
||||||
|
} else if (hashes.containsKey('sha3-256')) {
|
||||||
|
return 'sha3-256';
|
||||||
|
} else if (hashes.containsKey('sha3-256')) {
|
||||||
|
return 'sha-512';
|
||||||
|
} else if (hashes.containsKey('sha-256')) {
|
||||||
|
return 'sha-256';
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(false, 'No valid hash found');
|
||||||
|
return '';
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
String getStickerHashKey(Map<String, String> hashes) {
|
||||||
|
final key = getStickerHashKeyType(hashes);
|
||||||
|
return '$key:${hashes[key]}';
|
||||||
|
}
|
||||||
|
|||||||
137
lib/service/httpfiletransfer/client.dart
Normal file
137
lib/service/httpfiletransfer/client.dart
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
|
|
||||||
|
typedef ProgressCallback = void Function(int total, int current);
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class HttpPeekResult {
|
||||||
|
const HttpPeekResult(this.contentType, this.contentLength);
|
||||||
|
final String? contentType;
|
||||||
|
final int? contentLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download the file found at [uri] into the file [destination]. [onProgress] is
|
||||||
|
/// called whenever new data has been downloaded.
|
||||||
|
///
|
||||||
|
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||||
|
Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgress) async {
|
||||||
|
// TODO(Unknown): How do we close fileSink? Do we have to?
|
||||||
|
IOSink? fileSink;
|
||||||
|
final client = HttpClient();
|
||||||
|
try {
|
||||||
|
final req = await client.getUrl(uri);
|
||||||
|
final resp = await req.close();
|
||||||
|
|
||||||
|
if (!isRequestOkay(resp.statusCode)) {
|
||||||
|
client.close(force: true);
|
||||||
|
return resp.statusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The size of the remote file
|
||||||
|
final length = resp.contentLength;
|
||||||
|
|
||||||
|
fileSink = File(destination).openWrite(mode: FileMode.append);
|
||||||
|
var bytes = 0;
|
||||||
|
final downloadCompleter = Completer<void>();
|
||||||
|
unawaited(
|
||||||
|
resp.transform(
|
||||||
|
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||||
|
handleData: (data, sink) {
|
||||||
|
bytes += data.length;
|
||||||
|
onProgress(length, bytes);
|
||||||
|
|
||||||
|
sink.add(data);
|
||||||
|
},
|
||||||
|
handleDone: (sink) {
|
||||||
|
downloadCompleter.complete();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).pipe(fileSink),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for the download to complete
|
||||||
|
await downloadCompleter.future;
|
||||||
|
client.close(force: true);
|
||||||
|
//await fileSink.close();
|
||||||
|
|
||||||
|
return resp.statusCode;
|
||||||
|
} catch (ex) {
|
||||||
|
client.close(force: true);
|
||||||
|
//await fileSink?.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload the file found at [filePath] to [destination]. [headers] are HTTP headers
|
||||||
|
/// that are added to the PUT request. [onProgress] is called whenever new data has
|
||||||
|
/// been downloaded.
|
||||||
|
///
|
||||||
|
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||||
|
Future<int?> uploadFile(Uri destination, Map<String, String> headers, String filePath, ProgressCallback onProgress) async {
|
||||||
|
final client = HttpClient();
|
||||||
|
try {
|
||||||
|
final req = await client.putUrl(destination);
|
||||||
|
final file = File(filePath);
|
||||||
|
final length = await file.length();
|
||||||
|
req.contentLength = length;
|
||||||
|
|
||||||
|
// Set all known headers
|
||||||
|
headers.forEach((headerName, headerValue) {
|
||||||
|
req.headers.set(headerName, headerValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
var bytes = 0;
|
||||||
|
final stream = file.openRead().transform(
|
||||||
|
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||||
|
handleData: (data, sink) {
|
||||||
|
bytes += data.length;
|
||||||
|
onProgress(length, bytes);
|
||||||
|
|
||||||
|
sink.add(data);
|
||||||
|
},
|
||||||
|
handleDone: (sink) {
|
||||||
|
sink.close();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await req.addStream(stream);
|
||||||
|
final resp = await req.close();
|
||||||
|
|
||||||
|
return resp.statusCode;
|
||||||
|
} catch (ex) {
|
||||||
|
client.close(force: true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sends a HEAD request to [uri].
|
||||||
|
///
|
||||||
|
/// Returns the content type and content length if the server responded. If an error
|
||||||
|
/// occurs, returns null.
|
||||||
|
Future<HttpPeekResult?> peekUrl(Uri uri) async {
|
||||||
|
final client = HttpClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final req = await client.headUrl(uri);
|
||||||
|
final resp = await req.close();
|
||||||
|
|
||||||
|
if (!isRequestOkay(resp.statusCode)) {
|
||||||
|
client.close(force: true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.close(force: true);
|
||||||
|
final contentType = resp.headers['Content-Type'];
|
||||||
|
return HttpPeekResult(
|
||||||
|
contentType != null && contentType.isNotEmpty ?
|
||||||
|
contentType.first :
|
||||||
|
null,
|
||||||
|
resp.contentLength,
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
client.close(force: true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:external_path/external_path.dart';
|
import 'package:external_path/external_path.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
@@ -43,7 +43,6 @@ bool isRequestOkay(int? statusCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FileMetadata {
|
class FileMetadata {
|
||||||
|
|
||||||
const FileMetadata({ this.mime, this.size });
|
const FileMetadata({ this.mime, this.size });
|
||||||
final String? mime;
|
final String? mime;
|
||||||
final int? size;
|
final int? size;
|
||||||
@@ -53,15 +52,10 @@ class FileMetadata {
|
|||||||
/// does not specify the Content-Length header, null is returned.
|
/// does not specify the Content-Length header, null is returned.
|
||||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||||
Future<FileMetadata> peekFile(String url) async {
|
Future<FileMetadata> peekFile(String url) async {
|
||||||
final response = await Dio().headUri<dynamic>(Uri.parse(url));
|
final result = await peekUrl(Uri.parse(url));
|
||||||
|
|
||||||
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
|
|
||||||
|
|
||||||
final contentLengthHeaders = response.headers['Content-Length'];
|
|
||||||
final contentTypeHeaders = response.headers['Content-Type'];
|
|
||||||
|
|
||||||
return FileMetadata(
|
return FileMetadata(
|
||||||
mime: contentTypeHeaders?.first,
|
mime: result?.contentType,
|
||||||
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null,
|
size: result?.contentLength,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:dio/dio.dart' as dio;
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:image_size_getter/file_input.dart';
|
|
||||||
import 'package:image_size_getter/image_size_getter.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
@@ -17,6 +14,7 @@ import 'package:moxxyv2/service/conversation.dart';
|
|||||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
@@ -24,6 +22,7 @@ import 'package:moxxyv2/service/notifications.dart';
|
|||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/warning_types.dart';
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -103,19 +102,17 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
/// Queue the download job [job] to be performed.
|
/// Queue the download job [job] to be performed.
|
||||||
Future<void> downloadFile(FileDownloadJob job) async {
|
Future<void> downloadFile(FileDownloadJob job) async {
|
||||||
var canDownload = false;
|
|
||||||
await _uploadLock.synchronized(() async {
|
await _uploadLock.synchronized(() async {
|
||||||
if (_currentDownloadJob != null) {
|
if (_currentDownloadJob != null) {
|
||||||
|
_log.finest('Queuing up download task.');
|
||||||
_downloadQueue.add(job);
|
_downloadQueue.add(job);
|
||||||
} else {
|
} else {
|
||||||
|
_log.finest('Executing download task.');
|
||||||
_currentDownloadJob = job;
|
_currentDownloadJob = job;
|
||||||
canDownload = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (canDownload) {
|
|
||||||
unawaited(_performFileDownload(job));
|
unawaited(_performFileDownload(job));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _copyFile(FileUploadJob job) async {
|
Future<void> _copyFile(FileUploadJob job) async {
|
||||||
@@ -184,7 +181,6 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
final data = await file.readAsBytes();
|
|
||||||
final stat = file.statSync();
|
final stat = file.statSync();
|
||||||
|
|
||||||
// Request the upload slot
|
// Request the upload slot
|
||||||
@@ -201,20 +197,16 @@ class HttpFileTransferService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||||
try {
|
|
||||||
final response = await dio.Dio().putUri<dynamic>(
|
final uploadStatusCode = await client.uploadFile(
|
||||||
Uri.parse(slot.putUrl),
|
Uri.parse(slot.putUrl),
|
||||||
options: dio.Options(
|
slot.headers,
|
||||||
headers: slot.headers,
|
path,
|
||||||
contentType: 'application/octet-stream',
|
(total, current) {
|
||||||
requestEncoder: (_, __) => data,
|
|
||||||
),
|
|
||||||
data: data,
|
|
||||||
onSendProgress: (count, total) {
|
|
||||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||||
// is open.
|
// is open.
|
||||||
if (job.recipients.length == 1) {
|
if (job.recipients.length == 1) {
|
||||||
final progress = count.toDouble() / total.toDouble();
|
final progress = current.toDouble() / total.toDouble();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
ProgressEvent(
|
ProgressEvent(
|
||||||
id: job.messageMap.values.first.id,
|
id: job.messageMap.values.first.id,
|
||||||
@@ -226,8 +218,7 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
if (response.statusCode != 201) {
|
if (!isRequestOkay(uploadStatusCode)) {
|
||||||
// TODO(PapaTutuWawa): Trigger event
|
|
||||||
_log.severe('Upload failed');
|
_log.severe('Upload failed');
|
||||||
await _fileUploadFailed(job, fileUploadFailedError);
|
await _fileUploadFailed(job, fileUploadFailedError);
|
||||||
return;
|
return;
|
||||||
@@ -311,11 +302,6 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on dio.DioError {
|
|
||||||
_log.finest('Upload failed due to connection error');
|
|
||||||
await _fileUploadFailed(job, fileUploadFailedError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await _pickNextUploadTask();
|
await _pickNextUploadTask();
|
||||||
}
|
}
|
||||||
@@ -350,7 +336,6 @@ class HttpFileTransferService {
|
|||||||
/// Actually attempt to download the file described by the job [job].
|
/// Actually attempt to download the file described by the job [job].
|
||||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||||
final filename = job.location.filename;
|
final filename = job.location.filename;
|
||||||
_log.finest('Downloading ${job.location.url} as $filename');
|
|
||||||
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
||||||
|
|
||||||
var downloadPath = downloadedPath;
|
var downloadPath = downloadedPath;
|
||||||
@@ -360,13 +345,16 @@ class HttpFileTransferService {
|
|||||||
downloadPath = pathlib.join(tempDir.path, filename);
|
downloadPath = pathlib.join(tempDir.path, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
dio.Response<dynamic>? response;
|
_log.finest('Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)');
|
||||||
|
|
||||||
|
int? downloadStatusCode;
|
||||||
try {
|
try {
|
||||||
response = await dio.Dio().downloadUri(
|
_log.finest('Beginning download...');
|
||||||
|
downloadStatusCode = await client.downloadFile(
|
||||||
Uri.parse(job.location.url),
|
Uri.parse(job.location.url),
|
||||||
downloadPath,
|
downloadPath,
|
||||||
onReceiveProgress: (count, total) {
|
(total, current) {
|
||||||
final progress = count.toDouble() / total.toDouble();
|
final progress = current.toDouble() / total.toDouble();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
ProgressEvent(
|
ProgressEvent(
|
||||||
id: job.mId,
|
id: job.mId,
|
||||||
@@ -375,19 +363,17 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} on dio.DioError catch(err) {
|
_log.finest('Download done...');
|
||||||
// TODO(PapaTutuWawa): React if we received an error that is not related to the
|
} catch (err) {
|
||||||
// connection.
|
|
||||||
_log.finest('Failed to download: $err');
|
_log.finest('Failed to download: $err');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRequestOkay(downloadStatusCode)) {
|
||||||
|
_log.warning('HTTP GET of ${job.location.url} returned $downloadStatusCode');
|
||||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRequestOkay(response.statusCode)) {
|
|
||||||
_log.warning('HTTP GET of ${job.location.url} returned ${response.statusCode}');
|
|
||||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
var integrityCheckPassed = true;
|
var integrityCheckPassed = true;
|
||||||
final conv = (await GetIt.I.get<ConversationService>()
|
final conv = (await GetIt.I.get<ConversationService>()
|
||||||
.getConversationByJid(job.conversationJid))!;
|
.getConversationByJid(job.conversationJid))!;
|
||||||
@@ -438,19 +424,31 @@ class HttpFileTransferService {
|
|||||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||||
|
|
||||||
// Find out the dimensions
|
// Find out the dimensions
|
||||||
// TODO(Unknown): Restrict to the library's supported file types
|
final imageSize = await getImageSizeFromPath(downloadedPath);
|
||||||
Size? size;
|
if (imageSize == null) {
|
||||||
try {
|
_log.warning('Failed to get image size for $downloadedPath');
|
||||||
size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
|
|
||||||
} catch (ex) {
|
|
||||||
_log.warning('Failed to get image size for $downloadedPath: $ex');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mediaWidth = size?.width;
|
mediaWidth = imageSize?.width.toInt();
|
||||||
mediaHeight = size?.height;
|
mediaHeight = imageSize?.height.toInt();
|
||||||
} else if (mime.startsWith('video/')) {
|
} else if (mime.startsWith('video/')) {
|
||||||
// TODO(Unknown): Also figure out the thumbnail size here
|
|
||||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Generate thumbnail
|
||||||
|
final thumbnailPath = await getVideoThumbnailPath(
|
||||||
|
downloadedPath,
|
||||||
|
job.conversationJid,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find out the dimensions
|
||||||
|
final imageSize = await getImageSizeFromPath(thumbnailPath);
|
||||||
|
if (imageSize == null) {
|
||||||
|
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaWidth = imageSize?.width.toInt();
|
||||||
|
mediaHeight = imageSize?.height.toInt();*/
|
||||||
} else if (mime.startsWith('audio/')) {
|
} else if (mime.startsWith('audio/')) {
|
||||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||||
}
|
}
|
||||||
@@ -500,19 +498,20 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||||
}
|
|
||||||
|
|
||||||
// Free the download resources for the next one
|
// Free the download resources for the next one
|
||||||
await _pickNextDownloadTask();
|
await _pickNextDownloadTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickNextDownloadTask() async {
|
Future<void> _pickNextDownloadTask() async {
|
||||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
|
||||||
|
|
||||||
await _downloadLock.synchronized(() async {
|
await _downloadLock.synchronized(() async {
|
||||||
if (_downloadQueue.isNotEmpty) {
|
if (_downloadQueue.isNotEmpty) {
|
||||||
_currentDownloadJob = _downloadQueue.removeFirst();
|
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||||
|
|
||||||
|
// Only download if we have a connection
|
||||||
|
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) {
|
||||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_currentDownloadJob = null;
|
_currentDownloadJob = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class MessageService {
|
|||||||
String sid,
|
String sid,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
|
bool containsNoStore,
|
||||||
{
|
{
|
||||||
String? srcUrl,
|
String? srcUrl,
|
||||||
String? key,
|
String? key,
|
||||||
@@ -63,6 +64,10 @@ class MessageService {
|
|||||||
bool isDownloading = false,
|
bool isDownloading = false,
|
||||||
bool isUploading = false,
|
bool isUploading = false,
|
||||||
int? mediaSize,
|
int? mediaSize,
|
||||||
|
String? stickerPackId,
|
||||||
|
String? stickerHashKey,
|
||||||
|
int? pseudoMessageType,
|
||||||
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
}
|
}
|
||||||
) async {
|
) async {
|
||||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
||||||
@@ -74,6 +79,7 @@ class MessageService {
|
|||||||
sid,
|
sid,
|
||||||
isFileUploadNotification,
|
isFileUploadNotification,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
containsNoStore,
|
||||||
srcUrl: srcUrl,
|
srcUrl: srcUrl,
|
||||||
key: key,
|
key: key,
|
||||||
iv: iv,
|
iv: iv,
|
||||||
@@ -93,6 +99,10 @@ class MessageService {
|
|||||||
isUploading: isUploading,
|
isUploading: isUploading,
|
||||||
isDownloading: isDownloading,
|
isDownloading: isDownloading,
|
||||||
mediaSize: mediaSize,
|
mediaSize: mediaSize,
|
||||||
|
stickerPackId: stickerPackId,
|
||||||
|
stickerHashKey: stickerHashKey,
|
||||||
|
pseudoMessageType: pseudoMessageType,
|
||||||
|
pseudoMessageData: pseudoMessageData,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only update the cache if the conversation already has been loaded. This prevents
|
// Only update the cache if the conversation already has been loaded. This prevents
|
||||||
@@ -115,6 +125,17 @@ class MessageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Message?> getMessageByStanzaOrOriginId(String conversationJid, String id) async {
|
||||||
|
if (!_messageCache.containsKey(conversationJid)) {
|
||||||
|
await getMessagesForJid(conversationJid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstWhereOrNull(
|
||||||
|
_messageCache[conversationJid]!,
|
||||||
|
(message) => message.sid == id || message.originId == id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<Message?> getMessageById(String conversationJid, int id) async {
|
Future<Message?> getMessageById(String conversationJid, int id) async {
|
||||||
if (!_messageCache.containsKey(conversationJid)) {
|
if (!_messageCache.containsKey(conversationJid)) {
|
||||||
await getMessagesForJid(conversationJid);
|
await getMessagesForJid(conversationJid);
|
||||||
@@ -151,6 +172,8 @@ class MessageService {
|
|||||||
Object? sid = notSpecified,
|
Object? sid = notSpecified,
|
||||||
Object? thumbnailData = notSpecified,
|
Object? thumbnailData = notSpecified,
|
||||||
bool? isRetracted,
|
bool? isRetracted,
|
||||||
|
bool? isEdited,
|
||||||
|
Object? reactions = notSpecified,
|
||||||
}) async {
|
}) async {
|
||||||
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
||||||
id,
|
id,
|
||||||
@@ -177,6 +200,8 @@ class MessageService {
|
|||||||
isRetracted: isRetracted,
|
isRetracted: isRetracted,
|
||||||
isMedia: isMedia,
|
isMedia: isMedia,
|
||||||
thumbnailData: thumbnailData,
|
thumbnailData: thumbnailData,
|
||||||
|
isEdited: isEdited,
|
||||||
|
reactions: reactions,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ import 'package:moxxyv2/service/conversation.dart';
|
|||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
|
||||||
class MoxxyOmemoManager extends OmemoManager {
|
class MoxxyOmemoManager extends BaseOmemoManager {
|
||||||
|
|
||||||
MoxxyOmemoManager() : super();
|
MoxxyOmemoManager() : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<OmemoSessionManager> getSessionManager() async {
|
Future<OmemoManager> getOmemoManager() async {
|
||||||
final os = GetIt.I.get<OmemoService>();
|
final os = GetIt.I.get<OmemoService>();
|
||||||
await os.ensureInitialized();
|
await os.ensureInitialized();
|
||||||
return os.omemoState;
|
return os.omemoManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import 'package:synchronized/synchronized.dart';
|
|||||||
/// backoff. This means that we perform the random backoff only as long as we are
|
/// backoff. This means that we perform the random backoff only as long as we are
|
||||||
/// connected. Otherwise, we idle until we have a connection again.
|
/// connected. Otherwise, we idle until we have a connection again.
|
||||||
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||||
|
|
||||||
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
|
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
|
||||||
: _isTesting = isTesting,
|
: _isTesting = isTesting,
|
||||||
_timerLock = Lock(),
|
_timerLock = Lock(),
|
||||||
@@ -46,7 +45,7 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
|||||||
// Cancel the timer if it was running
|
// Cancel the timer if it was running
|
||||||
await _stopTimer();
|
await _stopTimer();
|
||||||
await setIsReconnecting(false);
|
await setIsReconnecting(false);
|
||||||
triggerConnectionLost!();
|
await triggerConnectionLost!();
|
||||||
} else if (regained && shouldReconnect) {
|
} else if (regained && shouldReconnect) {
|
||||||
// We should reconnect
|
// We should reconnect
|
||||||
_log.finest('Network regained. Attempting reconnection...');
|
_log.finest('Network regained. Attempting reconnection...');
|
||||||
|
|||||||
@@ -1,21 +1,91 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
|
||||||
class MoxxyRosterManager extends RosterManager {
|
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||||
@override
|
@override
|
||||||
Future<void> commitLastRosterVersion(String version) async {
|
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||||
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith(
|
final rs = GetIt.I.get<RosterService>();
|
||||||
|
return RosterCacheLoadResult(
|
||||||
|
(await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion,
|
||||||
|
(await rs.getRoster()).map((item) => XmppRosterItem(
|
||||||
|
jid: item.jid,
|
||||||
|
name: item.title,
|
||||||
|
subscription: item.subscription,
|
||||||
|
ask: item.ask.isEmpty ? null : item.ask,
|
||||||
|
groups: item.groups,
|
||||||
|
),).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> commitRoster(String? version, List<String> removed, List<XmppRosterItem> modified, List<XmppRosterItem> added) async {
|
||||||
|
final rs = GetIt.I.get<RosterService>();
|
||||||
|
final xs = GetIt.I.get<XmppService>();
|
||||||
|
await xs.modifyXmppState((state) => state.copyWith(
|
||||||
lastRosterVersion: version,
|
lastRosterVersion: version,
|
||||||
),);
|
),);
|
||||||
|
|
||||||
|
// Remove stale items
|
||||||
|
for (final jid in removed) {
|
||||||
|
await rs.removeRosterItemByJid(jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
// Create new roster items
|
||||||
Future<void> loadLastRosterVersion() async {
|
final rosterAdded = List<RosterItem>.empty(growable: true);
|
||||||
final ver = (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion;
|
for (final item in added) {
|
||||||
if (ver != null) {
|
rosterAdded.add(
|
||||||
setRosterVersion(ver);
|
await rs.addRosterItemFromData(
|
||||||
}
|
'',
|
||||||
|
'',
|
||||||
|
item.jid,
|
||||||
|
item.name ?? item.jid.split('@').first,
|
||||||
|
item.subscription,
|
||||||
|
item.ask ?? '',
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
groups: item.groups,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO(PapaTutuWawa): Fetch the avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update modified items
|
||||||
|
final rosterModified = List<RosterItem>.empty(growable: true);
|
||||||
|
for (final item in modified) {
|
||||||
|
final ritem = await rs.getRosterItemByJid(item.jid);
|
||||||
|
if (ritem == null) {
|
||||||
|
//_log.warning('Could not find roster item with JID $jid during update');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rosterModified.add(
|
||||||
|
await rs.updateRosterItem(
|
||||||
|
ritem.id,
|
||||||
|
title: item.name,
|
||||||
|
subscription: item.subscription,
|
||||||
|
ask: item.ask,
|
||||||
|
groups: item.groups,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the UI
|
||||||
|
// TODO(Unknown): This may not be the cleanest place to put it
|
||||||
|
sendEvent(
|
||||||
|
RosterDiffEvent(
|
||||||
|
added: rosterAdded,
|
||||||
|
modified: rosterModified,
|
||||||
|
removed: removed,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/events.dart';
|
import 'package:moxxyv2/service/events.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
@@ -85,21 +86,36 @@ class NotificationsService {
|
|||||||
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
||||||
/// then Android's BigPicture will be used.
|
/// then Android's BigPicture will be used.
|
||||||
Future<void> showNotification(modelc.Conversation c, modelm.Message m, String title, { String? body }) async {
|
Future<void> showNotification(modelc.Conversation c, modelm.Message m, String title, { String? body }) async {
|
||||||
// TODO(Unknown): Keep track of notifications to create a summary notification
|
|
||||||
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
||||||
final body = m.isMedia ?
|
String body;
|
||||||
mimeTypeToEmoji(m.mediaType) :
|
if (m.stickerPackId != null) {
|
||||||
m.body;
|
body = t.messages.sticker;
|
||||||
|
} else if (m.isMedia) {
|
||||||
|
body = mimeTypeToEmoji(m.mediaType);
|
||||||
|
} else {
|
||||||
|
body = m.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
|
final contactIntegrationEnabled = await css.isContactIntegrationEnabled();
|
||||||
|
final title = contactIntegrationEnabled ?
|
||||||
|
c.contactDisplayName ?? c.title :
|
||||||
|
c.title;
|
||||||
|
final avatarPath = contactIntegrationEnabled ?
|
||||||
|
c.contactAvatarPath ?? c.avatarUrl :
|
||||||
|
c.avatarUrl;
|
||||||
|
|
||||||
await AwesomeNotifications().createNotification(
|
await AwesomeNotifications().createNotification(
|
||||||
content: NotificationContent(
|
content: NotificationContent(
|
||||||
id: m.id,
|
id: m.id,
|
||||||
groupKey: c.jid,
|
groupKey: c.jid,
|
||||||
channelKey: _messageChannelKey,
|
channelKey: _messageChannelKey,
|
||||||
summary: c.title,
|
summary: title,
|
||||||
title: c.title,
|
title: title,
|
||||||
body: body,
|
body: body,
|
||||||
largeIcon: c.avatarUrl.isNotEmpty ? 'file://${c.avatarUrl}' : null,
|
largeIcon: avatarPath.isNotEmpty ?
|
||||||
|
'file://$avatarPath' :
|
||||||
|
null,
|
||||||
notificationLayout: m.isThumbnailable ?
|
notificationLayout: m.isThumbnailable ?
|
||||||
NotificationLayout.BigPicture :
|
NotificationLayout.BigPicture :
|
||||||
NotificationLayout.Messaging,
|
NotificationLayout.Messaging,
|
||||||
@@ -108,8 +124,8 @@ class NotificationsService {
|
|||||||
payload: <String, String>{
|
payload: <String, String>{
|
||||||
'conversationJid': c.jid,
|
'conversationJid': c.jid,
|
||||||
'sid': m.sid,
|
'sid': m.sid,
|
||||||
'title': c.title,
|
'title': title,
|
||||||
'avatarUrl': c.avatarUrl,
|
'avatarUrl': avatarPath,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
|
||||||
Future<OmemoSessionManager> generateNewIdentityImpl(String jid) async {
|
Future<OmemoDevice> generateNewIdentityImpl(String jid) async {
|
||||||
return OmemoSessionManager.generateNewIdentity(
|
return OmemoDevice.generateNewDevice(jid);
|
||||||
jid,
|
|
||||||
MoxxyBTBVTrustManager(
|
|
||||||
<RatchetMapKey, BTBVTrustState>{},
|
|
||||||
<RatchetMapKey, bool>{},
|
|
||||||
<String, List<int>>{},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
import 'package:moxxyv2/service/omemo/types.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp.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:omemo_dart/omemo_dart.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class OmemoDoubleRatchetWrapper {
|
class OmemoDoubleRatchetWrapper {
|
||||||
|
|
||||||
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
||||||
final OmemoDoubleRatchet ratchet;
|
final OmemoDoubleRatchet ratchet;
|
||||||
final int id;
|
final int id;
|
||||||
@@ -21,14 +26,14 @@ class OmemoDoubleRatchetWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class OmemoService {
|
class OmemoService {
|
||||||
|
|
||||||
final Logger _log = Logger('OmemoService');
|
final Logger _log = Logger('OmemoService');
|
||||||
|
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
final Lock _lock = Lock();
|
final Lock _lock = Lock();
|
||||||
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
|
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
|
||||||
|
final Map<String, Map<int, String>> _fingerprintCache = {};
|
||||||
|
|
||||||
late OmemoSessionManager omemoState;
|
late OmemoManager omemoManager;
|
||||||
|
|
||||||
Future<void> initializeIfNeeded(String jid) async {
|
Future<void> initializeIfNeeded(String jid) async {
|
||||||
final done = await _lock.synchronized(() => _initialized);
|
final done = await _lock.synchronized(() => _initialized);
|
||||||
@@ -36,44 +41,73 @@ class OmemoService {
|
|||||||
|
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
final db = GetIt.I.get<DatabaseService>();
|
||||||
final device = await db.loadOmemoDevice(jid);
|
final device = await db.loadOmemoDevice(jid);
|
||||||
|
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||||
|
final deviceList = <String, List<int>>{};
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||||
// Generate the identity in the background
|
|
||||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
|
||||||
|
|
||||||
await commitDevice(await omemoState.getDevice());
|
|
||||||
await commitDeviceMap(<String, List<int>>{});
|
|
||||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
|
||||||
} else {
|
} else {
|
||||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
|
||||||
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
||||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||||
ratchetMap[key] = ratchet.ratchet;
|
ratchetMap[key] = ratchet.ratchet;
|
||||||
}
|
}
|
||||||
|
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
deviceList.addAll(await db.loadOmemoDeviceList());
|
||||||
omemoState = OmemoSessionManager(
|
|
||||||
device,
|
|
||||||
await db.loadOmemoDeviceList(),
|
|
||||||
ratchetMap,
|
|
||||||
await loadTrustManager(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
omemoState.eventStream.listen((event) async {
|
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) {
|
if (event is RatchetModifiedEvent) {
|
||||||
await GetIt.I.get<DatabaseService>().saveRatchet(
|
await GetIt.I.get<DatabaseService>().saveRatchet(
|
||||||
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
|
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
|
||||||
);
|
);
|
||||||
} else if (event is DeviceMapModifiedEvent) {
|
|
||||||
await commitDeviceMap(event.map);
|
if (event.added) {
|
||||||
|
// Cache the fingerprint
|
||||||
|
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
||||||
|
await GetIt.I.get<DatabaseService>().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) {
|
} else if (event is DeviceModifiedEvent) {
|
||||||
await commitDevice(event.device);
|
await commitDevice(event.device);
|
||||||
|
|
||||||
// Publish it
|
// Publish it
|
||||||
await GetIt.I.get<XmppConnection>()
|
await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||||
.getManagerById<OmemoManager>(omemoManager)!
|
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||||
.publishBundle(await event.device.toBundle());
|
.publishBundle(await event.device.toBundle());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -88,32 +122,63 @@ class OmemoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<OmemoDevice> regenerateDevice(String jid) async {
|
/// 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<XmppService>().getXmppState();
|
||||||
|
if (jid == xmppState.jid) return;
|
||||||
|
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final message = await ms.addMessageFromData(
|
||||||
|
'',
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'',
|
||||||
|
jid,
|
||||||
|
false,
|
||||||
|
'',
|
||||||
|
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
|
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
|
||||||
await _lock.synchronized(() {
|
await _lock.synchronized(() {
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||||
final oldId = await omemoState.getDeviceId();
|
final oldId = await omemoManager.getDeviceId();
|
||||||
|
|
||||||
// Clear the database
|
// Clear the database
|
||||||
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
||||||
|
|
||||||
// Regenerate the identity in the background
|
// Regenerate the identity in the background
|
||||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
final device = await compute(generateNewIdentityImpl, jid);
|
||||||
|
await omemoManager.replaceDevice(device);
|
||||||
await commitDevice(await omemoState.getDevice());
|
await commitDevice(device);
|
||||||
await commitDeviceMap(<String, List<int>>{});
|
await commitDeviceMap(<String, List<int>>{});
|
||||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||||
|
|
||||||
// Remove the old device
|
// Remove the old device
|
||||||
final omemo = GetIt.I.get<XmppConnection>()
|
final omemo = GetIt.I.get<moxxmpp.XmppConnection>()
|
||||||
.getManagerById<OmemoManager>(omemoManager)!;
|
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||||
await omemo.deleteDevice(oldId);
|
await omemo.deleteDevice(oldId);
|
||||||
|
|
||||||
// Publish the new one
|
// Publish the new one
|
||||||
await omemo.publishBundle(await omemoState.getDeviceBundle());
|
await omemo.publishBundle(await omemoManager.getDeviceBundle());
|
||||||
|
|
||||||
// Allow access again
|
// Allow access again
|
||||||
await _lock.synchronized(() {
|
await _lock.synchronized(() {
|
||||||
@@ -126,7 +191,7 @@ class OmemoService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Return the OmemoDevice
|
// Return the OmemoDevice
|
||||||
return OmemoDevice(
|
return model.OmemoDevice(
|
||||||
await getDeviceFingerprint(),
|
await getDeviceFingerprint(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
@@ -157,7 +222,7 @@ class OmemoService {
|
|||||||
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
|
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitDevice(Device device) async {
|
Future<void> commitDevice(OmemoDevice device) async {
|
||||||
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
|
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,55 +233,108 @@ class OmemoService {
|
|||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
_log.finest('publishDeviceIfNeeded: Done');
|
_log.finest('publishDeviceIfNeeded: Done');
|
||||||
|
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||||
final omemo = conn.getManagerById<OmemoManager>(omemoManager)!;
|
final omemo = conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||||
final dm = conn.getManagerById<DiscoManager>(discoManager)!;
|
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||||
final bareJid = conn.getConnectionSettings().jid.toBare();
|
final bareJid = conn.getConnectionSettings().jid.toBare();
|
||||||
final device = await omemoState.getDevice();
|
final device = await omemoManager.getDevice();
|
||||||
|
|
||||||
final bundlesRaw = await dm.discoItemsQuery(
|
final bundlesRaw = await dm.discoItemsQuery(
|
||||||
bareJid.toString(),
|
bareJid.toString(),
|
||||||
node: omemoBundlesXmlns,
|
node: moxxmpp.omemoBundlesXmlns,
|
||||||
);
|
);
|
||||||
if (bundlesRaw.isType<DiscoError>()) {
|
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||||
await omemo.publishBundle(await device.toBundle());
|
await omemo.publishBundle(await device.toBundle());
|
||||||
return bundlesRaw.get<DiscoError>();
|
return bundlesRaw.get<moxxmpp.DiscoError>();
|
||||||
}
|
}
|
||||||
|
|
||||||
final bundleIds = bundlesRaw
|
final bundleIds = bundlesRaw
|
||||||
.get<List<DiscoItem>>()
|
.get<List<moxxmpp.DiscoItem>>()
|
||||||
.where((item) => item.name != null)
|
.where((item) => item.name != null)
|
||||||
.map((item) => int.parse(item.name!));
|
.map((item) => int.parse(item.name!));
|
||||||
if (!bundleIds.contains(device.id)) {
|
if (!bundleIds.contains(device.id)) {
|
||||||
final result = await omemo.publishBundle(await device.toBundle());
|
final result = await omemo.publishBundle(await device.toBundle());
|
||||||
if (result.isType<OmemoError>()) return result.get<OmemoError>();
|
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final idsRaw = await omemo.getDeviceList(bareJid);
|
final idsRaw = await omemo.getDeviceList(bareJid);
|
||||||
final ids = idsRaw.isType<OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
final ids = idsRaw.isType<moxxmpp.OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
||||||
if (!ids.contains(device.id)) {
|
if (!ids.contains(device.id)) {
|
||||||
final result = await omemo.publishBundle(await device.toBundle());
|
final result = await omemo.publishBundle(await device.toBundle());
|
||||||
if (result.isType<OmemoError>()) return result.get<OmemoError>();
|
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
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 GetIt.I.get<DatabaseService>().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 GetIt.I.get<DatabaseService>()
|
||||||
|
.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 {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
final fingerprints = await omemoState.getHexFingerprintsForJid(jid);
|
|
||||||
final keys = List<OmemoDevice>.empty(growable: true);
|
// Get finger prints if we have to
|
||||||
for (final fp in fingerprints) {
|
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(
|
keys.add(
|
||||||
OmemoDevice(
|
model.OmemoDevice(
|
||||||
fp.fingerprint,
|
_fingerprintCache[jid]![deviceId]!,
|
||||||
await omemoState.trustManager.isTrusted(jid, fp.deviceId),
|
await tm.isTrusted(jid, deviceId),
|
||||||
// TODO(Unknown): Allow verifying OMEMO keys
|
trustMap[deviceId] == BTBVTrustState.verified,
|
||||||
false,
|
await tm.isEnabled(jid, deviceId),
|
||||||
await omemoState.trustManager.isEnabled(jid, fp.deviceId),
|
deviceId,
|
||||||
fp.deviceId,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,7 +343,6 @@ class OmemoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||||
|
|
||||||
await GetIt.I.get<DatabaseService>().saveTrustCache(
|
await GetIt.I.get<DatabaseService>().saveTrustCache(
|
||||||
json['trust']! as Map<String, int>,
|
json['trust']! as Map<String, int>,
|
||||||
);
|
);
|
||||||
@@ -248,58 +365,70 @@ class OmemoService {
|
|||||||
|
|
||||||
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
|
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
await omemoState.trustManager.setEnabled(jid, deviceId, enabled);
|
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeAllSessions(String jid) async {
|
Future<void> removeAllSessions(String jid) async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
await omemoState.removeAllRatchets(jid);
|
await omemoManager.removeAllRatchets(jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> getDeviceId() async {
|
Future<int> getDeviceId() async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
return omemoState.getDeviceId();
|
return omemoManager.getDeviceId();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> getDeviceFingerprint() async {
|
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
|
||||||
return (await omemoState.getHexFingerprintForDevice()).fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
||||||
/// published on [ownJid]'s devices PubSub node.
|
/// published on [ownJid]'s devices PubSub node.
|
||||||
/// Note that the list is made so that the current device is excluded.
|
/// Note that the list is made so that the current device is excluded.
|
||||||
Future<List<OmemoDevice>> getOwnFingerprints(JID ownJid) async {
|
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
|
||||||
final ownId = await getDeviceId();
|
final ownId = await getDeviceId();
|
||||||
final keys = List<OmemoDevice>.from(
|
final keys = List<model.OmemoDevice>.from(
|
||||||
await getOmemoKeysForJid(ownJid.toString()),
|
await getOmemoKeysForJid(ownJid.toString()),
|
||||||
);
|
);
|
||||||
|
final bareJid = ownJid.toBare().toString();
|
||||||
|
|
||||||
// TODO(PapaTutuWawa): This should be cached in the database and only requested if
|
// Get fingerprints if we have to
|
||||||
// it's not cached.
|
await _loadOrFetchFingerprints(ownJid);
|
||||||
final allDevicesRaw = await conn.getManagerById<OmemoManager>(omemoManager)!
|
|
||||||
.retrieveDeviceBundles(ownJid);
|
|
||||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
|
||||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
|
||||||
|
|
||||||
for (final device in allDevices) {
|
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||||
// All devices that are publishes that is not the current device
|
final trustMap = await tm.getDevicesTrust(bareJid);
|
||||||
if (device.id == ownId) continue;
|
|
||||||
final curveIk = await device.ik.toCurve25519();
|
|
||||||
|
|
||||||
|
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(
|
keys.add(
|
||||||
OmemoDevice(
|
model.OmemoDevice(
|
||||||
HEX.encode(await curveIk.getBytes()),
|
fingerprint,
|
||||||
false,
|
await tm.isTrusted(bareJid, deviceId),
|
||||||
false,
|
trustMap[deviceId] == BTBVTrustState.verified,
|
||||||
false,
|
await tm.isEnabled(bareJid, deviceId),
|
||||||
device.id,
|
deviceId,
|
||||||
hasSessionWith: false,
|
hasSessionWith: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> verifyDevice(int deviceId, String jid) async {
|
||||||
|
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||||
|
await tm.setDeviceTrust(
|
||||||
|
jid,
|
||||||
|
deviceId,
|
||||||
|
BTBVTrustState.verified,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
|
||||||
|
void onNewConnection() {
|
||||||
|
if (_initialized) {
|
||||||
|
omemoManager.onNewConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
lib/service/omemo/types.dart
Normal file
6
lib/service/omemo/types.dart
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
class OmemoCacheTriple {
|
||||||
|
const OmemoCacheTriple(this.jid, this.deviceId, this.fingerprint);
|
||||||
|
final String jid;
|
||||||
|
final int deviceId;
|
||||||
|
final String fingerprint;
|
||||||
|
}
|
||||||
@@ -1,189 +1,28 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
|
||||||
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
|
|
||||||
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
|
|
||||||
return (i) => i.jid == jid;
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef AddRosterItemFunction = Future<RosterItem> Function(
|
|
||||||
String avatarUrl,
|
|
||||||
String avatarHash,
|
|
||||||
String jid,
|
|
||||||
String title,
|
|
||||||
String subscription,
|
|
||||||
String ask,
|
|
||||||
{
|
|
||||||
List<String> groups,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
typedef UpdateRosterItemFunction = Future<RosterItem> Function(
|
|
||||||
int id, {
|
|
||||||
String? avatarUrl,
|
|
||||||
String? avatarHash,
|
|
||||||
String? title,
|
|
||||||
String? subscription,
|
|
||||||
String? ask,
|
|
||||||
List<String>? groups,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
typedef RemoveRosterItemFunction = Future<void> Function(String jid);
|
|
||||||
typedef GetConversationFunction = Future<Conversation?> Function(String jid);
|
|
||||||
typedef SendEventFunction = void Function(BackgroundEvent event, { String? id });
|
|
||||||
|
|
||||||
/// Compare the local roster with the roster we received either by request or by push.
|
|
||||||
/// Returns a diff between the roster before and after the request or the push.
|
|
||||||
/// NOTE: This abuses the [RosterDiffEvent] type a bit.
|
|
||||||
Future<RosterDiffEvent> processRosterDiff(
|
|
||||||
List<RosterItem> currentRoster,
|
|
||||||
List<XmppRosterItem> remoteRoster,
|
|
||||||
bool isRosterPush,
|
|
||||||
AddRosterItemFunction addRosterItemFromData,
|
|
||||||
UpdateRosterItemFunction updateRosterItem,
|
|
||||||
RemoveRosterItemFunction removeRosterItemByJid,
|
|
||||||
GetConversationFunction getConversationByJid,
|
|
||||||
SendEventFunction _sendEvent,
|
|
||||||
) async {
|
|
||||||
final removed = List<String>.empty(growable: true);
|
|
||||||
final modified = List<RosterItem>.empty(growable: true);
|
|
||||||
final added = List<RosterItem>.empty(growable: true);
|
|
||||||
|
|
||||||
for (final item in remoteRoster) {
|
|
||||||
if (isRosterPush) {
|
|
||||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
|
||||||
if (litem != null) {
|
|
||||||
if (item.subscription == 'remove') {
|
|
||||||
// We have the item locally but it has been removed
|
|
||||||
await removeRosterItemByJid(item.jid);
|
|
||||||
removed.add(item.jid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item has been modified
|
|
||||||
final newItem = await updateRosterItem(
|
|
||||||
litem.id,
|
|
||||||
subscription: item.subscription,
|
|
||||||
title: item.name,
|
|
||||||
ask: item.ask,
|
|
||||||
groups: item.groups,
|
|
||||||
);
|
|
||||||
|
|
||||||
modified.add(newItem);
|
|
||||||
|
|
||||||
// Check if we have a conversation that we need to modify
|
|
||||||
final conv = await getConversationByJid(item.jid);
|
|
||||||
if (conv != null) {
|
|
||||||
_sendEvent(
|
|
||||||
ConversationUpdatedEvent(
|
|
||||||
conversation: conv.copyWith(subscription: item.subscription),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Item does not exist locally
|
|
||||||
if (item.subscription == 'remove') {
|
|
||||||
// Item has been removed but we don't have it locally
|
|
||||||
removed.add(item.jid);
|
|
||||||
} else {
|
|
||||||
// Item has been added and we don't have it locally
|
|
||||||
final newItem = await addRosterItemFromData(
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
item.jid,
|
|
||||||
item.name ?? item.jid.split('@')[0],
|
|
||||||
item.subscription,
|
|
||||||
item.ask ?? '',
|
|
||||||
groups: item.groups,
|
|
||||||
);
|
|
||||||
|
|
||||||
added.add(newItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
|
||||||
if (litem != null) {
|
|
||||||
// Item is modified
|
|
||||||
if (litem.title != item.name || litem.subscription != item.subscription || !listEquals(litem.groups, item.groups)) {
|
|
||||||
final modifiedItem = await updateRosterItem(
|
|
||||||
litem.id,
|
|
||||||
title: item.name,
|
|
||||||
subscription: item.subscription,
|
|
||||||
groups: item.groups,
|
|
||||||
);
|
|
||||||
modified.add(modifiedItem);
|
|
||||||
|
|
||||||
// Check if we have a conversation that we need to modify
|
|
||||||
final conv = await getConversationByJid(litem.jid);
|
|
||||||
if (conv != null) {
|
|
||||||
_sendEvent(
|
|
||||||
ConversationUpdatedEvent(
|
|
||||||
conversation: conv.copyWith(subscription: item.subscription),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Item is new
|
|
||||||
added.add(await addRosterItemFromData(
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
item.jid,
|
|
||||||
item.jid.split('@')[0],
|
|
||||||
item.subscription,
|
|
||||||
item.ask ?? '',
|
|
||||||
groups: item.groups,
|
|
||||||
),);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRosterPush) {
|
|
||||||
for (final item in currentRoster) {
|
|
||||||
final ritem = firstWhereOrNull(remoteRoster, (XmppRosterItem i) => i.jid == item.jid);
|
|
||||||
if (ritem == null) {
|
|
||||||
await removeRosterItemByJid(item.jid);
|
|
||||||
removed.add(item.jid);
|
|
||||||
}
|
|
||||||
// We don't handle the modification case here as that is covered by the huge
|
|
||||||
// loop above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return RosterDiffEvent(
|
|
||||||
added: added,
|
|
||||||
modified: modified,
|
|
||||||
removed: removed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class RosterService {
|
class RosterService {
|
||||||
|
RosterService() : _log = Logger('RosterService');
|
||||||
RosterService()
|
Map<String, RosterItem>? _rosterCache;
|
||||||
: _rosterCache = HashMap(),
|
|
||||||
_rosterLoaded = false,
|
|
||||||
_log = Logger('RosterService');
|
|
||||||
final HashMap<String, RosterItem> _rosterCache;
|
|
||||||
bool _rosterLoaded;
|
|
||||||
final Logger _log;
|
final Logger _log;
|
||||||
|
|
||||||
Future<bool> isInRoster(String jid) async {
|
Future<void> _loadRosterIfNeeded() async {
|
||||||
if (!_rosterLoaded) {
|
if (_rosterCache == null) {
|
||||||
await loadRosterFromDatabase();
|
await loadRosterFromDatabase();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return _rosterCache.containsKey(jid);
|
Future<bool> isInRoster(String jid) async {
|
||||||
|
await _loadRosterIfNeeded();
|
||||||
|
return _rosterCache!.containsKey(jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||||
@@ -194,6 +33,10 @@ class RosterService {
|
|||||||
String title,
|
String title,
|
||||||
String subscription,
|
String subscription,
|
||||||
String ask,
|
String ask,
|
||||||
|
bool pseudoRosterItem,
|
||||||
|
String? contactId,
|
||||||
|
String? contactAvatarPath,
|
||||||
|
String? contactDisplayName,
|
||||||
{
|
{
|
||||||
List<String> groups = const [],
|
List<String> groups = const [],
|
||||||
}
|
}
|
||||||
@@ -205,11 +48,15 @@ class RosterService {
|
|||||||
title,
|
title,
|
||||||
subscription,
|
subscription,
|
||||||
ask,
|
ask,
|
||||||
|
pseudoRosterItem,
|
||||||
|
contactId,
|
||||||
|
contactAvatarPath,
|
||||||
|
contactDisplayName,
|
||||||
groups: groups,
|
groups: groups,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the cache
|
// Update the cache
|
||||||
_rosterCache[item.jid] = item;
|
_rosterCache![item.jid] = item;
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -222,7 +69,11 @@ class RosterService {
|
|||||||
String? title,
|
String? title,
|
||||||
String? subscription,
|
String? subscription,
|
||||||
String? ask,
|
String? ask,
|
||||||
|
Object pseudoRosterItem = notSpecified,
|
||||||
List<String>? groups,
|
List<String>? groups,
|
||||||
|
Object? contactId = notSpecified,
|
||||||
|
Object? contactAvatarPath = notSpecified,
|
||||||
|
Object? contactDisplayName = notSpecified,
|
||||||
}
|
}
|
||||||
) async {
|
) async {
|
||||||
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
|
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
|
||||||
@@ -232,30 +83,34 @@ class RosterService {
|
|||||||
title: title,
|
title: title,
|
||||||
subscription: subscription,
|
subscription: subscription,
|
||||||
ask: ask,
|
ask: ask,
|
||||||
|
pseudoRosterItem: pseudoRosterItem,
|
||||||
groups: groups,
|
groups: groups,
|
||||||
|
contactId: contactId,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
_rosterCache[newItem.jid] = newItem;
|
_rosterCache![newItem.jid] = newItem;
|
||||||
|
|
||||||
return newItem;
|
return newItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
||||||
Future<void> removeRosterItem(int id) async {
|
Future<void> removeRosterItem(int id) async {
|
||||||
|
// NOTE: This call ensures that _rosterCache != null
|
||||||
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
||||||
|
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||||
|
|
||||||
/// Update cache
|
/// Update cache
|
||||||
_rosterCache.removeWhere((_, value) => value.id == id);
|
_rosterCache!.removeWhere((_, value) => value.id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a roster item from the database based on its JID.
|
/// Removes a roster item from the database based on its JID.
|
||||||
Future<void> removeRosterItemByJid(String jid) async {
|
Future<void> removeRosterItemByJid(String jid) async {
|
||||||
if (!_rosterLoaded) {
|
await _loadRosterIfNeeded();
|
||||||
await loadRosterFromDatabase();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final item in _rosterCache.values) {
|
for (final item in _rosterCache!.values) {
|
||||||
if (item.jid == jid) {
|
if (item.jid == jid) {
|
||||||
await removeRosterItem(item.id);
|
await removeRosterItem(item.id);
|
||||||
return;
|
return;
|
||||||
@@ -265,17 +120,14 @@ class RosterService {
|
|||||||
|
|
||||||
/// Returns the entire roster
|
/// Returns the entire roster
|
||||||
Future<List<RosterItem>> getRoster() async {
|
Future<List<RosterItem>> getRoster() async {
|
||||||
if (!_rosterLoaded) {
|
await _loadRosterIfNeeded();
|
||||||
await loadRosterFromDatabase();
|
return _rosterCache!.values.toList();
|
||||||
}
|
|
||||||
|
|
||||||
return _rosterCache.values.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
||||||
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
||||||
if (await isInRoster(jid)) {
|
if (await isInRoster(jid)) {
|
||||||
return _rosterCache[jid];
|
return _rosterCache![jid];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -286,9 +138,9 @@ class RosterService {
|
|||||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||||
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
||||||
|
|
||||||
_rosterLoaded = true;
|
_rosterCache = <String, RosterItem>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
_rosterCache[item.jid] = item;
|
_rosterCache![item.jid] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
@@ -298,6 +150,8 @@ class RosterService {
|
|||||||
/// and, if it was successful, create the database entry. Returns the
|
/// and, if it was successful, create the database entry. Returns the
|
||||||
/// [RosterItem] model object.
|
/// [RosterItem] model object.
|
||||||
Future<RosterItem> addToRosterWrapper(String avatarUrl, String avatarHash, String jid, String title) async {
|
Future<RosterItem> addToRosterWrapper(String avatarUrl, String avatarHash, String jid, String title) async {
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
|
final contactId = await css.getContactIdForJid(jid);
|
||||||
final item = await addRosterItemFromData(
|
final item = await addRosterItemFromData(
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
avatarHash,
|
avatarHash,
|
||||||
@@ -305,6 +159,10 @@ class RosterService {
|
|||||||
title,
|
title,
|
||||||
'none',
|
'none',
|
||||||
'',
|
'',
|
||||||
|
false,
|
||||||
|
contactId,
|
||||||
|
await css.getProfilePicturePathForJid(jid),
|
||||||
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
final result = await GetIt.I.get<XmppConnection>().getRosterManager().addToRoster(jid, title);
|
final result = await GetIt.I.get<XmppConnection>().getRosterManager().addToRoster(jid, title);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
@@ -337,59 +195,6 @@ class RosterService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> requestRoster() async {
|
|
||||||
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
|
|
||||||
Result<RosterRequestResult?, RosterError> result;
|
|
||||||
if (roster.rosterVersioningAvailable()) {
|
|
||||||
_log.fine('Stream supports roster versioning');
|
|
||||||
result = await roster.requestRosterPushes();
|
|
||||||
_log.fine('Requesting roster pushes done');
|
|
||||||
} else {
|
|
||||||
_log.fine('Stream does not support roster versioning');
|
|
||||||
result = await roster.requestRoster();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.isType<RosterError>()) {
|
|
||||||
_log.warning('Failed to request roster');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final value = result.get<RosterRequestResult?>();
|
|
||||||
if (value != null) {
|
|
||||||
final currentRoster = await getRoster();
|
|
||||||
sendEvent(
|
|
||||||
await processRosterDiff(
|
|
||||||
currentRoster,
|
|
||||||
value.items,
|
|
||||||
false,
|
|
||||||
addRosterItemFromData,
|
|
||||||
updateRosterItem,
|
|
||||||
removeRosterItemByJid,
|
|
||||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
|
||||||
sendEvent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles a roster push.
|
|
||||||
Future<void> handleRosterPushEvent(RosterPushEvent event) async {
|
|
||||||
final item = event.item;
|
|
||||||
final currentRoster = await getRoster();
|
|
||||||
sendEvent(
|
|
||||||
await processRosterDiff(
|
|
||||||
currentRoster,
|
|
||||||
[ item ],
|
|
||||||
true,
|
|
||||||
addRosterItemFromData,
|
|
||||||
updateRosterItem,
|
|
||||||
removeRosterItemByJid,
|
|
||||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
|
||||||
sendEvent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
Future<void> acceptSubscriptionRequest(String jid) async {
|
||||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid);
|
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:moxxyv2/service/avatars.dart';
|
|||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||||
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
@@ -30,6 +31,7 @@ import 'package:moxxyv2/service/notifications.dart';
|
|||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
|
import 'package:moxxyv2/service/stickers.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||||
@@ -153,10 +155,13 @@ Future<void> entrypoint() async {
|
|||||||
GetIt.I.registerSingleton<MessageService>(MessageService());
|
GetIt.I.registerSingleton<MessageService>(MessageService());
|
||||||
GetIt.I.registerSingleton<OmemoService>(OmemoService());
|
GetIt.I.registerSingleton<OmemoService>(OmemoService());
|
||||||
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
||||||
|
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||||
|
GetIt.I.registerSingleton<StickersService>(StickersService());
|
||||||
final xmpp = XmppService();
|
final xmpp = XmppService();
|
||||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||||
|
|
||||||
await GetIt.I.get<NotificationsService>().init();
|
await GetIt.I.get<NotificationsService>().init();
|
||||||
|
await GetIt.I.get<ContactsService>().init();
|
||||||
|
|
||||||
if (!kDebugMode) {
|
if (!kDebugMode) {
|
||||||
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
|
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
|
||||||
@@ -173,7 +178,7 @@ Future<void> entrypoint() async {
|
|||||||
)..registerManagers([
|
)..registerManagers([
|
||||||
MoxxyStreamManagementManager(),
|
MoxxyStreamManagementManager(),
|
||||||
MoxxyDiscoManager(),
|
MoxxyDiscoManager(),
|
||||||
MoxxyRosterManager(),
|
RosterManager(MoxxyRosterStateManager()),
|
||||||
MoxxyOmemoManager(),
|
MoxxyOmemoManager(),
|
||||||
PingManager(),
|
PingManager(),
|
||||||
MessageManager(),
|
MessageManager(),
|
||||||
@@ -197,6 +202,9 @@ Future<void> entrypoint() async {
|
|||||||
CryptographicHashManager(),
|
CryptographicHashManager(),
|
||||||
DelayedDeliveryManager(),
|
DelayedDeliveryManager(),
|
||||||
MessageRetractionManager(),
|
MessageRetractionManager(),
|
||||||
|
LastMessageCorrectionManager(),
|
||||||
|
MessageReactionsManager(),
|
||||||
|
StickersManager(),
|
||||||
])
|
])
|
||||||
..registerFeatureNegotiators([
|
..registerFeatureNegotiators([
|
||||||
ResourceBindingNegotiator(),
|
ResourceBindingNegotiator(),
|
||||||
@@ -244,6 +252,7 @@ Future<void> entrypoint() async {
|
|||||||
sendEvent(ServiceReadyEvent());
|
sendEvent(ServiceReadyEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
Future<void> receiveUIEvent(Map<String, dynamic>? data) async {
|
Future<void> receiveUIEvent(Map<String, dynamic>? data) async {
|
||||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data);
|
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data);
|
||||||
}
|
}
|
||||||
|
|||||||
344
lib/service/stickers.dart
Normal file
344
lib/service/stickers.dart
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:archive/archive.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxlib/moxlib.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||||
|
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];
|
||||||
|
|
||||||
|
final pack = await GetIt.I.get<DatabaseService>().getStickerPackById(id);
|
||||||
|
if (pack == null) return null;
|
||||||
|
|
||||||
|
_stickerPacks[id] = pack;
|
||||||
|
return _stickerPacks[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Sticker?> getStickerByHashKey(String packId, String hashKey) async {
|
||||||
|
final pack = await getStickerPackById(packId);
|
||||||
|
if (pack == null) return null;
|
||||||
|
|
||||||
|
return firstWhereOrNull<Sticker>(
|
||||||
|
pack.stickers,
|
||||||
|
(sticker) => sticker.hashKey == hashKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StickerPack>> getStickerPacks() async {
|
||||||
|
if (_stickerPacks.isEmpty) {
|
||||||
|
final packs = await GetIt.I.get<DatabaseService>().loadStickerPacks();
|
||||||
|
for (final pack in packs) {
|
||||||
|
_stickerPacks[pack.id] = pack;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _stickerPacks.values.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeStickerPack(String id) async {
|
||||||
|
final pack = await getStickerPackById(id);
|
||||||
|
assert(pack != null, 'The sticker pack must exist');
|
||||||
|
|
||||||
|
// Delete the files
|
||||||
|
final stickerPackPath = await getStickerPackPath(
|
||||||
|
pack!.hashAlgorithm,
|
||||||
|
pack.hashValue,
|
||||||
|
);
|
||||||
|
final stickerPackDir = Directory(stickerPackPath);
|
||||||
|
if (stickerPackDir.existsSync()) {
|
||||||
|
unawaited(
|
||||||
|
stickerPackDir.delete(
|
||||||
|
recursive: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from the database
|
||||||
|
await GetIt.I.get<DatabaseService>().removeStickerPackById(id);
|
||||||
|
|
||||||
|
// Remove from the cache
|
||||||
|
_stickerPacks.remove(id);
|
||||||
|
|
||||||
|
// Retract from PubSub
|
||||||
|
final state = await GetIt.I.get<XmppService>().getXmppState();
|
||||||
|
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||||
|
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
|
||||||
|
.retractStickerPack(moxxmpp.JID.fromString(state.jid!), id);
|
||||||
|
|
||||||
|
if (result.isType<moxxmpp.PubSubError>()) {
|
||||||
|
_log.severe('Failed to retract sticker pack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
|
||||||
|
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
|
final state = await GetIt.I.get<XmppService>().getXmppState();
|
||||||
|
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||||
|
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
|
||||||
|
.publishStickerPack(
|
||||||
|
moxxmpp.JID.fromString(state.jid!),
|
||||||
|
pack,
|
||||||
|
accessModel: prefs.isStickersNodePublic ?
|
||||||
|
'open' :
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isType<moxxmpp.PubSubError>()) {
|
||||||
|
_log.severe('Failed to publish sticker pack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the path to the sticker pack with hash algorithm [algo] and hash [hash].
|
||||||
|
/// Ensures that the directory exists before returning.
|
||||||
|
Future<String> _getStickerPackPath(String algo, String hash) async {
|
||||||
|
final stickerDirPath = await getStickerPackPath(algo, hash);
|
||||||
|
final stickerDir = Directory(stickerDirPath);
|
||||||
|
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
|
||||||
|
|
||||||
|
return stickerDirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> importFromPubSubWithEvent(moxxmpp.JID jid, String stickerPackId) async {
|
||||||
|
final stickerPack = await importFromPubSub(jid, stickerPackId);
|
||||||
|
if (stickerPack == null) return;
|
||||||
|
|
||||||
|
sendEvent(
|
||||||
|
StickerPackAddedEvent(
|
||||||
|
stickerPack: stickerPack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the jid of the host [jid] and the id [stickerPackId] of the sticker pack
|
||||||
|
/// and tries to fetch and install it, including publishing on our own PubSub node.
|
||||||
|
///
|
||||||
|
/// On success, returns the installed StickerPack. On failure, returns null.
|
||||||
|
Future<StickerPack?> importFromPubSub(moxxmpp.JID jid, String stickerPackId) async {
|
||||||
|
final result = await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||||
|
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
|
||||||
|
.fetchStickerPack(jid.toBare(), stickerPackId);
|
||||||
|
|
||||||
|
if (result.isType<moxxmpp.PubSubError>()) {
|
||||||
|
_log.warning('Failed to fetch sticker pack $jid:$stickerPackId');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stickerPackRaw = StickerPack.fromMoxxmpp(
|
||||||
|
result.get<moxxmpp.StickerPack>(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Install the sticker pack
|
||||||
|
return installFromPubSub(stickerPackRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
|
||||||
|
assert(!remotePack.local, 'Sticker pack must be remote');
|
||||||
|
|
||||||
|
final stickerPackPath = await _getStickerPackPath(
|
||||||
|
remotePack.hashAlgorithm,
|
||||||
|
remotePack.hashValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
var success = true;
|
||||||
|
final stickers = List<Sticker>.from(remotePack.stickers);
|
||||||
|
for (var i = 0; i < stickers.length; i++) {
|
||||||
|
final sticker = stickers[i];
|
||||||
|
final stickerPath = p.join(
|
||||||
|
stickerPackPath,
|
||||||
|
sticker.hashes.values.first,
|
||||||
|
);
|
||||||
|
final downloadStatusCode = await downloadFile(
|
||||||
|
Uri.parse(sticker.urlSources.first),
|
||||||
|
stickerPath,
|
||||||
|
(_, __) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRequestOkay(downloadStatusCode)) {
|
||||||
|
_log.severe('Request not okay: $downloadStatusCode');
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
stickers[i] = sticker.copyWith(
|
||||||
|
path: stickerPath,
|
||||||
|
hashKey: getStickerHashKey(sticker.hashes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
_log.severe('Import failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the sticker pack to the database
|
||||||
|
final db = GetIt.I.get<DatabaseService>();
|
||||||
|
await db.addStickerPackFromData(remotePack);
|
||||||
|
|
||||||
|
// Add the stickers to the database
|
||||||
|
final stickersDb = List<Sticker>.empty(growable: true);
|
||||||
|
for (final sticker in stickers) {
|
||||||
|
stickersDb.add(
|
||||||
|
await db.addStickerFromData(
|
||||||
|
sticker.mediaType,
|
||||||
|
sticker.desc,
|
||||||
|
sticker.size,
|
||||||
|
sticker.width,
|
||||||
|
sticker.height,
|
||||||
|
sticker.hashes,
|
||||||
|
sticker.urlSources,
|
||||||
|
sticker.path,
|
||||||
|
remotePack.hashValue,
|
||||||
|
sticker.suggests,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish but don't block
|
||||||
|
unawaited(
|
||||||
|
_publishStickerPack(remotePack.toMoxxmpp()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return remotePack.copyWith(
|
||||||
|
stickers: stickersDb,
|
||||||
|
local: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Imports a sticker pack from [path].
|
||||||
|
/// The format is as follows:
|
||||||
|
/// - The file MUST be an uncompressed tar archive
|
||||||
|
/// - All files must be at the top level of the archive
|
||||||
|
/// - A file 'urn.xmpp.stickers.0.xml' must exist and must contain only the <pack /> element
|
||||||
|
/// - The File Metadata Elements must also contain a <name /> element
|
||||||
|
/// - The file referenced by the <name/> element must also exist on the archive's top level
|
||||||
|
Future<StickerPack?> importFromFile(String path) async {
|
||||||
|
final archiveBytes = await File(path).readAsBytes();
|
||||||
|
final archive = TarDecoder().decodeBytes(archiveBytes);
|
||||||
|
final metadata = archive.findFile('urn.xmpp.stickers.0.xml');
|
||||||
|
if (metadata == null) {
|
||||||
|
_log.severe('Invalid sticker pack: No metadata file');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final content = utf8.decode(metadata.content as List<int>);
|
||||||
|
final node = moxxmpp.XMLNode.fromString(content);
|
||||||
|
final packRaw = moxxmpp.StickerPack.fromXML(
|
||||||
|
'',
|
||||||
|
node,
|
||||||
|
hashAvailable: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (packRaw.restricted) {
|
||||||
|
_log.severe('Invalid sticker pack: Restricted');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final sticker in packRaw.stickers) {
|
||||||
|
final filename = sticker.metadata.name;
|
||||||
|
if (filename == null) {
|
||||||
|
_log.severe('Invalid sticker pack: One sticker has no <name/>');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stickerFile = archive.findFile(filename);
|
||||||
|
if (stickerFile == null) {
|
||||||
|
_log.severe('Invalid sticker pack: $filename does not exist in archive');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final pack = packRaw.copyWithId(
|
||||||
|
moxxmpp.HashFunction.sha256,
|
||||||
|
await packRaw.getHash(moxxmpp.HashFunction.sha256),
|
||||||
|
);
|
||||||
|
_log.finest('New sticker pack identifier: sha256:${pack.id}');
|
||||||
|
|
||||||
|
if (await getStickerPackById(pack.id) != null) {
|
||||||
|
_log.severe('Invalid sticker pack: Already exists');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final stickerDirPath = await getStickerPackPath(
|
||||||
|
pack.hashAlgorithm.toName(),
|
||||||
|
pack.hashValue,
|
||||||
|
);
|
||||||
|
final stickerDir = Directory(stickerDirPath);
|
||||||
|
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
|
||||||
|
|
||||||
|
final db = GetIt.I.get<DatabaseService>();
|
||||||
|
|
||||||
|
// Create the sticker pack first
|
||||||
|
final stickerPack = StickerPack(
|
||||||
|
pack.hashValue,
|
||||||
|
pack.name,
|
||||||
|
pack.summary,
|
||||||
|
[],
|
||||||
|
pack.hashAlgorithm.toName(),
|
||||||
|
pack.hashValue,
|
||||||
|
pack.restricted,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await db.addStickerPackFromData(stickerPack);
|
||||||
|
|
||||||
|
// Add all stickers
|
||||||
|
final stickers = List<Sticker>.empty(growable: true);
|
||||||
|
for (final sticker in pack.stickers) {
|
||||||
|
final filename = sticker.metadata.name!;
|
||||||
|
final stickerFile = archive.findFile(filename)!;
|
||||||
|
final stickerPath = p.join(stickerDirPath, filename);
|
||||||
|
await File(stickerPath).writeAsBytes(
|
||||||
|
stickerFile.content as List<int>,
|
||||||
|
);
|
||||||
|
|
||||||
|
stickers.add(
|
||||||
|
await db.addStickerFromData(
|
||||||
|
sticker.metadata.mediaType!,
|
||||||
|
sticker.metadata.desc!,
|
||||||
|
sticker.metadata.size!,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sticker.metadata.hashes,
|
||||||
|
sticker.sources
|
||||||
|
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||||
|
.map((moxxmpp.StatelessFileSharingUrlSource source) => source.url)
|
||||||
|
.toList(),
|
||||||
|
stickerPath,
|
||||||
|
pack.hashValue,
|
||||||
|
sticker.suggests,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final stickerPackWithStickers = stickerPack.copyWith(
|
||||||
|
stickers: stickers,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add it to the cache
|
||||||
|
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
|
||||||
|
|
||||||
|
_log.info('Sticker pack ${stickerPack.id} successfully added to the database');
|
||||||
|
|
||||||
|
// Publish but don't block
|
||||||
|
unawaited(_publishStickerPack(pack));
|
||||||
|
return stickerPackWithStickers;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,8 +3,6 @@ import 'dart:io';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:image_size_getter/file_input.dart';
|
|
||||||
import 'package:image_size_getter/image_size_getter.dart' as image_size;
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
import 'package:moxlib/moxlib.dart';
|
||||||
@@ -15,6 +13,7 @@ import 'package:moxxyv2/service/avatars.dart';
|
|||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||||
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/helpers.dart';
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
@@ -29,12 +28,15 @@ import 'package:moxxyv2/service/preferences.dart';
|
|||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/state.dart';
|
import 'package:moxxyv2/service/state.dart';
|
||||||
|
import 'package:moxxyv2/service/stickers.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker.dart' as sticker;
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
|
||||||
@@ -53,7 +55,6 @@ class XmppService {
|
|||||||
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
|
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
|
||||||
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
|
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
|
||||||
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
|
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
|
||||||
EventTypeMatcher<RosterPushEvent>(_onRosterPush),
|
|
||||||
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
|
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
|
||||||
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
|
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
|
||||||
EventTypeMatcher<MessageEvent>(_onMessage),
|
EventTypeMatcher<MessageEvent>(_onMessage),
|
||||||
@@ -127,6 +128,49 @@ class XmppService {
|
|||||||
/// Returns the JID of the chat that is currently opened. Null, if none is open.
|
/// Returns the JID of the chat that is currently opened. Null, if none is open.
|
||||||
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
|
String? getCurrentlyOpenedChatJid() => _currentlyOpenedChatJid;
|
||||||
|
|
||||||
|
/// Sends a message correction to [recipient] regarding the message with stanza id
|
||||||
|
/// [oldId]. The old message's body gets corrected to [newBody]. [id] is the message's
|
||||||
|
/// database id. [chatState] can be optionally specified to also include a chat state
|
||||||
|
/// in the message.
|
||||||
|
///
|
||||||
|
/// This function handles updating the message and optionally the corresponding
|
||||||
|
/// conversation.
|
||||||
|
Future<void> sendMessageCorrection(int id, String newBody, String oldId, String recipient, ChatState? chatState) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
// Update the database
|
||||||
|
final msg = await ms.updateMessage(
|
||||||
|
id,
|
||||||
|
isEdited: true,
|
||||||
|
body: newBody,
|
||||||
|
);
|
||||||
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
|
final conv = await cs.getConversationByJid(msg.conversationJid);
|
||||||
|
if (conv != null && conv.lastMessage?.id == id) {
|
||||||
|
final newConv = await cs.updateConversation(
|
||||||
|
conv.id,
|
||||||
|
lastChangeTimestamp: timestamp,
|
||||||
|
lastMessage: msg,
|
||||||
|
);
|
||||||
|
cs.setConversation(newConv);
|
||||||
|
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the correction
|
||||||
|
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||||
|
MessageDetails(
|
||||||
|
to: recipient,
|
||||||
|
body: newBody,
|
||||||
|
lastMessageCorrectionId: oldId,
|
||||||
|
chatState: chatState,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Sends a message to JIDs in [recipients] with the body of [body].
|
/// Sends a message to JIDs in [recipients] with the body of [body].
|
||||||
Future<void> sendMessage({
|
Future<void> sendMessage({
|
||||||
required String body,
|
required String body,
|
||||||
@@ -134,6 +178,7 @@ class XmppService {
|
|||||||
Message? quotedMessage,
|
Message? quotedMessage,
|
||||||
String? commandId,
|
String? commandId,
|
||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
|
sticker.Sticker? sticker,
|
||||||
}) async {
|
}) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
@@ -149,12 +194,18 @@ class XmppService {
|
|||||||
timestamp,
|
timestamp,
|
||||||
conn.getConnectionSettings().jid.toString(),
|
conn.getConnectionSettings().jid.toString(),
|
||||||
recipient,
|
recipient,
|
||||||
false,
|
sticker != null,
|
||||||
sid,
|
sid,
|
||||||
false,
|
false,
|
||||||
conversation!.encrypted,
|
conversation!.encrypted,
|
||||||
|
// TODO(Unknown): Maybe make this depend on some setting
|
||||||
|
false,
|
||||||
originId: originId,
|
originId: originId,
|
||||||
quoteId: quotedMessage?.sid,
|
quoteId: quotedMessage?.sid,
|
||||||
|
stickerPackId: sticker?.stickerPackId,
|
||||||
|
stickerHashKey: sticker?.hashKey,
|
||||||
|
srcUrl: sticker?.urlSources.first,
|
||||||
|
mediaType: sticker?.mediaType,
|
||||||
);
|
);
|
||||||
final newConversation = await cs.updateConversation(
|
final newConversation = await cs.updateConversation(
|
||||||
conversation.id,
|
conversation.id,
|
||||||
@@ -180,6 +231,27 @@ class XmppService {
|
|||||||
quoteId: quotedMessage?.sid,
|
quoteId: quotedMessage?.sid,
|
||||||
chatState: chatState,
|
chatState: chatState,
|
||||||
shouldEncrypt: newConversation.encrypted,
|
shouldEncrypt: newConversation.encrypted,
|
||||||
|
stickerPackId: sticker?.stickerPackId,
|
||||||
|
sfs: sticker == null ?
|
||||||
|
null :
|
||||||
|
StatelessFileSharingData(
|
||||||
|
FileMetadataData(
|
||||||
|
mediaType: sticker.mediaType,
|
||||||
|
width: sticker.width,
|
||||||
|
height: sticker.height,
|
||||||
|
desc: sticker.desc,
|
||||||
|
size: sticker.size,
|
||||||
|
thumbnails: [],
|
||||||
|
hashes: sticker.hashes,
|
||||||
|
),
|
||||||
|
sticker.urlSources
|
||||||
|
// ignore: unnecessary_lambdas
|
||||||
|
.map((s) => StatelessFileSharingUrlSource(s))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
setOOBFallbackBody: sticker != null ?
|
||||||
|
false :
|
||||||
|
true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -351,6 +423,7 @@ class XmppService {
|
|||||||
// Create a new message
|
// Create a new message
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||||
|
|
||||||
// Path -> Recipient -> Message
|
// Path -> Recipient -> Message
|
||||||
@@ -375,13 +448,13 @@ class XmppService {
|
|||||||
|
|
||||||
// TODO(Unknown): Do the same for videos
|
// TODO(Unknown): Do the same for videos
|
||||||
if (pathMime != null && pathMime.startsWith('image/')) {
|
if (pathMime != null && pathMime.startsWith('image/')) {
|
||||||
try {
|
final imageSize = await getImageSizeFromPath(path);
|
||||||
final imageSize = image_size.ImageSizeGetter.getSize(FileInput(File(path)));
|
if (imageSize != null) {
|
||||||
dimensions[path] = Size(
|
dimensions[path] = Size(
|
||||||
imageSize.width.toDouble(),
|
imageSize.width,
|
||||||
imageSize.height.toDouble(),
|
imageSize.height,
|
||||||
);
|
);
|
||||||
} catch (ex) {
|
} else {
|
||||||
_log.warning('Failed to get image dimensions for $path');
|
_log.warning('Failed to get image dimensions for $path');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,6 +468,8 @@ class XmppService {
|
|||||||
conn.generateId(),
|
conn.generateId(),
|
||||||
false,
|
false,
|
||||||
encrypt[recipient]!,
|
encrypt[recipient]!,
|
||||||
|
// TODO(Unknown): Maybe make this depend on some setting
|
||||||
|
false,
|
||||||
mediaUrl: path,
|
mediaUrl: path,
|
||||||
mediaType: pathMime,
|
mediaType: pathMime,
|
||||||
originId: conn.generateId(),
|
originId: conn.generateId(),
|
||||||
@@ -445,6 +520,7 @@ class XmppService {
|
|||||||
} else {
|
} else {
|
||||||
// Create conversation
|
// Create conversation
|
||||||
final rosterItem = await rs.getRosterItemByJid(recipient);
|
final rosterItem = await rs.getRosterItemByJid(recipient);
|
||||||
|
final contactId = await css.getContactIdForJid(recipient);
|
||||||
var newConversation = await cs.addConversationFromData(
|
var newConversation = await cs.addConversationFromData(
|
||||||
// TODO(Unknown): Should we use the JID parser?
|
// TODO(Unknown): Should we use the JID parser?
|
||||||
rosterItem?.title ?? recipient.split('@').first,
|
rosterItem?.title ?? recipient.split('@').first,
|
||||||
@@ -456,6 +532,9 @@ class XmppService {
|
|||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
prefs.enableOmemoByDefault,
|
prefs.enableOmemoByDefault,
|
||||||
|
contactId,
|
||||||
|
await css.getProfilePicturePathForJid(recipient),
|
||||||
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
|
|
||||||
sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, newConversation.id);
|
sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, newConversation.id);
|
||||||
@@ -585,9 +664,25 @@ class XmppService {
|
|||||||
unawaited(_initializeOmemoService(settings.jid.toString()));
|
unawaited(_initializeOmemoService(settings.jid.toString()));
|
||||||
|
|
||||||
if (!event.resumed) {
|
if (!event.resumed) {
|
||||||
|
// Reset the blocking service's cache
|
||||||
|
GetIt.I.get<BlocklistService>().onNewConnection();
|
||||||
|
|
||||||
|
// Reset the OMEMO cache
|
||||||
|
GetIt.I.get<OmemoService>().onNewConnection();
|
||||||
|
|
||||||
|
// Enable carbons
|
||||||
|
final carbonsResult = await connection
|
||||||
|
.getManagerById<CarbonsManager>(carbonsManager)!
|
||||||
|
.enableCarbons();
|
||||||
|
if (!carbonsResult) {
|
||||||
|
_log.warning('Failed to enable carbons');
|
||||||
|
}
|
||||||
|
|
||||||
// In section 5 of XEP-0198 it says that a client should not request the roster
|
// In section 5 of XEP-0198 it says that a client should not request the roster
|
||||||
// in case of a stream resumption.
|
// in case of a stream resumption.
|
||||||
await GetIt.I.get<RosterService>().requestRoster();
|
await connection
|
||||||
|
.getManagerById<RosterManager>(rosterManager)!
|
||||||
|
.requestRoster();
|
||||||
|
|
||||||
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
|
// TODO(Unknown): Once groupchats come into the equation, this gets trickier
|
||||||
final roster = await GetIt.I.get<RosterService>().getRoster();
|
final roster = await GetIt.I.get<RosterService>().getRoster();
|
||||||
@@ -632,29 +727,27 @@ class XmppService {
|
|||||||
|
|
||||||
if (!prefs.showSubscriptionRequests) return;
|
if (!prefs.showSubscriptionRequests) return;
|
||||||
|
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final conversation = await cs.getConversationByJid(event.from.toBare().toString());
|
final conversation = await cs.getConversationByJid(event.from.toBare().toString());
|
||||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
if (conversation != null) {
|
if (conversation != null && !conversation.open) {
|
||||||
final newConversation = await cs.updateConversation(
|
|
||||||
conversation.id,
|
|
||||||
open: true,
|
|
||||||
lastChangeTimestamp: timestamp,
|
|
||||||
);
|
|
||||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
|
||||||
} else {
|
|
||||||
// TODO(Unknown): Make it configurable if this should happen
|
// TODO(Unknown): Make it configurable if this should happen
|
||||||
final bare = event.from.toBare();
|
final bare = event.from.toBare().toString();
|
||||||
|
final contactId = await css.getContactIdForJid(bare);
|
||||||
final conv = await cs.addConversationFromData(
|
final conv = await cs.addConversationFromData(
|
||||||
bare.toString().split('@')[0],
|
bare.split('@')[0],
|
||||||
null,
|
null,
|
||||||
'', // TODO(Unknown): avatarUrl
|
'', // TODO(Unknown): avatarUrl
|
||||||
bare.toString(),
|
bare,
|
||||||
0,
|
0,
|
||||||
timestamp,
|
timestamp,
|
||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
prefs.enableOmemoByDefault,
|
prefs.enableOmemoByDefault,
|
||||||
|
contactId,
|
||||||
|
await css.getProfilePicturePathForJid(bare),
|
||||||
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
|
|
||||||
sendEvent(ConversationAddedEvent(conversation: conv));
|
sendEvent(ConversationAddedEvent(conversation: conv));
|
||||||
@@ -788,7 +881,8 @@ class XmppService {
|
|||||||
// that the message body and the OOB url are the same if the OOB url is not null.
|
// that the message body and the OOB url are the same if the OOB url is not null.
|
||||||
return embeddedFile != null
|
return embeddedFile != null
|
||||||
&& Uri.parse(embeddedFile.url).scheme == 'https'
|
&& Uri.parse(embeddedFile.url).scheme == 'https'
|
||||||
&& implies(event.oob != null, event.body == event.oob?.url);
|
&& implies(event.oob != null, event.body == event.oob?.url)
|
||||||
|
&& event.stickerPackId == null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a message retraction given the MessageEvent [event].
|
/// Handle a message retraction given the MessageEvent [event].
|
||||||
@@ -854,6 +948,144 @@ class XmppService {
|
|||||||
sendEvent(MessageUpdatedEvent(message: newMsg));
|
sendEvent(MessageUpdatedEvent(message: newMsg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handleMessageCorrection(MessageEvent event, String conversationJid) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
final msg = await ms.getMessageByStanzaId(conversationJid, event.messageCorrectionId!);
|
||||||
|
if (msg == null) {
|
||||||
|
_log.warning('Received message correction for message ${event.messageCorrectionId} we cannot find.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the Jid is allowed to do correct the message
|
||||||
|
// TODO(Unknown): Maybe use the JID parser?
|
||||||
|
final bareSender = event.fromJid.toBare().toString();
|
||||||
|
if (msg.sender.split('/').first != bareSender) {
|
||||||
|
_log.warning('Received a message correction from $bareSender for message that is not sent by $bareSender');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the message can be corrected
|
||||||
|
if (!msg.canEdit(true)) {
|
||||||
|
_log.warning('Received a message correction for a message that cannot be edited');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newMsg = await ms.updateMessage(
|
||||||
|
msg.id,
|
||||||
|
body: event.body,
|
||||||
|
isEdited: true,
|
||||||
|
);
|
||||||
|
sendEvent(MessageUpdatedEvent(message: newMsg));
|
||||||
|
|
||||||
|
final conv = await cs.getConversationByJid(msg.conversationJid);
|
||||||
|
if (conv != null && conv.lastMessage?.id == msg.id) {
|
||||||
|
final newConv = conv.copyWith(
|
||||||
|
lastMessage: newMsg,
|
||||||
|
);
|
||||||
|
cs.setConversation(newConv);
|
||||||
|
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleMessageReactions(MessageEvent event, String conversationJid) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
// TODO(Unknown): Once we support groupchats, we need to instead query by the stanza-id
|
||||||
|
final msg = await ms.getMessageByStanzaOrOriginId(
|
||||||
|
conversationJid,
|
||||||
|
event.messageReactions!.messageId,
|
||||||
|
);
|
||||||
|
if (msg == null) {
|
||||||
|
_log.warning('Received reactions for ${event.messageReactions!.messageId} from ${event.fromJid} for $conversationJid, but could not find message.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final state = await getXmppState();
|
||||||
|
final sender = event.fromJid.toBare().toString();
|
||||||
|
final isCarbon = sender == state.jid;
|
||||||
|
final reactions = List<Reaction>.from(msg.reactions);
|
||||||
|
final emojis = event.messageReactions!.emojis;
|
||||||
|
|
||||||
|
// Find out what emojis the sender has already sent
|
||||||
|
final sentEmojis = msg.reactions
|
||||||
|
.where((r) {
|
||||||
|
return isCarbon ?
|
||||||
|
r.reactedBySelf :
|
||||||
|
r.senders.contains(sender);
|
||||||
|
})
|
||||||
|
.map((r) => r.emoji)
|
||||||
|
.toList();
|
||||||
|
// Find out what reactions were removed
|
||||||
|
final removedEmojis = sentEmojis
|
||||||
|
.where((e) => !emojis.contains(e));
|
||||||
|
|
||||||
|
for (final emoji in emojis) {
|
||||||
|
final i = reactions.indexWhere((r) => r.emoji == emoji);
|
||||||
|
if (i == -1) {
|
||||||
|
reactions.add(
|
||||||
|
Reaction(
|
||||||
|
isCarbon ?
|
||||||
|
[] :
|
||||||
|
[sender],
|
||||||
|
emoji,
|
||||||
|
isCarbon,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
List<String> senders;
|
||||||
|
if (isCarbon) {
|
||||||
|
senders = reactions[i].senders;
|
||||||
|
} else {
|
||||||
|
// Ensure that we don't add a sender multiple times to the same reaction
|
||||||
|
if (reactions[i].senders.contains(sender)) {
|
||||||
|
senders = reactions[i].senders;
|
||||||
|
} else {
|
||||||
|
senders = [
|
||||||
|
...reactions[i].senders,
|
||||||
|
sender,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reactions[i] = reactions[i].copyWith(
|
||||||
|
senders: senders,
|
||||||
|
reactedBySelf: isCarbon ?
|
||||||
|
true :
|
||||||
|
reactions[i].reactedBySelf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final emoji in removedEmojis) {
|
||||||
|
final i = reactions.indexWhere((r) => r.emoji == emoji);
|
||||||
|
assert(i >= -1, 'The reaction must exist');
|
||||||
|
|
||||||
|
if (isCarbon && reactions[i].senders.isEmpty ||
|
||||||
|
!isCarbon && reactions[i].senders.length == 1 && !reactions[i].reactedBySelf) {
|
||||||
|
reactions.removeAt(i);
|
||||||
|
} else {
|
||||||
|
reactions[i] = reactions[i].copyWith(
|
||||||
|
senders: isCarbon ?
|
||||||
|
reactions[i].senders :
|
||||||
|
reactions[i].senders
|
||||||
|
.where((s) => s != sender)
|
||||||
|
.toList(),
|
||||||
|
reactedBySelf: isCarbon ?
|
||||||
|
false :
|
||||||
|
reactions[i].reactedBySelf,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final newMessage = await ms.updateMessage(
|
||||||
|
msg.id,
|
||||||
|
reactions: reactions,
|
||||||
|
);
|
||||||
|
sendEvent(
|
||||||
|
MessageUpdatedEvent(message: newMessage),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onMessage(MessageEvent event, { dynamic extra }) async {
|
Future<void> _onMessage(MessageEvent event, { dynamic extra }) async {
|
||||||
// The jid this message event is meant for
|
// The jid this message event is meant for
|
||||||
final conversationJid = event.isCarbon
|
final conversationJid = event.isCarbon
|
||||||
@@ -869,6 +1101,12 @@ class XmppService {
|
|||||||
// Process the chat state update. Can also be attached to other messages
|
// Process the chat state update. Can also be attached to other messages
|
||||||
if (event.chatState != null) await _onChatState(event.chatState!, conversationJid);
|
if (event.chatState != null) await _onChatState(event.chatState!, conversationJid);
|
||||||
|
|
||||||
|
// Process message corrections separately
|
||||||
|
if (event.messageCorrectionId != null) {
|
||||||
|
await _handleMessageCorrection(event, conversationJid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Process File Upload Notifications replacements separately
|
// Process File Upload Notifications replacements separately
|
||||||
if (event.funReplacement != null) {
|
if (event.funReplacement != null) {
|
||||||
await _handleFileUploadNotificationReplacement(event, conversationJid);
|
await _handleFileUploadNotificationReplacement(event, conversationJid);
|
||||||
@@ -880,6 +1118,12 @@ class XmppService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle message reactions
|
||||||
|
if (event.messageReactions != null) {
|
||||||
|
await _handleMessageReactions(event, conversationJid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Stop the processing here if the event does not describe a displayable message
|
// Stop the processing here if the event does not describe a displayable message
|
||||||
if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;
|
if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;
|
||||||
|
|
||||||
@@ -903,16 +1147,13 @@ class XmppService {
|
|||||||
// Pre-process the message in case it is a reply to another message
|
// Pre-process the message in case it is a reply to another message
|
||||||
String? replyId;
|
String? replyId;
|
||||||
var messageBody = event.body;
|
var messageBody = event.body;
|
||||||
// TODO(Unknown): Implement
|
if (event.reply != null) {
|
||||||
if (event.reply != null /* && check if event.reply.to is okay */) {
|
|
||||||
replyId = event.reply!.id;
|
replyId = event.reply!.id;
|
||||||
|
|
||||||
// Strip the compatibility fallback, if specified
|
// Strip the compatibility fallback, if specified
|
||||||
if (event.reply!.start != null && event.reply!.end != null) {
|
messageBody = event.reply!.removeFallback(messageBody);
|
||||||
messageBody = messageBody.replaceRange(event.reply!.start!, event.reply!.end, '');
|
|
||||||
_log.finest('Removed message reply compatibility fallback from message');
|
_log.finest('Removed message reply compatibility fallback from message');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// The Url of the file embedded in the message, if there is one.
|
// The Url of the file embedded in the message, if there is one.
|
||||||
final embeddedFile = _getMessageSrcUrl(event);
|
final embeddedFile = _getMessageSrcUrl(event);
|
||||||
@@ -930,6 +1171,33 @@ class XmppService {
|
|||||||
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
|
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
|
||||||
// A guess for the Mime type of the embedded file.
|
// A guess for the Mime type of the embedded file.
|
||||||
var mimeGuess = _getMimeGuess(event);
|
var mimeGuess = _getMimeGuess(event);
|
||||||
|
// Guess a sticker hash key, if the message is a sticker
|
||||||
|
final stickerHashKey = event.stickerPackId != null ?
|
||||||
|
getStickerHashKey(event.sfs!.metadata.hashes) :
|
||||||
|
null;
|
||||||
|
// The potential sticker pack
|
||||||
|
final stickerPack = event.stickerPackId != null ?
|
||||||
|
await GetIt.I.get<StickersService>().getStickerPackById(
|
||||||
|
event.stickerPackId!,
|
||||||
|
) :
|
||||||
|
null;
|
||||||
|
|
||||||
|
// Automatically download the sticker pack, if
|
||||||
|
// - a sticker was received,
|
||||||
|
// - the sender is in the roster,
|
||||||
|
// - we don't have the sticker pack locally,
|
||||||
|
// - and it is enabled in the settings
|
||||||
|
if (event.stickerPackId != null &&
|
||||||
|
stickerPack == null &&
|
||||||
|
prefs.autoDownloadStickersFromContacts &&
|
||||||
|
isInRoster) {
|
||||||
|
unawaited(
|
||||||
|
GetIt.I.get<StickersService>().importFromPubSubWithEvent(
|
||||||
|
event.fromJid,
|
||||||
|
event.stickerPackId!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create the message in the database
|
// Create the message in the database
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
@@ -939,10 +1207,11 @@ class XmppService {
|
|||||||
messageTimestamp,
|
messageTimestamp,
|
||||||
event.fromJid.toString(),
|
event.fromJid.toString(),
|
||||||
conversationJid,
|
conversationJid,
|
||||||
isFileEmbedded || event.fun != null,
|
isFileEmbedded || event.fun != null || event.stickerPackId != null,
|
||||||
event.sid,
|
event.sid,
|
||||||
event.fun != null,
|
event.fun != null,
|
||||||
event.encrypted,
|
event.encrypted,
|
||||||
|
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ?? false,
|
||||||
srcUrl: embeddedFile?.url,
|
srcUrl: embeddedFile?.url,
|
||||||
filename: event.fun?.name ?? embeddedFile?.filename,
|
filename: event.fun?.name ?? embeddedFile?.filename,
|
||||||
key: embeddedFile?.keyBase64,
|
key: embeddedFile?.keyBase64,
|
||||||
@@ -955,6 +1224,9 @@ class XmppService {
|
|||||||
quoteId: replyId,
|
quoteId: replyId,
|
||||||
originId: event.stanzaId.originId,
|
originId: event.stanzaId.originId,
|
||||||
errorType: errorTypeFromException(event.other['encryption_error']),
|
errorType: errorTypeFromException(event.other['encryption_error']),
|
||||||
|
plaintextHashes: event.sfs?.metadata.hashes,
|
||||||
|
stickerPackId: event.stickerPackId,
|
||||||
|
stickerHashKey: stickerHashKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Attempt to auto-download the embedded file
|
// Attempt to auto-download the embedded file
|
||||||
@@ -962,6 +1234,7 @@ class XmppService {
|
|||||||
final fts = GetIt.I.get<HttpFileTransferService>();
|
final fts = GetIt.I.get<HttpFileTransferService>();
|
||||||
final metadata = await peekFile(embeddedFile!.url);
|
final metadata = await peekFile(embeddedFile!.url);
|
||||||
|
|
||||||
|
_log.finest('Advertised file MIME: ${metadata.mime}');
|
||||||
if (metadata.mime != null) mimeGuess = metadata.mime;
|
if (metadata.mime != null) mimeGuess = metadata.mime;
|
||||||
|
|
||||||
// Auto-download only if the file is below the set limit, if the limit is not set to
|
// Auto-download only if the file is below the set limit, if the limit is not set to
|
||||||
@@ -987,6 +1260,7 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
final ns = GetIt.I.get<NotificationsService>();
|
final ns = GetIt.I.get<NotificationsService>();
|
||||||
// The body to be displayed in the conversations list
|
// The body to be displayed in the conversations list
|
||||||
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
|
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
|
||||||
@@ -1026,6 +1300,7 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// The conversation does not exist, so we must create it
|
// The conversation does not exist, so we must create it
|
||||||
|
final contactId = await css.getContactIdForJid(conversationJid);
|
||||||
final newConversation = await cs.addConversationFromData(
|
final newConversation = await cs.addConversationFromData(
|
||||||
rosterItem?.title ?? conversationJid.split('@')[0],
|
rosterItem?.title ?? conversationJid.split('@')[0],
|
||||||
message,
|
message,
|
||||||
@@ -1036,6 +1311,9 @@ class XmppService {
|
|||||||
true,
|
true,
|
||||||
prefs.defaultMuteState,
|
prefs.defaultMuteState,
|
||||||
message.encrypted,
|
message.encrypted,
|
||||||
|
contactId,
|
||||||
|
await css.getProfilePicturePathForJid(conversationJid),
|
||||||
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notify the UI
|
// Notify the UI
|
||||||
@@ -1052,13 +1330,15 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify the UI of the message
|
// Mark the file as downlading when it includes a File Upload Notification
|
||||||
if (message.isDownloading != (event.fun != null)) {
|
if (event.fun != null) {
|
||||||
message = await ms.updateMessage(
|
message = await ms.updateMessage(
|
||||||
message.id,
|
message.id,
|
||||||
isDownloading: event.fun != null,
|
isDownloading: true,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify the UI of the message
|
||||||
sendEvent(MessageAddedEvent(message: message));
|
sendEvent(MessageAddedEvent(message: message));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,12 +1386,13 @@ class XmppService {
|
|||||||
sendEvent(MessageUpdatedEvent(message: message));
|
sendEvent(MessageUpdatedEvent(message: message));
|
||||||
|
|
||||||
if (shouldDownload) {
|
if (shouldDownload) {
|
||||||
|
_log.finest('Advertised file MIME: ${_getMimeGuess(event)}');
|
||||||
await GetIt.I.get<HttpFileTransferService>().downloadFile(
|
await GetIt.I.get<HttpFileTransferService>().downloadFile(
|
||||||
FileDownloadJob(
|
FileDownloadJob(
|
||||||
embeddedFile,
|
embeddedFile,
|
||||||
message.id,
|
message.id,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
null,
|
_getMimeGuess(event),
|
||||||
shouldShowNotification: false,
|
shouldShowNotification: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1121,17 +1402,8 @@ class XmppService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRosterPush(RosterPushEvent event, { dynamic extra }) async {
|
|
||||||
_log.fine("Roster push version: ${event.ver ?? "(null)"}");
|
|
||||||
await GetIt.I.get<RosterService>().handleRosterPushEvent(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
|
Future<void> _onAvatarUpdated(AvatarUpdatedEvent event, { dynamic extra }) async {
|
||||||
await GetIt.I.get<AvatarService>().updateAvatarForJid(
|
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
|
||||||
event.jid,
|
|
||||||
event.hash,
|
|
||||||
event.base64,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {
|
Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ import 'package:moxlib/awaitabledatasender.dart';
|
|||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||||
|
|
||||||
part 'commands.moxxy.dart';
|
part 'commands.moxxy.dart';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:moxxyv2/shared/models/message.dart';
|
|||||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||||
|
|
||||||
part 'events.moxxy.dart';
|
part 'events.moxxy.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import 'dart:core';
|
import 'dart:core';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
import 'package:video_thumbnail/video_thumbnail.dart';
|
||||||
|
|
||||||
/// Add a leading zero, if required, to ensure that an integer is rendered
|
/// Add a leading zero, if required, to ensure that an integer is rendered
|
||||||
/// as a two "digit" string.
|
/// as a two "digit" string.
|
||||||
@@ -34,7 +40,7 @@ String formatConversationTimestamp(int timestamp, int now) {
|
|||||||
return '${hourDifference}h';
|
return '${hourDifference}h';
|
||||||
}
|
}
|
||||||
} else if (difference <= Duration.millisecondsPerMinute) {
|
} else if (difference <= Duration.millisecondsPerMinute) {
|
||||||
return 'Just now';
|
return t.dateTime.justNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
return '${(difference / Duration.millisecondsPerMinute).floor()}min';
|
return '${(difference / Duration.millisecondsPerMinute).floor()}min';
|
||||||
@@ -52,9 +58,10 @@ String formatMessageTimestamp(int timestamp, int now) {
|
|||||||
return '${dt.hour}:${padInt(dt.minute)}';
|
return '${dt.hour}:${padInt(dt.minute)}';
|
||||||
} else {
|
} else {
|
||||||
if (difference < Duration.millisecondsPerMinute) {
|
if (difference < Duration.millisecondsPerMinute) {
|
||||||
return 'Just now';
|
return t.dateTime.justNow;
|
||||||
} else {
|
} else {
|
||||||
return '${(difference / Duration.millisecondsPerMinute).floor()}min ago';
|
final diff = (difference / Duration.millisecondsPerMinute).floor();
|
||||||
|
return t.dateTime.nMinutesAgo(min: diff);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,19 +70,19 @@ String formatMessageTimestamp(int timestamp, int now) {
|
|||||||
String weekdayToStringAbbrev(int day) {
|
String weekdayToStringAbbrev(int day) {
|
||||||
switch (day) {
|
switch (day) {
|
||||||
case DateTime.monday:
|
case DateTime.monday:
|
||||||
return 'Mon';
|
return t.dateTime.mondayAbbrev;
|
||||||
case DateTime.tuesday:
|
case DateTime.tuesday:
|
||||||
return 'Tue';
|
return t.dateTime.tuesdayAbbrev;
|
||||||
case DateTime.wednesday:
|
case DateTime.wednesday:
|
||||||
return 'Wed';
|
return t.dateTime.wednessdayAbbrev;
|
||||||
case DateTime.thursday:
|
case DateTime.thursday:
|
||||||
return 'Thu';
|
return t.dateTime.thursdayAbbrev;
|
||||||
case DateTime.friday:
|
case DateTime.friday:
|
||||||
return 'Fri';
|
return t.dateTime.fridayAbbrev;
|
||||||
case DateTime.saturday:
|
case DateTime.saturday:
|
||||||
return 'Sat';
|
return t.dateTime.saturdayAbbrev;
|
||||||
case DateTime.sunday:
|
case DateTime.sunday:
|
||||||
return 'Sun';
|
return t.dateTime.sundayAbbrev;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not happen
|
// Should not happen
|
||||||
@@ -86,29 +93,29 @@ String weekdayToStringAbbrev(int day) {
|
|||||||
String monthToString(int month) {
|
String monthToString(int month) {
|
||||||
switch (month) {
|
switch (month) {
|
||||||
case DateTime.january:
|
case DateTime.january:
|
||||||
return 'January';
|
return t.dateTime.january;
|
||||||
case DateTime.february:
|
case DateTime.february:
|
||||||
return 'February';
|
return t.dateTime.february;
|
||||||
case DateTime.march:
|
case DateTime.march:
|
||||||
return 'March';
|
return t.dateTime.march;
|
||||||
case DateTime.april:
|
case DateTime.april:
|
||||||
return 'April';
|
return t.dateTime.april;
|
||||||
case DateTime.may:
|
case DateTime.may:
|
||||||
return 'May';
|
return t.dateTime.may;
|
||||||
case DateTime.june:
|
case DateTime.june:
|
||||||
return 'June';
|
return t.dateTime.june;
|
||||||
case DateTime.july:
|
case DateTime.july:
|
||||||
return 'July';
|
return t.dateTime.july;
|
||||||
case DateTime.august:
|
case DateTime.august:
|
||||||
return 'August';
|
return t.dateTime.august;
|
||||||
case DateTime.september:
|
case DateTime.september:
|
||||||
return 'September';
|
return t.dateTime.september;
|
||||||
case DateTime.october:
|
case DateTime.october:
|
||||||
return 'October';
|
return t.dateTime.october;
|
||||||
case DateTime.november:
|
case DateTime.november:
|
||||||
return 'November';
|
return t.dateTime.november;
|
||||||
case DateTime.december:
|
case DateTime.december:
|
||||||
return 'December';
|
return t.dateTime.december;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should not happen
|
// Should not happen
|
||||||
@@ -119,9 +126,9 @@ String monthToString(int month) {
|
|||||||
/// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'.
|
/// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'.
|
||||||
String formatDateBubble(DateTime dt, DateTime now) {
|
String formatDateBubble(DateTime dt, DateTime now) {
|
||||||
if (dt.day == now.day && dt.month == now.month && dt.year == now.year) {
|
if (dt.day == now.day && dt.month == now.month && dt.year == now.year) {
|
||||||
return 'Today';
|
return t.dateTime.today;
|
||||||
} else if (now.subtract(const Duration(days: 1)).day == dt.day) {
|
} else if (now.subtract(const Duration(days: 1)).day == dt.day) {
|
||||||
return 'Yesterday';
|
return t.dateTime.yesterday;
|
||||||
} else if (dt.year == now.year) {
|
} else if (dt.year == now.year) {
|
||||||
return '${weekdayToStringAbbrev(dt.weekday)}, ${dt.day}. ${monthToString(dt.month)}';
|
return '${weekdayToStringAbbrev(dt.weekday)}, ${dt.day}. ${monthToString(dt.month)}';
|
||||||
} else {
|
} else {
|
||||||
@@ -317,3 +324,91 @@ String fileSizeToString(int size) {
|
|||||||
return '$size B';
|
return '$size B';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load [path] into memory and determine its width and height. Returns null in case
|
||||||
|
/// of an error.
|
||||||
|
Future<Size?> getImageSizeFromPath(String path) async {
|
||||||
|
final bytes = await File(path).readAsBytes();
|
||||||
|
return getImageSizeFromData(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like getImageSizeFromPath but taking the image's bytes directly.
|
||||||
|
Future<Size?> getImageSizeFromData(Uint8List bytes) async {
|
||||||
|
try {
|
||||||
|
final dartCodec = await instantiateImageCodec(bytes);
|
||||||
|
final dartFrame = await dartCodec.getNextFrame();
|
||||||
|
|
||||||
|
final size = Size(
|
||||||
|
dartFrame.image.width.toDouble(),
|
||||||
|
dartFrame.image.height.toDouble(),
|
||||||
|
);
|
||||||
|
|
||||||
|
dartFrame.image.dispose();
|
||||||
|
dartCodec.dispose();
|
||||||
|
|
||||||
|
return size;
|
||||||
|
} catch (_) {
|
||||||
|
// TODO(PapaTutuWawa): Log error
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a thumbnail file (JPEG) for the video at [path]. [conversationJid] refers
|
||||||
|
/// to the JID of the conversation the file comes from.
|
||||||
|
/// If the thumbnail already exists, then just its path is returned. If not, then
|
||||||
|
/// it gets generated first.
|
||||||
|
Future<String?> getVideoThumbnailPath(String path, String conversationJid, String mime) async {
|
||||||
|
//print('getVideoThumbnailPath: Mime type: $mime');
|
||||||
|
|
||||||
|
// Ignore mime types that may be wacky
|
||||||
|
if (mime == 'video/webm') return null;
|
||||||
|
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final thumbnailFilenameNoExtension = p.withoutExtension(
|
||||||
|
p.basename(path),
|
||||||
|
);
|
||||||
|
final thumbnailFilename = '$thumbnailFilenameNoExtension.jpg';
|
||||||
|
final thumbnailDirectory = p.join(
|
||||||
|
tempDir.path,
|
||||||
|
'thumbnails',
|
||||||
|
conversationJid,
|
||||||
|
);
|
||||||
|
final thumbnailPath = p.join(thumbnailDirectory, thumbnailFilename);
|
||||||
|
|
||||||
|
final dir = Directory(thumbnailDirectory);
|
||||||
|
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||||
|
final file = File(thumbnailPath);
|
||||||
|
if (file.existsSync()) return thumbnailPath;
|
||||||
|
|
||||||
|
final r = await VideoThumbnail.thumbnailFile(
|
||||||
|
video: path,
|
||||||
|
thumbnailPath: thumbnailDirectory,
|
||||||
|
imageFormat: ImageFormat.JPEG,
|
||||||
|
quality: 75,
|
||||||
|
);
|
||||||
|
assert(r == thumbnailPath, 'The generated video thumbnail has a different path than we expected: $r vs. $thumbnailPath');
|
||||||
|
|
||||||
|
return thumbnailPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getContactProfilePicturePath(String id) async {
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final avatarDir = p.join(
|
||||||
|
tempDir.path,
|
||||||
|
'contacts',
|
||||||
|
'avatars',
|
||||||
|
);
|
||||||
|
final dir = Directory(avatarDir);
|
||||||
|
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||||
|
|
||||||
|
return p.join(avatarDir, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getStickerPackPath(String hashFunction, String hashValue) async {
|
||||||
|
final appDir = await getApplicationDocumentsDirectory();
|
||||||
|
return p.join(
|
||||||
|
appDir.path,
|
||||||
|
'stickers',
|
||||||
|
'${hashFunction}_$hashValue',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/models/media.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||||
|
|
||||||
part 'conversation.freezed.dart';
|
part 'conversation.freezed.dart';
|
||||||
part 'conversation.g.dart';
|
part 'conversation.g.dart';
|
||||||
@@ -59,6 +61,14 @@ class Conversation with _$Conversation {
|
|||||||
bool encrypted,
|
bool encrypted,
|
||||||
// The current chat state
|
// The current chat state
|
||||||
@ConversationChatStateConverter() ChatState chatState,
|
@ConversationChatStateConverter() ChatState chatState,
|
||||||
|
{
|
||||||
|
// The id of the contact in the device's phonebook if it exists
|
||||||
|
String? contactId,
|
||||||
|
// The path to the contact avatar, if available
|
||||||
|
String? contactAvatarPath,
|
||||||
|
// The contact's display name, if it exists
|
||||||
|
String? contactDisplayName,
|
||||||
|
}
|
||||||
) = _Conversation;
|
) = _Conversation;
|
||||||
|
|
||||||
const Conversation._();
|
const Conversation._();
|
||||||
@@ -76,10 +86,9 @@ class Conversation with _$Conversation {
|
|||||||
'subscription': subscription,
|
'subscription': subscription,
|
||||||
'encrypted': intToBool(json['encrypted']! as int),
|
'encrypted': intToBool(json['encrypted']! as int),
|
||||||
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
|
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||||
'lastMessage': <String, dynamic>{
|
}).copyWith(
|
||||||
'message': lastMessage?.toJson(),
|
lastMessage: lastMessage,
|
||||||
},
|
);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toDatabaseJson() {
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
@@ -96,9 +105,34 @@ class Conversation with _$Conversation {
|
|||||||
'open': boolToInt(open),
|
'open': boolToInt(open),
|
||||||
'muted': boolToInt(muted),
|
'muted': boolToInt(muted),
|
||||||
'encrypted': boolToInt(encrypted),
|
'encrypted': boolToInt(encrypted),
|
||||||
'lastMessage': lastMessage?.id,
|
'lastMessageId': lastMessage?.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// XMPP avatar's path.
|
||||||
|
String? get avatarPathWithOptionalContact {
|
||||||
|
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||||
|
return contactAvatarPath ?? avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
return avatarUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// title.
|
||||||
|
String get titleWithOptionalContact {
|
||||||
|
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||||
|
return contactDisplayName ?? title;
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sorts conversations in descending order by their last change timestamp.
|
/// Sorts conversations in descending order by their last change timestamp.
|
||||||
|
|||||||
@@ -3,39 +3,55 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||||
import 'package:moxxyv2/shared/warning_types.dart';
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
|
|
||||||
part 'message.freezed.dart';
|
part 'message.freezed.dart';
|
||||||
part 'message.g.dart';
|
part 'message.g.dart';
|
||||||
|
|
||||||
|
const pseudoMessageTypeNewDevice = 1;
|
||||||
|
|
||||||
Map<String, String>? _optionalJsonDecode(String? data) {
|
Map<String, String>? _optionalJsonDecode(String? data) {
|
||||||
if (data == null) return null;
|
if (data == null) return null;
|
||||||
|
|
||||||
return jsonDecode(data) as Map<String, String>;
|
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String>();
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _optionalJsonEncode(Map<String, String>? data) {
|
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
|
||||||
|
if (data == null) return <String, dynamic>{};
|
||||||
|
|
||||||
|
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _optionalJsonEncode(Map<String, dynamic>? data) {
|
||||||
if (data == null) return null;
|
if (data == null) return null;
|
||||||
|
|
||||||
return jsonEncode(data);
|
return jsonEncode(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
||||||
|
if (data == null) return null;
|
||||||
|
if (data.isEmpty) return null;
|
||||||
|
|
||||||
|
return jsonEncode(data);
|
||||||
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class Message with _$Message {
|
class Message with _$Message {
|
||||||
// NOTE: id is the database id of the message
|
|
||||||
// NOTE: isMedia is for telling the UI that this message contains the URL for media but the path is not yet available
|
|
||||||
// NOTE: srcUrl is the Url that a file has been or can be downloaded from
|
|
||||||
|
|
||||||
factory Message(
|
factory Message(
|
||||||
String sender,
|
String sender,
|
||||||
String body,
|
String body,
|
||||||
int timestamp,
|
int timestamp,
|
||||||
String sid,
|
String sid,
|
||||||
|
// The database-internal identifier of the message
|
||||||
int id,
|
int id,
|
||||||
String conversationJid,
|
String conversationJid,
|
||||||
|
// True if the message contains some embedded media
|
||||||
bool isMedia,
|
bool isMedia,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
|
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||||
|
bool containsNoStore,
|
||||||
{
|
{
|
||||||
int? errorType,
|
int? errorType,
|
||||||
int? warningType,
|
int? warningType,
|
||||||
@@ -46,6 +62,7 @@ class Message with _$Message {
|
|||||||
String? thumbnailData,
|
String? thumbnailData,
|
||||||
int? mediaWidth,
|
int? mediaWidth,
|
||||||
int? mediaHeight,
|
int? mediaHeight,
|
||||||
|
// If non-null: Indicates where some media entry originated/originates from
|
||||||
String? srcUrl,
|
String? srcUrl,
|
||||||
String? key,
|
String? key,
|
||||||
String? iv,
|
String? iv,
|
||||||
@@ -54,12 +71,18 @@ class Message with _$Message {
|
|||||||
@Default(false) bool displayed,
|
@Default(false) bool displayed,
|
||||||
@Default(false) bool acked,
|
@Default(false) bool acked,
|
||||||
@Default(false) bool isRetracted,
|
@Default(false) bool isRetracted,
|
||||||
|
@Default(false) bool isEdited,
|
||||||
String? originId,
|
String? originId,
|
||||||
Message? quotes,
|
Message? quotes,
|
||||||
String? filename,
|
String? filename,
|
||||||
Map<String, String>? plaintextHashes,
|
Map<String, String>? plaintextHashes,
|
||||||
Map<String, String>? ciphertextHashes,
|
Map<String, String>? ciphertextHashes,
|
||||||
int? mediaSize,
|
int? mediaSize,
|
||||||
|
@Default([]) List<Reaction> reactions,
|
||||||
|
String? stickerPackId,
|
||||||
|
String? stickerHashKey,
|
||||||
|
int? pseudoMessageType,
|
||||||
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
}
|
}
|
||||||
) = _Message;
|
) = _Message;
|
||||||
|
|
||||||
@@ -82,13 +105,25 @@ class Message with _$Message {
|
|||||||
'isDownloading': intToBool(json['isDownloading']! as int),
|
'isDownloading': intToBool(json['isDownloading']! as int),
|
||||||
'isUploading': intToBool(json['isUploading']! as int),
|
'isUploading': intToBool(json['isUploading']! as int),
|
||||||
'isRetracted': intToBool(json['isRetracted']! as int),
|
'isRetracted': intToBool(json['isRetracted']! as int),
|
||||||
}).copyWith(quotes: quotes);
|
'isEdited': intToBool(json['isEdited']! as int),
|
||||||
|
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||||
|
'reactions': <Map<String, dynamic>>[],
|
||||||
|
'pseudoMessageData': _optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
||||||
|
}).copyWith(
|
||||||
|
quotes: quotes,
|
||||||
|
reactions: (jsonDecode(json['reactions']! as String) as List<dynamic>)
|
||||||
|
.cast<Map<String, dynamic>>()
|
||||||
|
.map<Reaction>(Reaction.fromJson)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toDatabaseJson() {
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
final map = toJson()
|
final map = toJson()
|
||||||
..remove('id')
|
..remove('id')
|
||||||
..remove('quotes');
|
..remove('quotes')
|
||||||
|
..remove('reactions')
|
||||||
|
..remove('pseudoMessageData');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...map,
|
...map,
|
||||||
@@ -105,6 +140,14 @@ class Message with _$Message {
|
|||||||
'isDownloading': boolToInt(isDownloading),
|
'isDownloading': boolToInt(isDownloading),
|
||||||
'isUploading': boolToInt(isUploading),
|
'isUploading': boolToInt(isUploading),
|
||||||
'isRetracted': boolToInt(isRetracted),
|
'isRetracted': boolToInt(isRetracted),
|
||||||
|
'isEdited': boolToInt(isEdited),
|
||||||
|
'containsNoStore': boolToInt(containsNoStore),
|
||||||
|
'reactions': jsonEncode(
|
||||||
|
reactions
|
||||||
|
.map((r) => r.toJson())
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,24 +163,30 @@ class Message with _$Message {
|
|||||||
return mimeTypeToEmoji(mediaType, addTypeName: false);
|
return mimeTypeToEmoji(mediaType, addTypeName: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True if the message is a pseudo message.
|
||||||
|
bool get isPseudoMessage => pseudoMessageType != null && pseudoMessageData != null;
|
||||||
|
|
||||||
/// Returns true if the message can be quoted. False if not.
|
/// Returns true if the message can be quoted. False if not.
|
||||||
bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading;
|
bool get isQuotable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||||
|
|
||||||
/// Returns true if the message can be retracted. False if not.
|
/// Returns true if the message can be retracted. False if not.
|
||||||
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
||||||
bool canRetract(bool sentBySelf) {
|
bool canRetract(bool sentBySelf) {
|
||||||
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading;
|
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns true if we can send a reaction for this message.
|
||||||
|
bool get isReactable => !hasError && !isRetracted && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||||
|
|
||||||
/// Returns true if the message can be edited. False if not.
|
/// Returns true if the message can be edited. False if not.
|
||||||
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
||||||
bool canEdit(bool sentBySelf) {
|
bool canEdit(bool sentBySelf) {
|
||||||
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading;
|
return sentBySelf && !isMedia && !isFileUploadNotification && !isUploading && !isDownloading && !isPseudoMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the message can open the selection menu by longpressing. False if
|
/// Returns true if the message can open the selection menu by longpressing. False if
|
||||||
/// not.
|
/// not.
|
||||||
bool get isLongpressable => !isRetracted;
|
bool get isLongpressable => !isRetracted && !isPseudoMessage;
|
||||||
|
|
||||||
/// Returns true if the menu item to show the error should be shown in the
|
/// Returns true if the menu item to show the error should be shown in the
|
||||||
/// longpress menu.
|
/// longpress menu.
|
||||||
@@ -150,11 +199,14 @@ class Message with _$Message {
|
|||||||
|
|
||||||
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
||||||
/// images.
|
/// images.
|
||||||
bool get isThumbnailable => isMedia && mediaType != null && (
|
bool get isThumbnailable => !isPseudoMessage && isMedia && mediaType != null && (
|
||||||
mediaType!.startsWith('image/') ||
|
mediaType!.startsWith('image/') ||
|
||||||
mediaType!.startsWith('video/')
|
mediaType!.startsWith('video/')
|
||||||
);
|
);
|
||||||
|
|
||||||
/// Returns true if the message can be copied to the clipboard.
|
/// Returns true if the message can be copied to the clipboard.
|
||||||
bool get isCopyable => !isMedia && body.isNotEmpty;
|
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
||||||
|
|
||||||
|
/// Returns true if the message is a sticker
|
||||||
|
bool get isSticker => isMedia && stickerPackId != null && stickerHashKey != null && !isPseudoMessage;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ class PreferencesState with _$PreferencesState {
|
|||||||
// NOTE: A value of 'default' means that the system's configured language should
|
// NOTE: A value of 'default' means that the system's configured language should
|
||||||
// be used
|
// be used
|
||||||
@Default('default') String languageLocaleCode,
|
@Default('default') String languageLocaleCode,
|
||||||
|
@Default(false) bool enableContactIntegration,
|
||||||
|
@Default(true) bool enableStickers,
|
||||||
|
@Default(true) bool autoDownloadStickersFromContacts,
|
||||||
|
@Default(true) bool isStickersNodePublic,
|
||||||
|
@Default(false) bool showDebugMenu,
|
||||||
}) = _PreferencesState;
|
}) = _PreferencesState;
|
||||||
|
|
||||||
// JSON serialization
|
// JSON serialization
|
||||||
|
|||||||
26
lib/shared/models/reaction.dart
Normal file
26
lib/shared/models/reaction.dart
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'reaction.freezed.dart';
|
||||||
|
part 'reaction.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class Reaction with _$Reaction {
|
||||||
|
factory Reaction(
|
||||||
|
List<String> senders,
|
||||||
|
String emoji,
|
||||||
|
// NOTE: Store this with the model to prevent having to to a O(n) search across the
|
||||||
|
// list of reactions on every rebuild
|
||||||
|
bool reactedBySelf,
|
||||||
|
) = _Reaction;
|
||||||
|
|
||||||
|
const Reaction._();
|
||||||
|
|
||||||
|
/// JSON
|
||||||
|
factory Reaction.fromJson(Map<String, dynamic> json) => _$ReactionFromJson(json);
|
||||||
|
|
||||||
|
int get reactions {
|
||||||
|
if (reactedBySelf) return senders.length + 1;
|
||||||
|
|
||||||
|
return senders.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
|
||||||
part 'roster.freezed.dart';
|
part 'roster.freezed.dart';
|
||||||
part 'roster.g.dart';
|
part 'roster.g.dart';
|
||||||
@@ -13,7 +14,18 @@ class RosterItem with _$RosterItem {
|
|||||||
String title,
|
String title,
|
||||||
String subscription,
|
String subscription,
|
||||||
String ask,
|
String ask,
|
||||||
|
// Indicates whether the "roster item" really exists on the roster and is not just there
|
||||||
|
// for the contact integration
|
||||||
|
bool pseudoRosterItem,
|
||||||
List<String> groups,
|
List<String> groups,
|
||||||
|
{
|
||||||
|
// The id of the contact in the device's phonebook, if it exists
|
||||||
|
String? contactId,
|
||||||
|
// The path to the profile picture of the contact, if it exists
|
||||||
|
String? contactAvatarPath,
|
||||||
|
// The contact's display name, if it exists
|
||||||
|
String? contactDisplayName,
|
||||||
|
}
|
||||||
) = _RosterItem;
|
) = _RosterItem;
|
||||||
|
|
||||||
const RosterItem._();
|
const RosterItem._();
|
||||||
@@ -26,13 +38,20 @@ class RosterItem with _$RosterItem {
|
|||||||
...json,
|
...json,
|
||||||
// TODO(PapaTutuWawa): Fix
|
// TODO(PapaTutuWawa): Fix
|
||||||
'groups': <String>[],
|
'groups': <String>[],
|
||||||
|
'pseudoRosterItem': intToBool(json['pseudoRosterItem']! as int),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toDatabaseJson() {
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
return toJson()
|
final json = toJson()
|
||||||
..remove('id')
|
..remove('id')
|
||||||
// TODO(PapaTutuWawa): Fix
|
// TODO(PapaTutuWawa): Fix
|
||||||
..remove('groups');
|
..remove('groups')
|
||||||
|
..remove('pseudoRosterItem');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...json,
|
||||||
|
'pseudoRosterItem': boolToInt(pseudoRosterItem),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
91
lib/shared/models/sticker.dart
Normal file
91
lib/shared/models/sticker.dart
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
|
import 'package:moxxyv2/service/helpers.dart';
|
||||||
|
|
||||||
|
part 'sticker.freezed.dart';
|
||||||
|
part 'sticker.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class Sticker with _$Sticker {
|
||||||
|
factory Sticker(
|
||||||
|
String hashKey,
|
||||||
|
String mediaType,
|
||||||
|
String desc,
|
||||||
|
int size,
|
||||||
|
int? width,
|
||||||
|
int? height,
|
||||||
|
/// Hash algorithm (algo attribute) -> Base64 encoded hash
|
||||||
|
Map<String, String> hashes,
|
||||||
|
List<String> urlSources,
|
||||||
|
String path,
|
||||||
|
String stickerPackId,
|
||||||
|
Map<String, String> suggests,
|
||||||
|
) = _Sticker;
|
||||||
|
|
||||||
|
const Sticker._();
|
||||||
|
|
||||||
|
/// Moxxmpp
|
||||||
|
factory Sticker.fromMoxxmpp(moxxmpp.Sticker sticker, String stickerPackId) => Sticker(
|
||||||
|
getStickerHashKey(sticker.metadata.hashes),
|
||||||
|
sticker.metadata.mediaType!,
|
||||||
|
sticker.metadata.desc!,
|
||||||
|
sticker.metadata.size!,
|
||||||
|
sticker.metadata.width,
|
||||||
|
sticker.metadata.height,
|
||||||
|
sticker.metadata.hashes,
|
||||||
|
sticker.sources
|
||||||
|
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||||
|
.map((src) => src.url)
|
||||||
|
.toList(),
|
||||||
|
'',
|
||||||
|
stickerPackId,
|
||||||
|
sticker.suggests,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// JSON
|
||||||
|
factory Sticker.fromJson(Map<String, dynamic> json) => _$StickerFromJson(json);
|
||||||
|
|
||||||
|
factory Sticker.fromDatabaseJson(Map<String, dynamic> json) {
|
||||||
|
return Sticker.fromJson({
|
||||||
|
...json,
|
||||||
|
'hashes': (jsonDecode(json['hashes']! as String) as Map<dynamic, dynamic>).cast<String, String>(),
|
||||||
|
'urlSources': (jsonDecode(json['urlSources']! as String) as List<dynamic>).cast<String>(),
|
||||||
|
'suggests': (jsonDecode(json['suggests']! as String) as Map<dynamic, dynamic>).cast<String, String>(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
|
final map = toJson()
|
||||||
|
..remove('hashes')
|
||||||
|
..remove('urlSources')
|
||||||
|
..remove('suggests');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...map,
|
||||||
|
'hashes': jsonEncode(hashes),
|
||||||
|
'urlSources': jsonEncode(urlSources),
|
||||||
|
'suggests': jsonEncode(suggests),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
moxxmpp.Sticker toMoxxmpp() => moxxmpp.Sticker(
|
||||||
|
moxxmpp.FileMetadataData(
|
||||||
|
mediaType: mediaType,
|
||||||
|
desc: desc,
|
||||||
|
size: size,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
thumbnails: [],
|
||||||
|
hashes: hashes,
|
||||||
|
),
|
||||||
|
urlSources
|
||||||
|
// ignore: unnecessary_lambdas
|
||||||
|
.map((src) => moxxmpp.StatelessFileSharingUrlSource(src))
|
||||||
|
.toList(),
|
||||||
|
suggests,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// True, if the sticker is backed by an image with MIME type image/*.
|
||||||
|
bool get isImage => mediaType.startsWith('image/');
|
||||||
|
}
|
||||||
74
lib/shared/models/sticker_pack.dart
Normal file
74
lib/shared/models/sticker_pack.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||||
|
|
||||||
|
part 'sticker_pack.freezed.dart';
|
||||||
|
part 'sticker_pack.g.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class StickerPack with _$StickerPack {
|
||||||
|
factory StickerPack(
|
||||||
|
String id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
List<Sticker> stickers,
|
||||||
|
String hashAlgorithm,
|
||||||
|
String hashValue,
|
||||||
|
bool restricted,
|
||||||
|
bool local,
|
||||||
|
) = _StickerPack;
|
||||||
|
|
||||||
|
const StickerPack._();
|
||||||
|
|
||||||
|
/// Moxxmpp
|
||||||
|
factory StickerPack.fromMoxxmpp(moxxmpp.StickerPack pack, bool local) => StickerPack(
|
||||||
|
pack.id,
|
||||||
|
pack.name,
|
||||||
|
pack.summary,
|
||||||
|
pack.stickers
|
||||||
|
.map((sticker) => Sticker.fromMoxxmpp(sticker, pack.id))
|
||||||
|
.toList(),
|
||||||
|
pack.hashAlgorithm.toName(),
|
||||||
|
pack.hashValue,
|
||||||
|
pack.restricted,
|
||||||
|
local,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// JSON
|
||||||
|
factory StickerPack.fromJson(Map<String, dynamic> json) => _$StickerPackFromJson(json);
|
||||||
|
|
||||||
|
factory StickerPack.fromDatabaseJson(Map<String, dynamic> json, List<Sticker> stickers) {
|
||||||
|
final pack = StickerPack.fromJson({
|
||||||
|
...json,
|
||||||
|
'local': true,
|
||||||
|
'restricted': intToBool(json['restricted']! as int),
|
||||||
|
'stickers': <Sticker>[],
|
||||||
|
});
|
||||||
|
|
||||||
|
return pack.copyWith(stickers: stickers);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toDatabaseJson() {
|
||||||
|
final json = toJson()
|
||||||
|
..remove('local')
|
||||||
|
..remove('stickers');
|
||||||
|
|
||||||
|
return {
|
||||||
|
...json,
|
||||||
|
'restricted': boolToInt(restricted),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
moxxmpp.StickerPack toMoxxmpp() => moxxmpp.StickerPack(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
moxxmpp.hashFunctionFromName(hashAlgorithm),
|
||||||
|
hashValue,
|
||||||
|
stickers
|
||||||
|
.map((sticker) => sticker.toMoxxmpp())
|
||||||
|
.toList(),
|
||||||
|
restricted,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,12 +16,10 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
AddContactBloc() : super(AddContactState()) {
|
AddContactBloc() : super(AddContactState()) {
|
||||||
on<AddedContactEvent>(_onContactAdded);
|
on<AddedContactEvent>(_onContactAdded);
|
||||||
on<JidChangedEvent>(_onJidChanged);
|
on<JidChangedEvent>(_onJidChanged);
|
||||||
|
on<PageResetEvent>(_onPageReset);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onContactAdded(AddedContactEvent event, Emitter<AddContactState> emit) async {
|
Future<void> _onContactAdded(AddedContactEvent event, Emitter<AddContactState> emit) async {
|
||||||
// TODO(Unknown): Remove once we can disable the custom buttom
|
|
||||||
if (state.working) return;
|
|
||||||
|
|
||||||
final validation = validateJidString(state.jid);
|
final validation = validateJidString(state.jid);
|
||||||
if (validation != null) {
|
if (validation != null) {
|
||||||
emit(state.copyWith(jidError: validation));
|
emit(state.copyWith(jidError: validation));
|
||||||
@@ -30,7 +28,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
working: true,
|
isWorking: true,
|
||||||
jidError: null,
|
jidError: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -42,14 +40,21 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
),
|
),
|
||||||
) as AddContactResultEvent;
|
) as AddContactResultEvent;
|
||||||
|
|
||||||
|
await _onPageReset(PageResetEvent(), emit);
|
||||||
|
|
||||||
if (result.conversation != null) {
|
if (result.conversation != null) {
|
||||||
if (result.added) {
|
if (result.added) {
|
||||||
GetIt.I.get<ConversationsBloc>().add(ConversationsAddedEvent(result.conversation!));
|
GetIt.I.get<ConversationsBloc>().add(
|
||||||
|
ConversationsAddedEvent(result.conversation!),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
GetIt.I.get<ConversationsBloc>().add(ConversationsUpdatedEvent(result.conversation!));
|
GetIt.I.get<ConversationsBloc>().add(
|
||||||
|
ConversationsUpdatedEvent(result.conversation!),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert(result.conversation != null, 'RequestedConversationEvent must contain a not null conversation');
|
||||||
GetIt.I.get<ConversationBloc>().add(
|
GetIt.I.get<ConversationBloc>().add(
|
||||||
RequestedConversationEvent(
|
RequestedConversationEvent(
|
||||||
result.conversation!.jid,
|
result.conversation!.jid,
|
||||||
@@ -61,6 +66,20 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onJidChanged(JidChangedEvent event, Emitter<AddContactState> emit) async {
|
Future<void> _onJidChanged(JidChangedEvent event, Emitter<AddContactState> emit) async {
|
||||||
emit(state.copyWith(jid: event.jid));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
jid: event.jid,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onPageReset(PageResetEvent event, Emitter<AddContactState> emit) async {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
jidError: null,
|
||||||
|
jid: '',
|
||||||
|
isWorking: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ class AddedContactEvent extends AddContactEvent {}
|
|||||||
|
|
||||||
/// Triggered by the UI when the JID input field is changed
|
/// Triggered by the UI when the JID input field is changed
|
||||||
class JidChangedEvent extends AddContactEvent {
|
class JidChangedEvent extends AddContactEvent {
|
||||||
|
|
||||||
JidChangedEvent(this.jid);
|
JidChangedEvent(this.jid);
|
||||||
final String jid;
|
final String jid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Triggered when the UI wants to reset its state
|
||||||
|
class PageResetEvent extends AddContactEvent {}
|
||||||
|
|||||||
@@ -5,6 +5,6 @@ class AddContactState with _$AddContactState {
|
|||||||
factory AddContactState({
|
factory AddContactState({
|
||||||
@Default('') String jid,
|
@Default('') String jid,
|
||||||
@Default(null) String? jidError,
|
@Default(null) String? jidError,
|
||||||
@Default(false) bool working,
|
@Default(false) bool isWorking,
|
||||||
}) = _AddContactState;
|
}) = _AddContactState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
|
||||||
part 'blocklist_bloc.freezed.dart';
|
part 'blocklist_bloc.freezed.dart';
|
||||||
part 'blocklist_event.dart';
|
part 'blocklist_event.dart';
|
||||||
@@ -9,11 +13,44 @@ part 'blocklist_state.dart';
|
|||||||
|
|
||||||
class BlocklistBloc extends Bloc<BlocklistEvent, BlocklistState> {
|
class BlocklistBloc extends Bloc<BlocklistEvent, BlocklistState> {
|
||||||
BlocklistBloc() : super(BlocklistState()) {
|
BlocklistBloc() : super(BlocklistState()) {
|
||||||
|
on<BlocklistRequestedEvent>(_onBlocklistRequested);
|
||||||
on<UnblockedJidEvent>(_onJidUnblocked);
|
on<UnblockedJidEvent>(_onJidUnblocked);
|
||||||
on<UnblockedAllEvent>(_onUnblockedAll);
|
on<UnblockedAllEvent>(_onUnblockedAll);
|
||||||
on<BlocklistPushedEvent>(_onBlocklistPushed);
|
on<BlocklistPushedEvent>(_onBlocklistPushed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onBlocklistRequested(BlocklistRequestedEvent event, Emitter<BlocklistState> emit) async {
|
||||||
|
final mustDoWork = state.blocklist.isEmpty;
|
||||||
|
|
||||||
|
if (mustDoWork) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isWorking: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
|
PushedNamedEvent(
|
||||||
|
const NavigationDestination(blocklistRoute),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state.blocklist.isEmpty) {
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
GetBlocklistCommand(),
|
||||||
|
) as GetBlocklistResultEvent;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
blocklist: result.entries,
|
||||||
|
isWorking: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onJidUnblocked(UnblockedJidEvent event, Emitter<BlocklistState> emit) async {
|
Future<void> _onJidUnblocked(UnblockedJidEvent event, Emitter<BlocklistState> emit) async {
|
||||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
UnblockJidCommand(
|
UnblockJidCommand(
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ part of 'blocklist_bloc.dart';
|
|||||||
|
|
||||||
abstract class BlocklistEvent {}
|
abstract class BlocklistEvent {}
|
||||||
|
|
||||||
|
/// Triggered when the blocklist page has been requested
|
||||||
|
class BlocklistRequestedEvent extends BlocklistEvent {}
|
||||||
|
|
||||||
/// Triggered when a JID is unblocked
|
/// Triggered when a JID is unblocked
|
||||||
class UnblockedJidEvent extends BlocklistEvent {
|
class UnblockedJidEvent extends BlocklistEvent {
|
||||||
|
|
||||||
UnblockedJidEvent(this.jid);
|
UnblockedJidEvent(this.jid);
|
||||||
final String jid;
|
final String jid;
|
||||||
}
|
}
|
||||||
@@ -16,7 +18,6 @@ class UnblockedAllEvent extends BlocklistEvent {
|
|||||||
|
|
||||||
/// Triggered when we receive a blocklist push
|
/// Triggered when we receive a blocklist push
|
||||||
class BlocklistPushedEvent extends BlocklistEvent {
|
class BlocklistPushedEvent extends BlocklistEvent {
|
||||||
|
|
||||||
BlocklistPushedEvent(this.added, this.removed);
|
BlocklistPushedEvent(this.added, this.removed);
|
||||||
final List<String> added;
|
final List<String> added;
|
||||||
final List<String> removed;
|
final List<String> removed;
|
||||||
|
|||||||
@@ -4,5 +4,6 @@ part of 'blocklist_bloc.dart';
|
|||||||
class BlocklistState with _$BlocklistState {
|
class BlocklistState with _$BlocklistState {
|
||||||
factory BlocklistState({
|
factory BlocklistState({
|
||||||
@Default(<String>[]) List<String> blocklist,
|
@Default(<String>[]) List<String> blocklist,
|
||||||
|
@Default(false) bool isWorking,
|
||||||
}) = _BlocklistState;
|
}) = _BlocklistState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:flutter_vibrate/flutter_vibrate.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
import 'package:moxlib/moxlib.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart' as events;
|
import 'package:moxxyv2/shared/events.dart' as events;
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
part 'conversation_bloc.freezed.dart';
|
part 'conversation_bloc.freezed.dart';
|
||||||
part 'conversation_event.dart';
|
part 'conversation_event.dart';
|
||||||
@@ -42,10 +51,23 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
on<BackgroundChangedEvent>(_onBackgroundChanged);
|
on<BackgroundChangedEvent>(_onBackgroundChanged);
|
||||||
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
||||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||||
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
|
on<PickerToggledEvent>(_onPickerToggled);
|
||||||
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
||||||
on<OmemoSetEvent>(_onOmemoSet);
|
on<OmemoSetEvent>(_onOmemoSet);
|
||||||
on<MessageRetractedEvent>(_onMessageRetracted);
|
on<MessageRetractedEvent>(_onMessageRetracted);
|
||||||
|
on<MessageEditSelectedEvent>(_onMessageEditSelected);
|
||||||
|
on<MessageEditCancelledEvent>(_onMessageEditCancelled);
|
||||||
|
on<SendButtonDragStartedEvent>(_onDragStarted);
|
||||||
|
on<SendButtonDragEndedEvent>(_onDragEnded);
|
||||||
|
on<SendButtonLockedEvent>(_onSendButtonLocked);
|
||||||
|
on<SendButtonLockPressedEvent>(_onSendButtonLockPressed);
|
||||||
|
on<RecordingCanceledEvent>(_onRecordingCanceled);
|
||||||
|
on<ReactionAddedEvent>(_onReactionAdded);
|
||||||
|
on<ReactionRemovedEvent>(_onReactionRemoved);
|
||||||
|
on<StickerSentEvent>(_onStickerSent);
|
||||||
|
on<SoftKeyboardVisibilityChanged>(_onSoftKeyboardVisibilityChanged);
|
||||||
|
|
||||||
|
_audioRecorder = Record();
|
||||||
}
|
}
|
||||||
/// The current chat state with the conversation partner
|
/// The current chat state with the conversation partner
|
||||||
ChatState _currentChatState;
|
ChatState _currentChatState;
|
||||||
@@ -54,6 +76,10 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
/// The last time the text has been changed
|
/// The last time the text has been changed
|
||||||
int _lastChangeTimestamp;
|
int _lastChangeTimestamp;
|
||||||
|
|
||||||
|
/// The audio recorder
|
||||||
|
late Record _audioRecorder;
|
||||||
|
DateTime? _recordingStart;
|
||||||
|
|
||||||
void _setLastChangeTimestamp() {
|
void _setLastChangeTimestamp() {
|
||||||
_lastChangeTimestamp = DateTime.now().millisecondsSinceEpoch;
|
_lastChangeTimestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
}
|
}
|
||||||
@@ -97,7 +123,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
state: s.toString().split('.').last,
|
state: s.toString().split('.').last,
|
||||||
jid: state.conversation!.jid,
|
jid: state.conversation!.jid,
|
||||||
),
|
),
|
||||||
awaitable: false,);
|
awaitable: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onInit(InitConversationEvent event, Emitter<ConversationState> emit) async {
|
Future<void> _onInit(InitConversationEvent event, Emitter<ConversationState> emit) async {
|
||||||
@@ -115,6 +142,15 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
state.copyWith(
|
state.copyWith(
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
quotedMessage: null,
|
quotedMessage: null,
|
||||||
|
messageEditing: false,
|
||||||
|
messageEditingOriginalBody: '',
|
||||||
|
messageText: '',
|
||||||
|
messageEditingId: null,
|
||||||
|
messageEditingSid: null,
|
||||||
|
sendButtonState: defaultSendButtonState,
|
||||||
|
isLocked: false,
|
||||||
|
isDragging: false,
|
||||||
|
isRecording: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,10 +196,21 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
_startComposeTimer();
|
_startComposeTimer();
|
||||||
_updateChatState(ChatState.composing);
|
_updateChatState(ChatState.composing);
|
||||||
|
|
||||||
|
SendButtonState sendButtonState;
|
||||||
|
if (state.messageEditing) {
|
||||||
|
sendButtonState = event.value == state.messageEditingOriginalBody ?
|
||||||
|
SendButtonState.cancelCorrection :
|
||||||
|
SendButtonState.send;
|
||||||
|
} else {
|
||||||
|
sendButtonState = event.value.isEmpty ?
|
||||||
|
defaultSendButtonState :
|
||||||
|
SendButtonState.send;
|
||||||
|
}
|
||||||
|
|
||||||
return emit(
|
return emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
messageText: event.value,
|
messageText: event.value,
|
||||||
showSendButton: event.value.isNotEmpty,
|
sendButtonState: sendButtonState,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,22 +221,28 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
_stopComposeTimer();
|
_stopComposeTimer();
|
||||||
|
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
SendMessageCommand(
|
SendMessageCommand(
|
||||||
recipients: [state.conversation!.jid],
|
recipients: [state.conversation!.jid],
|
||||||
body: state.messageText,
|
body: state.messageText,
|
||||||
quotedMessage: state.quotedMessage,
|
quotedMessage: state.quotedMessage,
|
||||||
chatState: chatStateToString(ChatState.active),
|
chatState: chatStateToString(ChatState.active),
|
||||||
|
editId: state.messageEditingId,
|
||||||
|
editSid: state.messageEditingSid,
|
||||||
),
|
),
|
||||||
) as events.MessageAddedEvent;
|
awaitable: false,
|
||||||
|
);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
messages: List<Message>.from(<Message>[ ...state.messages, result.message ]),
|
|
||||||
messageText: '',
|
messageText: '',
|
||||||
quotedMessage: null,
|
quotedMessage: null,
|
||||||
showSendButton: false,
|
sendButtonState: defaultSendButtonState,
|
||||||
emojiPickerVisible: false,
|
pickerVisible: false,
|
||||||
|
messageEditing: false,
|
||||||
|
messageEditingOriginalBody: '',
|
||||||
|
messageEditingId: null,
|
||||||
|
messageEditingSid: null,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -221,7 +274,16 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onJidAdded(JidAddedEvent event, Emitter<ConversationState> emit) async {
|
Future<void> _onJidAdded(JidAddedEvent event, Emitter<ConversationState> emit) async {
|
||||||
// TODO(Unknown): Maybe have some state here
|
// Just update the state here. If it does not work, then the next conversation
|
||||||
|
// update will fix it.
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
conversation: state.conversation!.copyWith(
|
||||||
|
inRoster: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
AddContactCommand(jid: state.conversation!.jid),
|
AddContactCommand(jid: state.conversation!.jid),
|
||||||
);
|
);
|
||||||
@@ -311,9 +373,13 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onEmojiPickerToggled(EmojiPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
Future<void> _onPickerToggled(PickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||||
final newState = !state.emojiPickerVisible;
|
final newState = !state.pickerVisible;
|
||||||
emit(state.copyWith(emojiPickerVisible: newState));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
pickerVisible: newState,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
if (event.handleKeyboard) {
|
if (event.handleKeyboard) {
|
||||||
if (newState) {
|
if (newState) {
|
||||||
@@ -352,4 +418,252 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
|||||||
awaitable: false,
|
awaitable: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onMessageEditSelected(MessageEditSelectedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
messageText: event.message.body,
|
||||||
|
quotedMessage: event.message.quotes,
|
||||||
|
messageEditing: true,
|
||||||
|
messageEditingOriginalBody: event.message.body,
|
||||||
|
messageEditingId: event.message.id,
|
||||||
|
messageEditingSid: event.message.sid,
|
||||||
|
sendButtonState: SendButtonState.cancelCorrection,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onMessageEditCancelled(MessageEditCancelledEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
messageText: '',
|
||||||
|
quotedMessage: null,
|
||||||
|
messageEditing: false,
|
||||||
|
messageEditingOriginalBody: '',
|
||||||
|
messageEditingId: null,
|
||||||
|
messageEditingSid: null,
|
||||||
|
sendButtonState: defaultSendButtonState,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDragStarted(SendButtonDragStartedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
final status = await Permission.speech.status;
|
||||||
|
if (status.isDenied) {
|
||||||
|
await Permission.speech.request();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isDragging: true,
|
||||||
|
isRecording: true,
|
||||||
|
pickerVisible: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
_recordingStart = now;
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final timestamp = '${now.year}${now.month}${now.day}${now.hour}${now.minute}${now.second}';
|
||||||
|
final tempFile = path.join(tempDir.path, 'audio_$timestamp.aac');
|
||||||
|
await _audioRecorder.start(
|
||||||
|
path: tempFile,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleRecordingEnd() async {
|
||||||
|
// Prevent messages of really short duration being sent
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (now.difference(_recordingStart!).inSeconds < 1) {
|
||||||
|
await Fluttertoast.showToast(
|
||||||
|
msg: t.warnings.conversation.holdForLonger,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn if something unexpected happened
|
||||||
|
final recordingPath = await _audioRecorder.stop();
|
||||||
|
if (recordingPath == null) {
|
||||||
|
await Fluttertoast.showToast(
|
||||||
|
msg: t.errors.conversation.audioRecordingError,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the file
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
SendFilesCommand(
|
||||||
|
paths: [recordingPath],
|
||||||
|
recipients: [state.conversation!.jid],
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDragEnded(SendButtonDragEndedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
final recording = state.isRecording;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isDragging: false,
|
||||||
|
isLocked: false,
|
||||||
|
isRecording: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recording) {
|
||||||
|
await _handleRecordingEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSendButtonLocked(SendButtonLockedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
Vibrate.feedback(FeedbackType.light);
|
||||||
|
|
||||||
|
emit(state.copyWith(isLocked: true));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSendButtonLockPressed(SendButtonLockPressedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
final recording = state.isRecording;
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isLocked: false,
|
||||||
|
isDragging: false,
|
||||||
|
isRecording: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (recording) {
|
||||||
|
await _handleRecordingEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRecordingCanceled(RecordingCanceledEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
Vibrate.feedback(FeedbackType.heavy);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isLocked: false,
|
||||||
|
isDragging: false,
|
||||||
|
isRecording: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final file = await _audioRecorder.stop();
|
||||||
|
unawaited(File(file!).delete());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onReactionAdded(ReactionAddedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
// Check if such a reaction already exists
|
||||||
|
final message = state.messages[event.index];
|
||||||
|
final msgs = List<Message>.from(state.messages);
|
||||||
|
final reactionIndex = message.reactions.indexWhere(
|
||||||
|
(Reaction r) => r.emoji == event.emoji,
|
||||||
|
);
|
||||||
|
if (reactionIndex != -1) {
|
||||||
|
// Ignore the request when the reaction would be invalid
|
||||||
|
final reaction = message.reactions[reactionIndex];
|
||||||
|
if (reaction.reactedBySelf) return;
|
||||||
|
|
||||||
|
final reactions = List<Reaction>.from(message.reactions);
|
||||||
|
reactions[reactionIndex] = reaction.copyWith(
|
||||||
|
reactedBySelf: true,
|
||||||
|
);
|
||||||
|
msgs[event.index] = message.copyWith(
|
||||||
|
reactions: reactions,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// The reaction is new
|
||||||
|
msgs[event.index] = message.copyWith(
|
||||||
|
reactions: [
|
||||||
|
...message.reactions,
|
||||||
|
Reaction(
|
||||||
|
[],
|
||||||
|
event.emoji,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
messages: msgs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
AddReactionToMessageCommand(
|
||||||
|
messageId: message.id,
|
||||||
|
emoji: event.emoji,
|
||||||
|
conversationJid: message.conversationJid,
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onReactionRemoved(ReactionRemovedEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
final message = state.messages[event.index];
|
||||||
|
final msgs = List<Message>.from(state.messages);
|
||||||
|
final reactionIndex = message.reactions.indexWhere(
|
||||||
|
(Reaction r) => r.emoji == event.emoji,
|
||||||
|
);
|
||||||
|
|
||||||
|
// We assume that reactionIndex >= 0
|
||||||
|
assert(reactionIndex >= 0, 'The reaction must be found');
|
||||||
|
final reactions = List<Reaction>.from(message.reactions);
|
||||||
|
if (message.reactions[reactionIndex].senders.isEmpty) {
|
||||||
|
reactions.removeAt(reactionIndex);
|
||||||
|
} else {
|
||||||
|
reactions[reactionIndex] = reactions[reactionIndex].copyWith(
|
||||||
|
reactedBySelf: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
msgs[event.index] = message.copyWith(reactions: reactions);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
messages: msgs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
RemoveReactionFromMessageCommand(
|
||||||
|
messageId: message.id,
|
||||||
|
emoji: event.emoji,
|
||||||
|
conversationJid: message.conversationJid,
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStickerSent(StickerSentEvent event, Emitter<ConversationState> emit) async {
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
SendStickerCommand(
|
||||||
|
stickerPackId: event.stickerPackId,
|
||||||
|
stickerHashKey: event.stickerHashKey,
|
||||||
|
recipient: state.conversation!.jid,
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close the picker
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
pickerVisible: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onSoftKeyboardVisibilityChanged(SoftKeyboardVisibilityChanged event, Emitter<ConversationState> emit) async {
|
||||||
|
if (event.visible && (state.pickerVisible)) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
pickerVisible: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,8 +98,8 @@ class ImagePickerRequestedEvent extends ConversationEvent {}
|
|||||||
class FilePickerRequestedEvent extends ConversationEvent {}
|
class FilePickerRequestedEvent extends ConversationEvent {}
|
||||||
|
|
||||||
/// Triggered when the emoji button is pressed
|
/// Triggered when the emoji button is pressed
|
||||||
class EmojiPickerToggledEvent extends ConversationEvent {
|
class PickerToggledEvent extends ConversationEvent {
|
||||||
EmojiPickerToggledEvent({this.handleKeyboard = true});
|
PickerToggledEvent({this.handleKeyboard = true});
|
||||||
final bool handleKeyboard;
|
final bool handleKeyboard;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,3 +120,56 @@ class MessageRetractedEvent extends ConversationEvent {
|
|||||||
MessageRetractedEvent(this.id);
|
MessageRetractedEvent(this.id);
|
||||||
final String id;
|
final String id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Triggered when a message has been selected for editing
|
||||||
|
class MessageEditSelectedEvent extends ConversationEvent {
|
||||||
|
MessageEditSelectedEvent(this.message);
|
||||||
|
final Message message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when a message edit has been cancelled
|
||||||
|
class MessageEditCancelledEvent extends ConversationEvent {
|
||||||
|
MessageEditCancelledEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when the dragging began
|
||||||
|
class SendButtonDragStartedEvent extends ConversationEvent {}
|
||||||
|
|
||||||
|
/// Triggered when the dragging ended
|
||||||
|
class SendButtonDragEndedEvent extends ConversationEvent {}
|
||||||
|
|
||||||
|
/// Triggered when the dragging ended
|
||||||
|
class SendButtonLockedEvent extends ConversationEvent {}
|
||||||
|
|
||||||
|
/// Triggered when the FAB has been locked
|
||||||
|
class SendButtonLockPressedEvent extends ConversationEvent {}
|
||||||
|
|
||||||
|
/// Triggered when the recording has been canceled
|
||||||
|
class RecordingCanceledEvent extends ConversationEvent {}
|
||||||
|
|
||||||
|
/// Triggered when a reaction has been added
|
||||||
|
class ReactionAddedEvent extends ConversationEvent {
|
||||||
|
ReactionAddedEvent(this.emoji, this.index);
|
||||||
|
final String emoji;
|
||||||
|
final int index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when a reaction has been removed
|
||||||
|
class ReactionRemovedEvent extends ConversationEvent {
|
||||||
|
ReactionRemovedEvent(this.emoji, this.index);
|
||||||
|
final String emoji;
|
||||||
|
final int index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when a sticker has been sent
|
||||||
|
class StickerSentEvent extends ConversationEvent {
|
||||||
|
StickerSentEvent(this.stickerPackId, this.stickerHashKey);
|
||||||
|
final String stickerPackId;
|
||||||
|
final String stickerHashKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered when the softkeyboard's visibility changed
|
||||||
|
class SoftKeyboardVisibilityChanged extends ConversationEvent {
|
||||||
|
SoftKeyboardVisibilityChanged(this.visible);
|
||||||
|
final bool visible;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,32 @@
|
|||||||
part of 'conversation_bloc.dart';
|
part of 'conversation_bloc.dart';
|
||||||
|
|
||||||
|
enum SendButtonState {
|
||||||
|
multi,
|
||||||
|
send,
|
||||||
|
cancelCorrection,
|
||||||
|
}
|
||||||
|
const defaultSendButtonState = SendButtonState.multi;
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
class ConversationState with _$ConversationState {
|
class ConversationState with _$ConversationState {
|
||||||
factory ConversationState({
|
factory ConversationState({
|
||||||
// Our own JID
|
// Our own JID
|
||||||
@Default('') String jid,
|
@Default('') String jid,
|
||||||
@Default('') String messageText,
|
@Default('') String messageText,
|
||||||
@Default(false) bool showSendButton,
|
@Default(defaultSendButtonState) SendButtonState sendButtonState,
|
||||||
@Default(null) Message? quotedMessage,
|
@Default(null) Message? quotedMessage,
|
||||||
@Default(<Message>[]) List<Message> messages,
|
@Default(<Message>[]) List<Message> messages,
|
||||||
@Default(null) Conversation? conversation,
|
@Default(null) Conversation? conversation,
|
||||||
@Default('') String backgroundPath,
|
@Default('') String backgroundPath,
|
||||||
@Default(false) bool emojiPickerVisible,
|
@Default(false) bool pickerVisible,
|
||||||
|
@Default(false) bool messageEditing,
|
||||||
|
@Default('') String messageEditingOriginalBody,
|
||||||
|
@Default(null) String? messageEditingSid,
|
||||||
|
@Default(null) int? messageEditingId,
|
||||||
|
|
||||||
|
// For recording
|
||||||
|
@Default(false) bool isDragging,
|
||||||
|
@Default(false) bool isLocked,
|
||||||
|
@Default(false) bool isRecording,
|
||||||
}) = _ConversationState;
|
}) = _ConversationState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,59 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:cropperx/cropperx.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
|
||||||
|
part 'crop_bloc.freezed.dart';
|
||||||
part 'crop_event.dart';
|
part 'crop_event.dart';
|
||||||
|
part 'crop_state.dart';
|
||||||
|
|
||||||
class CropBloc extends Bloc<CropEvent, CropState> {
|
class CropBloc extends Bloc<CropEvent, CropState> {
|
||||||
|
CropBloc() : super(CropState()) {
|
||||||
CropBloc() : super(CropState(null)) {
|
|
||||||
on<ImageCroppedEvent>(_onImageCropped);
|
on<ImageCroppedEvent>(_onImageCropped);
|
||||||
on<ResetImageEvent>(_onImageReset);
|
on<ResetImageEvent>(_onImageReset);
|
||||||
on<SetImageEvent>(_onImageSet);
|
on<SetImageEvent>(_onImageSet);
|
||||||
}
|
}
|
||||||
late Completer<Uint8List?> _completer;
|
late Completer<Uint8List?> _completer;
|
||||||
|
final GlobalKey cropKey = GlobalKey();
|
||||||
|
|
||||||
Future<void> _onImageCropped(ImageCroppedEvent event, Emitter<CropState> emit) async {
|
Future<void> _onImageCropped(ImageCroppedEvent event, Emitter<CropState> emit) async {
|
||||||
_completer.complete(event.image);
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isWorking: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final bytes = await Cropper.crop(
|
||||||
|
cropperKey: cropKey,
|
||||||
|
);
|
||||||
|
_completer.complete(bytes);
|
||||||
|
|
||||||
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
||||||
|
|
||||||
emit(CropState(null));
|
await _onImageReset(ResetImageEvent(), emit);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onImageReset(ResetImageEvent event, Emitter<CropState> emit) async {
|
Future<void> _onImageReset(ResetImageEvent event, Emitter<CropState> emit) async {
|
||||||
emit(CropState(null));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
image: null,
|
||||||
|
isWorking: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onImageSet(SetImageEvent event, Emitter<CropState> emit) async {
|
Future<void> _onImageSet(SetImageEvent event, Emitter<CropState> emit) async {
|
||||||
emit(CropState(event.image));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
image: event.image,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _performCropping(String path) async {
|
Future<void> _performCropping(String path) async {
|
||||||
|
|||||||
@@ -2,22 +2,11 @@ part of 'crop_bloc.dart';
|
|||||||
|
|
||||||
abstract class CropEvent {}
|
abstract class CropEvent {}
|
||||||
|
|
||||||
class ImageCroppedEvent extends CropEvent {
|
class ImageCroppedEvent extends CropEvent {}
|
||||||
|
|
||||||
ImageCroppedEvent(this.image);
|
|
||||||
final Uint8List image;
|
|
||||||
}
|
|
||||||
|
|
||||||
class ResetImageEvent extends CropEvent {}
|
class ResetImageEvent extends CropEvent {}
|
||||||
|
|
||||||
class SetImageEvent extends CropEvent {
|
class SetImageEvent extends CropEvent {
|
||||||
|
|
||||||
SetImageEvent(this.image);
|
SetImageEvent(this.image);
|
||||||
final Uint8List image;
|
final Uint8List image;
|
||||||
}
|
}
|
||||||
|
|
||||||
class CropState {
|
|
||||||
|
|
||||||
CropState(this.image);
|
|
||||||
final Uint8List? image;
|
|
||||||
}
|
|
||||||
|
|||||||
9
lib/ui/bloc/crop_state.dart
Normal file
9
lib/ui/bloc/crop_state.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
part of 'crop_bloc.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class CropState with _$CropState {
|
||||||
|
factory CropState({
|
||||||
|
@Default(null) Uint8List? image,
|
||||||
|
@Default(false) bool isWorking,
|
||||||
|
}) = _CropState;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import 'package:bloc/bloc.dart';
|
|||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:image/image.dart';
|
import 'package:image/image.dart';
|
||||||
import 'package:image_size_getter/image_size_getter.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
@@ -46,7 +46,7 @@ void _cropImage(List<dynamic> data) {
|
|||||||
stackBlurRgba(cropped.data, cropped.width, cropped.height, 20);
|
stackBlurRgba(cropped.data, cropped.width, cropped.height, 20);
|
||||||
}
|
}
|
||||||
|
|
||||||
File(destination).writeAsBytesSync(encodePng(cropped));
|
File(destination).writeAsBytesSync(encodeJpg(cropped, quality: 85));
|
||||||
port.send(true);
|
port.send(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +81,13 @@ class CropBackgroundBloc extends Bloc<CropBackgroundEvent, CropBackgroundState>
|
|||||||
);
|
);
|
||||||
|
|
||||||
final data = await File(event.path).readAsBytes();
|
final data = await File(event.path).readAsBytes();
|
||||||
final imageSize = ImageSizeGetter.getSize(MemoryInput(data));
|
final imageSize = (await getImageSizeFromData(data))!;
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
image: data,
|
image: data,
|
||||||
imagePath: event.path,
|
imagePath: event.path,
|
||||||
imageWidth: imageSize.width,
|
imageWidth: imageSize.width.toInt(),
|
||||||
imageHeight: imageSize.height,
|
imageHeight: imageSize.height.toInt(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,18 @@ import 'package:moxxyv2/shared/events.dart';
|
|||||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
|
|
||||||
part 'devices_bloc.freezed.dart';
|
part 'devices_bloc.freezed.dart';
|
||||||
part 'devices_event.dart';
|
part 'devices_event.dart';
|
||||||
part 'devices_state.dart';
|
part 'devices_state.dart';
|
||||||
|
|
||||||
class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
|
class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
|
||||||
|
|
||||||
DevicesBloc() : super(DevicesState()) {
|
DevicesBloc() : super(DevicesState()) {
|
||||||
on<DevicesRequestedEvent>(_onRequested);
|
on<DevicesRequestedEvent>(_onRequested);
|
||||||
on<DeviceEnabledSetEvent>(_onDeviceEnabledSet);
|
on<DeviceEnabledSetEvent>(_onDeviceEnabledSet);
|
||||||
on<SessionsRecreatedEvent>(_onSessionsRecreated);
|
on<SessionsRecreatedEvent>(_onSessionsRecreated);
|
||||||
|
on<DeviceVerifiedEvent>(_onDeviceVerified);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequested(DevicesRequestedEvent event, Emitter<DevicesState> emit) async {
|
Future<void> _onRequested(DevicesRequestedEvent event, Emitter<DevicesState> emit) async {
|
||||||
@@ -66,4 +67,28 @@ class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
|
|||||||
|
|
||||||
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
GetIt.I.get<NavigationBloc>().add(PoppedRouteEvent());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onDeviceVerified(DeviceVerifiedEvent event, Emitter<DevicesState> emit) async {
|
||||||
|
final result = isVerificationUriValid(
|
||||||
|
state.devices,
|
||||||
|
event.uri,
|
||||||
|
state.jid,
|
||||||
|
event.deviceId,
|
||||||
|
);
|
||||||
|
if (result == -1) return;
|
||||||
|
|
||||||
|
final devices = List<OmemoDevice>.from(state.devices);
|
||||||
|
devices[result] = devices[result].copyWith(
|
||||||
|
verified: true,
|
||||||
|
);
|
||||||
|
emit(state.copyWith(devices: devices));
|
||||||
|
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
MarkOmemoDeviceAsVerifiedCommand(
|
||||||
|
jid: state.jid,
|
||||||
|
deviceId: event.deviceId,
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ abstract class DevicesEvent {}
|
|||||||
|
|
||||||
/// Triggered when the user requested the key page
|
/// Triggered when the user requested the key page
|
||||||
class DevicesRequestedEvent extends DevicesEvent {
|
class DevicesRequestedEvent extends DevicesEvent {
|
||||||
|
|
||||||
DevicesRequestedEvent(this.jid);
|
DevicesRequestedEvent(this.jid);
|
||||||
final String jid;
|
final String jid;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Triggered by the UI when we want to enable or disable a key
|
/// Triggered by the UI when we want to enable or disable a key
|
||||||
class DeviceEnabledSetEvent extends DevicesEvent {
|
class DeviceEnabledSetEvent extends DevicesEvent {
|
||||||
|
|
||||||
DeviceEnabledSetEvent(this.deviceId, this.enabled);
|
DeviceEnabledSetEvent(this.deviceId, this.enabled);
|
||||||
final int deviceId;
|
final int deviceId;
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
@@ -19,3 +17,10 @@ class DeviceEnabledSetEvent extends DevicesEvent {
|
|||||||
|
|
||||||
/// Triggered by the UI when all OMEMO sessions should be recreated
|
/// Triggered by the UI when all OMEMO sessions should be recreated
|
||||||
class SessionsRecreatedEvent extends DevicesEvent {}
|
class SessionsRecreatedEvent extends DevicesEvent {}
|
||||||
|
|
||||||
|
/// Triggered by the UI when a device has been verified using the QR code
|
||||||
|
class DeviceVerifiedEvent extends DevicesEvent {
|
||||||
|
DeviceVerifiedEvent(this.uri, this.deviceId);
|
||||||
|
final Uri uri;
|
||||||
|
final int deviceId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,20 +15,17 @@ class NavigationDestination {
|
|||||||
abstract class NavigationEvent {}
|
abstract class NavigationEvent {}
|
||||||
|
|
||||||
class PushedNamedEvent extends NavigationEvent {
|
class PushedNamedEvent extends NavigationEvent {
|
||||||
|
|
||||||
PushedNamedEvent(this.destination);
|
PushedNamedEvent(this.destination);
|
||||||
final NavigationDestination destination;
|
final NavigationDestination destination;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PushedNamedAndRemoveUntilEvent extends NavigationEvent {
|
class PushedNamedAndRemoveUntilEvent extends NavigationEvent {
|
||||||
|
|
||||||
PushedNamedAndRemoveUntilEvent(this.destination, this.predicate);
|
PushedNamedAndRemoveUntilEvent(this.destination, this.predicate);
|
||||||
final NavigationDestination destination;
|
final NavigationDestination destination;
|
||||||
final RoutePredicate predicate;
|
final RoutePredicate predicate;
|
||||||
}
|
}
|
||||||
|
|
||||||
class PushedNamedReplaceEvent extends NavigationEvent {
|
class PushedNamedReplaceEvent extends NavigationEvent {
|
||||||
|
|
||||||
PushedNamedReplaceEvent(this.destination);
|
PushedNamedReplaceEvent(this.destination);
|
||||||
final NavigationDestination destination;
|
final NavigationDestination destination;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ class NewConversationBloc extends Bloc<NewConversationEvent, NewConversationStat
|
|||||||
final roster = List<RosterItem>.from(event.added);
|
final roster = List<RosterItem>.from(event.added);
|
||||||
|
|
||||||
for (final item in state.roster) {
|
for (final item in state.roster) {
|
||||||
|
// Handle removed items
|
||||||
|
if (event.removed.contains(item.jid)) continue;
|
||||||
|
|
||||||
|
// Handle modified items
|
||||||
final modified = firstWhereOrNull(
|
final modified = firstWhereOrNull(
|
||||||
event.modified,
|
event.modified,
|
||||||
(RosterItem i) => i.id == item.id,
|
(RosterItem i) => i.id == item.id,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/events.dart';
|
|||||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:moxxyv2/ui/helpers.dart';
|
||||||
import 'package:moxxyv2/ui/service/data.dart';
|
import 'package:moxxyv2/ui/service/data.dart';
|
||||||
|
|
||||||
part 'own_devices_bloc.freezed.dart';
|
part 'own_devices_bloc.freezed.dart';
|
||||||
@@ -14,13 +15,13 @@ part 'own_devices_event.dart';
|
|||||||
part 'own_devices_state.dart';
|
part 'own_devices_state.dart';
|
||||||
|
|
||||||
class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
|
class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
|
||||||
|
|
||||||
OwnDevicesBloc() : super(OwnDevicesState()) {
|
OwnDevicesBloc() : super(OwnDevicesState()) {
|
||||||
on<OwnDevicesRequestedEvent>(_onRequested);
|
on<OwnDevicesRequestedEvent>(_onRequested);
|
||||||
on<OwnDeviceEnabledSetEvent>(_onDeviceEnabledSet);
|
on<OwnDeviceEnabledSetEvent>(_onDeviceEnabledSet);
|
||||||
on<OwnSessionsRecreatedEvent>(_onSessionsRecreated);
|
on<OwnSessionsRecreatedEvent>(_onSessionsRecreated);
|
||||||
on<OwnDeviceRemovedEvent>(_onDeviceRemoved);
|
on<OwnDeviceRemovedEvent>(_onDeviceRemoved);
|
||||||
on<OwnDeviceRegeneratedEvent>(_onDeviceRegenerated);
|
on<OwnDeviceRegeneratedEvent>(_onDeviceRegenerated);
|
||||||
|
on<DeviceVerifiedEvent>(_onDeviceVerified);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequested(OwnDevicesRequestedEvent event, Emitter<OwnDevicesState> emit) async {
|
Future<void> _onRequested(OwnDevicesRequestedEvent event, Emitter<OwnDevicesState> emit) async {
|
||||||
@@ -124,4 +125,29 @@ class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onDeviceVerified(DeviceVerifiedEvent event, Emitter<OwnDevicesState> emit) async {
|
||||||
|
final ownJid = GetIt.I.get<UIDataService>().ownJid!;
|
||||||
|
final result = isVerificationUriValid(
|
||||||
|
state.keys,
|
||||||
|
event.uri,
|
||||||
|
ownJid,
|
||||||
|
event.deviceId,
|
||||||
|
);
|
||||||
|
if (result == -1) return;
|
||||||
|
|
||||||
|
final newDevices = List<OmemoDevice>.from(state.keys);
|
||||||
|
newDevices[result] = newDevices[result].copyWith(
|
||||||
|
verified: true,
|
||||||
|
);
|
||||||
|
emit(state.copyWith(keys: newDevices));
|
||||||
|
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
MarkOmemoDeviceAsVerifiedCommand(
|
||||||
|
jid: ownJid,
|
||||||
|
deviceId: event.deviceId,
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,13 @@ class OwnDeviceRegeneratedEvent extends OwnDevicesEvent {}
|
|||||||
|
|
||||||
/// Triggered by the UI when the device with id [deviceId] should be removed.
|
/// Triggered by the UI when the device with id [deviceId] should be removed.
|
||||||
class OwnDeviceRemovedEvent extends OwnDevicesEvent {
|
class OwnDeviceRemovedEvent extends OwnDevicesEvent {
|
||||||
|
|
||||||
OwnDeviceRemovedEvent(this.deviceId);
|
OwnDeviceRemovedEvent(this.deviceId);
|
||||||
final int deviceId;
|
final int deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Triggered by the UI when a device has been verified using the QR code
|
||||||
|
class DeviceVerifiedEvent extends OwnDevicesEvent {
|
||||||
|
DeviceVerifiedEvent(this.uri, this.deviceId);
|
||||||
|
final Uri uri;
|
||||||
|
final int deviceId;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ abstract class PreferencesEvent {}
|
|||||||
/// If [notify] is true, then the background service will be
|
/// If [notify] is true, then the background service will be
|
||||||
/// notified of this change.
|
/// notified of this change.
|
||||||
class PreferencesChangedEvent extends PreferencesEvent {
|
class PreferencesChangedEvent extends PreferencesEvent {
|
||||||
|
|
||||||
PreferencesChangedEvent(this.preferences, {
|
PreferencesChangedEvent(this.preferences, {
|
||||||
this.notify = true,
|
this.notify = true,
|
||||||
});
|
});
|
||||||
@@ -19,7 +18,6 @@ class SignedOutEvent extends PreferencesEvent {}
|
|||||||
|
|
||||||
/// Triggered when a background image has been set
|
/// Triggered when a background image has been set
|
||||||
class BackgroundImageSetEvent extends PreferencesEvent {
|
class BackgroundImageSetEvent extends PreferencesEvent {
|
||||||
|
|
||||||
BackgroundImageSetEvent(this.backgroundPath);
|
BackgroundImageSetEvent(this.backgroundPath);
|
||||||
final String backgroundPath;
|
final String backgroundPath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ServerInfoBloc extends Bloc<ServerInfoEvent, ServerInfoState> {
|
|||||||
csiSupported: result.supportsCsi,
|
csiSupported: result.supportsCsi,
|
||||||
httpFileUploadSupported: result.supportsHttpFileUpload,
|
httpFileUploadSupported: result.supportsHttpFileUpload,
|
||||||
userBlockingSupported: result.supportsUserBlocking,
|
userBlockingSupported: result.supportsUserBlocking,
|
||||||
|
carbonsSupported: result.supportsCarbons,
|
||||||
working: false,
|
working: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ class ServerInfoState with _$ServerInfoState {
|
|||||||
@Default(false) bool userBlockingSupported,
|
@Default(false) bool userBlockingSupported,
|
||||||
@Default(false) bool httpFileUploadSupported,
|
@Default(false) bool httpFileUploadSupported,
|
||||||
@Default(false) bool csiSupported,
|
@Default(false) bool csiSupported,
|
||||||
|
@Default(false) bool carbonsSupported,
|
||||||
}) = _ServerInfoState;
|
}) = _ServerInfoState;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,12 +26,26 @@ enum ShareSelectionType {
|
|||||||
|
|
||||||
/// Create a common ground between Conversations and RosterItems
|
/// Create a common ground between Conversations and RosterItems
|
||||||
class ShareListItem {
|
class ShareListItem {
|
||||||
const ShareListItem(this.avatarPath, this.jid, this.title, this.isConversation, this.isEncrypted);
|
const ShareListItem(
|
||||||
|
this.avatarPath,
|
||||||
|
this.jid,
|
||||||
|
this.title,
|
||||||
|
this.isConversation,
|
||||||
|
this.isEncrypted,
|
||||||
|
this.pseudoRosterItem,
|
||||||
|
this.contactId,
|
||||||
|
this.contactAvatarPath,
|
||||||
|
this.contactDisplayName,
|
||||||
|
);
|
||||||
final String avatarPath;
|
final String avatarPath;
|
||||||
final String jid;
|
final String jid;
|
||||||
final String title;
|
final String title;
|
||||||
final bool isConversation;
|
final bool isConversation;
|
||||||
final bool isEncrypted;
|
final bool isEncrypted;
|
||||||
|
final bool pseudoRosterItem;
|
||||||
|
final String? contactId;
|
||||||
|
final String? contactAvatarPath;
|
||||||
|
final String? contactDisplayName;
|
||||||
}
|
}
|
||||||
|
|
||||||
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
|
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
|
||||||
@@ -67,6 +81,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
|||||||
c.title,
|
c.title,
|
||||||
true,
|
true,
|
||||||
c.encrypted,
|
c.encrypted,
|
||||||
|
false,
|
||||||
|
c.contactId,
|
||||||
|
c.contactAvatarPath,
|
||||||
|
c.contactDisplayName,
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -83,6 +101,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
|||||||
rosterItem.title,
|
rosterItem.title,
|
||||||
false,
|
false,
|
||||||
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
||||||
|
rosterItem.pseudoRosterItem,
|
||||||
|
rosterItem.contactId,
|
||||||
|
rosterItem.contactAvatarPath,
|
||||||
|
rosterItem.contactDisplayName,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -92,6 +114,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
|||||||
rosterItem.title,
|
rosterItem.title,
|
||||||
false,
|
false,
|
||||||
items[index].isEncrypted,
|
items[index].isEncrypted,
|
||||||
|
items[index].pseudoRosterItem,
|
||||||
|
items[index].contactId,
|
||||||
|
items[index].contactAvatarPath,
|
||||||
|
items[index].contactDisplayName,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,7 +130,13 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async {
|
Future<void> _onRequested(ShareSelectionRequestedEvent event, Emitter<ShareSelectionState> emit) async {
|
||||||
emit(state.copyWith(paths: event.paths, text: event.text, type: event.type));
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
paths: event.paths,
|
||||||
|
text: event.text,
|
||||||
|
type: event.type,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
GetIt.I.get<NavigationBloc>().add(
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
PushedNamedAndRemoveUntilEvent(
|
PushedNamedAndRemoveUntilEvent(
|
||||||
|
|||||||
184
lib/ui/bloc/sticker_pack_bloc.dart
Normal file
184
lib/ui/bloc/sticker_pack_bloc.dart
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
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';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
|
||||||
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
|
||||||
|
part 'sticker_pack_bloc.freezed.dart';
|
||||||
|
part 'sticker_pack_event.dart';
|
||||||
|
part 'sticker_pack_state.dart';
|
||||||
|
|
||||||
|
class StickerPackBloc extends Bloc<StickerPackEvent, StickerPackState> {
|
||||||
|
StickerPackBloc() : super(StickerPackState()) {
|
||||||
|
on<LocallyAvailableStickerPackRequested>(_onLocalStickerPackRequested);
|
||||||
|
on<StickerPackRemovedEvent>(_onStickerPackRemoved);
|
||||||
|
on<RemoteStickerPackRequested>(_onRemoteStickerPackRequested);
|
||||||
|
on<StickerPackInstalledEvent>(_onStickerPackInstalled);
|
||||||
|
on<StickerPackRequested>(_onStickerPackRequested);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onLocalStickerPackRequested(LocallyAvailableStickerPackRequested event, Emitter<StickerPackState> emit) async {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isWorking: true,
|
||||||
|
isInstalling: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate
|
||||||
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
|
PushedNamedEvent(
|
||||||
|
const NavigationDestination(stickerPackRoute),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Apply
|
||||||
|
final stickerPack = firstWhereOrNull(
|
||||||
|
GetIt.I.get<stickers.StickersBloc>().state.stickerPacks,
|
||||||
|
(StickerPack pack) => pack.id == event.stickerPackId,
|
||||||
|
);
|
||||||
|
assert(stickerPack != null, 'The sticker pack must be found');
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isWorking: false,
|
||||||
|
stickerPack: stickerPack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStickerPackRemoved(StickerPackRemovedEvent event, Emitter<StickerPackState> emit) async {
|
||||||
|
// Reset internal state
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
stickerPack: null,
|
||||||
|
isWorking: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Leave the page
|
||||||
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
|
PoppedRouteEvent(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove the sticker pack
|
||||||
|
GetIt.I.get<stickers.StickersBloc>().add(
|
||||||
|
stickers.StickerPackRemovedEvent(event.stickerPackId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onRemoteStickerPackRequested(RemoteStickerPackRequested event, Emitter<StickerPackState> emit) async {
|
||||||
|
final mustDoWork = state.stickerPack == null || state.stickerPack?.id != event.stickerPackId;
|
||||||
|
if (mustDoWork) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isWorking: true,
|
||||||
|
isInstalling: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate
|
||||||
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
|
PushedNamedEvent(
|
||||||
|
const NavigationDestination(stickerPackRoute),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mustDoWork) {
|
||||||
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
FetchStickerPackCommand(
|
||||||
|
stickerPackId: event.stickerPackId,
|
||||||
|
jid: event.jid,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result is FetchStickerPackSuccessResult) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isWorking: false,
|
||||||
|
stickerPack: result.stickerPack,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Leave the page
|
||||||
|
GetIt.I.get<NavigationBloc>().add(
|
||||||
|
PoppedRouteEvent(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStickerPackInstalled(StickerPackInstalledEvent event, Emitter<StickerPackState> emit) async {
|
||||||
|
assert(!state.stickerPack!.local, 'Sticker pack must be remote');
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isInstalling: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
InstallStickerPackCommand(
|
||||||
|
stickerPack: state.stickerPack!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isInstalling: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await Fluttertoast.showToast(
|
||||||
|
msg: t.pages.stickerPack.fetchingFailure,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStickerPackRequested(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,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stickerPack == null) {
|
||||||
|
await _onRemoteStickerPackRequested(
|
||||||
|
RemoteStickerPackRequested(
|
||||||
|
event.stickerPackId,
|
||||||
|
event.jid,
|
||||||
|
),
|
||||||
|
emit,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _onLocalStickerPackRequested(
|
||||||
|
LocallyAvailableStickerPackRequested(event.stickerPackId),
|
||||||
|
emit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lib/ui/bloc/sticker_pack_event.dart
Normal file
33
lib/ui/bloc/sticker_pack_event.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
part of 'sticker_pack_bloc.dart';
|
||||||
|
|
||||||
|
abstract class StickerPackEvent {}
|
||||||
|
|
||||||
|
/// Triggered by the UI when the user navigates to a locally available sticker pack
|
||||||
|
class LocallyAvailableStickerPackRequested extends StickerPackEvent {
|
||||||
|
LocallyAvailableStickerPackRequested(this.stickerPackId);
|
||||||
|
final String stickerPackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered by the UI when the user navigates to a remote sticker pack
|
||||||
|
class RemoteStickerPackRequested extends StickerPackEvent {
|
||||||
|
RemoteStickerPackRequested(this.stickerPackId, this.jid);
|
||||||
|
final String stickerPackId;
|
||||||
|
final String jid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered by the UI when the sticker pack is removed
|
||||||
|
class StickerPackRemovedEvent extends StickerPackEvent {
|
||||||
|
StickerPackRemovedEvent(this.stickerPackId);
|
||||||
|
final String stickerPackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triggered by the UI when the sticker pack currently displayed is to be installed
|
||||||
|
class StickerPackInstalledEvent extends StickerPackEvent {}
|
||||||
|
|
||||||
|
/// Triggered by the UI when a URL has been tapped that contains a sticker pack that
|
||||||
|
/// or may not be locally available.
|
||||||
|
class StickerPackRequested extends StickerPackEvent {
|
||||||
|
StickerPackRequested(this.jid, this.stickerPackId);
|
||||||
|
final String jid;
|
||||||
|
final String stickerPackId;
|
||||||
|
}
|
||||||
10
lib/ui/bloc/sticker_pack_state.dart
Normal file
10
lib/ui/bloc/sticker_pack_state.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
part of 'sticker_pack_bloc.dart';
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class StickerPackState with _$StickerPackState {
|
||||||
|
factory StickerPackState({
|
||||||
|
StickerPack? stickerPack,
|
||||||
|
@Default(false) bool isWorking,
|
||||||
|
@Default(false) bool isInstalling,
|
||||||
|
}) = _StickerPackState;
|
||||||
|
}
|
||||||
150
lib/ui/bloc/stickers_bloc.dart
Normal file
150
lib/ui/bloc/stickers_bloc.dart
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
part 'stickers_bloc.freezed.dart';
|
||||||
|
part 'stickers_event.dart';
|
||||||
|
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.hashKey)] = 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.hashKey));
|
||||||
|
|
||||||
|
// Evict stickers from the cache
|
||||||
|
unawaited(FileImage(File(sticker.path)).evict());
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
stickerPacks: List.from(
|
||||||
|
state.stickerPacks.where((sp) => sp.id != event.stickerPackId),
|
||||||
|
),
|
||||||
|
stickerMap: sm,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
RemoveStickerPackCommand(
|
||||||
|
stickerPackId: event.stickerPackId,
|
||||||
|
),
|
||||||
|
awaitable: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onStickerPackImported(StickerPackImportedEvent event, Emitter<StickersState> emit) async {
|
||||||
|
final file = await FilePicker.platform.pickFiles();
|
||||||
|
if (file == null) return;
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
isImportRunning: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||||
|
ImportStickerPackCommand(
|
||||||
|
path: file.files.single.path!,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result is StickerPackImportSuccessEvent) {
|
||||||
|
final sm = Map<StickerKey, Sticker>.from(state.stickerMap);
|
||||||
|
for (final sticker in result.stickerPack.stickers) {
|
||||||
|
if (!sticker.isImage) continue;
|
||||||
|
|
||||||
|
sm[StickerKey(result.stickerPack.id, sticker.hashKey)] = 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,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.hashKey)] = sticker;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
stickerPacks: List<StickerPack>.from([
|
||||||
|
...state.stickerPacks,
|
||||||
|
event.stickerPack,
|
||||||
|
]),
|
||||||
|
stickerMap: sm,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
lib/ui/bloc/stickers_event.dart
Normal file
25
lib/ui/bloc/stickers_event.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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);
|
||||||
|
final String stickerPackId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
25
lib/ui/bloc/stickers_state.dart
Normal file
25
lib/ui/bloc/stickers_state.dart
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
part of 'stickers_bloc.dart';
|
||||||
|
|
||||||
|
@immutable
|
||||||
|
class StickerKey {
|
||||||
|
const StickerKey(this.packId, this.stickerHashKey);
|
||||||
|
final String packId;
|
||||||
|
final String stickerHashKey;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => packId.hashCode ^ stickerHashKey.hashCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is StickerKey && other.packId == packId && other.stickerHashKey == stickerHashKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
class StickersState with _$StickersState {
|
||||||
|
factory StickersState({
|
||||||
|
@Default([]) List<StickerPack> stickerPacks,
|
||||||
|
@Default({}) Map<StickerKey, Sticker> stickerMap,
|
||||||
|
@Default(false) bool isImportRunning,
|
||||||
|
}) = _StickersState;
|
||||||
|
}
|
||||||
@@ -4,9 +4,24 @@ const Radius radiusLarge = Radius.circular(10);
|
|||||||
const Radius radiusSmall = Radius.circular(4);
|
const Radius radiusSmall = Radius.circular(4);
|
||||||
|
|
||||||
const double textfieldRadiusRegular = 15;
|
const double textfieldRadiusRegular = 15;
|
||||||
const double textfieldRadiusConversation = 20;
|
const double textfieldRadiusConversation = 25;
|
||||||
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(top: 4, bottom: 4, left: 8, right: 8);
|
const EdgeInsetsGeometry textfieldPaddingRegular = EdgeInsets.only(
|
||||||
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.all(10);
|
top: 4,
|
||||||
|
bottom: 4,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The inner TextField padding for the TextField on the ConversationPage.
|
||||||
|
const EdgeInsetsGeometry textfieldPaddingConversation = EdgeInsets.only(
|
||||||
|
top: 12,
|
||||||
|
bottom: 12,
|
||||||
|
left: 8,
|
||||||
|
right: 8,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The font size for the TextField on the ConversationPage
|
||||||
|
const double textFieldFontSizeConversation = 18;
|
||||||
|
|
||||||
const int primaryColorHexRGBO = 0xffcf4aff;
|
const int primaryColorHexRGBO = 0xffcf4aff;
|
||||||
const int primaryColorAltHexRGB = 0xff9c18cd;
|
const int primaryColorAltHexRGB = 0xff9c18cd;
|
||||||
@@ -17,12 +32,66 @@ const Color primaryColorAlt = Color(primaryColorAltHexRGB);
|
|||||||
const Color primaryColorDisabled = Color(primaryColorDisabledHexRGB);
|
const Color primaryColorDisabled = Color(primaryColorDisabledHexRGB);
|
||||||
const Color textColorDisabled = Color(textColorDisabledHexRGB);
|
const Color textColorDisabled = Color(textColorDisabledHexRGB);
|
||||||
|
|
||||||
|
/// The color of a quote bubble displayed inside the TextField
|
||||||
|
const Color bubbleQuoteInTextFieldColorLight = Color(0xffc7c7c7);
|
||||||
|
const Color bubbleQuoteInTextFieldColorDark = Color(0xff2f2f2f);
|
||||||
|
|
||||||
|
/// The color of text inside a quote bubble inside the TextField
|
||||||
|
const Color bubbleQuoteInTextFieldTextColorLight = Color(0xff373737);
|
||||||
|
const Color bubbleQuoteInTextFieldTextColorDark = Color(0xffdadada);
|
||||||
|
|
||||||
|
/// The text color of the hint text on the ConversationPage
|
||||||
|
const Color textFieldHintTextColorLight = Color(0xff4a4a4a);
|
||||||
|
const Color textFieldHintTextColorDark = Color(0xffd6d6d6);
|
||||||
|
|
||||||
|
/// The regular text color of the TextField on the ConversationPage
|
||||||
|
const Color textFieldTextColorLight = Colors.black;
|
||||||
|
const Color textFieldTextColorDark = Colors.white;
|
||||||
|
|
||||||
|
/// The color of a bubble that was sent
|
||||||
const Color bubbleColorSent = Color(0xff7e0bce);
|
const Color bubbleColorSent = Color(0xff7e0bce);
|
||||||
const Color bubbleColorSentQuoted = bubbleColorSent;
|
|
||||||
|
/// The color of the quote widget for a sent quote
|
||||||
|
const Color bubbleColorSentQuoted = Color(0xff6e0ab4);
|
||||||
|
|
||||||
|
/// The color of a bubble that was received
|
||||||
const Color bubbleColorReceived = Color(0xff222222);
|
const Color bubbleColorReceived = Color(0xff222222);
|
||||||
const Color bubbleColorReceivedQuoted = bubbleColorReceived;
|
|
||||||
|
/// The color of the quote widget for a received quote
|
||||||
|
const Color bubbleColorReceivedQuoted = Color(0xff2f2f2f);
|
||||||
|
|
||||||
|
/// The color of a bubble when the message is unencrypted while the chat is encrypted
|
||||||
const Color bubbleColorUnencrypted = Color(0xffd40000);
|
const Color bubbleColorUnencrypted = Color(0xffd40000);
|
||||||
|
|
||||||
|
/// The color of a bubble for a pseudo message of type new device
|
||||||
|
const Color bubbleColorNewDevice = Color(0xffeee8d5);
|
||||||
|
|
||||||
|
/// The color of text within a regular bubble
|
||||||
|
const Color bubbleTextColor = Color(0xffffffff);
|
||||||
|
|
||||||
|
/// The color of text within a quote widget
|
||||||
|
const Color bubbleTextQuoteColor = Color(0xffdadada);
|
||||||
|
|
||||||
|
/// The color of the sender name in a quote
|
||||||
|
const Color bubbleTextQuoteSenderColor = Color(0xffff90ff);
|
||||||
|
|
||||||
|
/// The color of the input text field of the conversation page
|
||||||
|
const Color conversationTextFieldColorLight = Color(0xffe6e6e6);
|
||||||
|
const Color conversationTextFieldColorDark = Color(0xff414141);
|
||||||
|
|
||||||
|
/// The width of the white left border of quote widgets
|
||||||
|
const double quoteLeftBorderWidth = 4;
|
||||||
|
|
||||||
|
/// The background color of the avatar when no actual avatar is available
|
||||||
|
const Color profileFallbackBackgroundColorLight = Color(0xffc3c3c3);
|
||||||
|
const Color profileFallbackBackgroundColorDark = Color(0xff424242);
|
||||||
|
|
||||||
|
/// The text color of the avatar fallback text
|
||||||
|
const Color profileFallbackTextColorLight = Color(0xff343434);
|
||||||
|
const Color profileFallbackTextColorDark = Colors.white;
|
||||||
|
|
||||||
|
const Color settingsSectionTitleColor = Color(0xffb72fe7);
|
||||||
|
|
||||||
const double paddingVeryLarge = 64;
|
const double paddingVeryLarge = 64;
|
||||||
|
|
||||||
const Color tileColorDark = Color(0xff5c5c5c);
|
const Color tileColorDark = Color(0xff5c5c5c);
|
||||||
@@ -35,11 +104,20 @@ const double fontsizeBody = 15;
|
|||||||
const double fontsizeBodyOnlyEmojis = 30;
|
const double fontsizeBodyOnlyEmojis = 30;
|
||||||
const double fontsizeSubbody = 10;
|
const double fontsizeSubbody = 10;
|
||||||
|
|
||||||
// The translucent black we use when we need to ensure good contrast, for example when
|
/// The color for a shared media item
|
||||||
// displaying the download progress indicator.
|
final Color sharedMediaItemBackgroundColor = Colors.grey.shade500;
|
||||||
|
|
||||||
|
/// The color for a shared media summary
|
||||||
|
final Color sharedMediaSummaryBackgroundColor = Colors.grey.shade500;
|
||||||
|
|
||||||
|
/// The translucent black we use when we need to ensure good contrast, for example when
|
||||||
|
/// displaying the download progress indicator.
|
||||||
final backdropBlack = Colors.black.withAlpha(150);
|
final backdropBlack = Colors.black.withAlpha(150);
|
||||||
|
|
||||||
// Navigation constants
|
/// The height of the emoji/sticker picker
|
||||||
|
const double pickerHeight = 300;
|
||||||
|
|
||||||
|
/// Navigation constants
|
||||||
const String cropRoute = '/crop';
|
const String cropRoute = '/crop';
|
||||||
const String introRoute = '/intro';
|
const String introRoute = '/intro';
|
||||||
const String loginRoute = '/route';
|
const String loginRoute = '/route';
|
||||||
@@ -61,8 +139,11 @@ const String networkRoute = '$settingsRoute/network';
|
|||||||
const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
|
const String backgroundCroppingRoute = '$settingsRoute/appearance/background';
|
||||||
const String conversationSettingsRoute = '$settingsRoute/conversation';
|
const String conversationSettingsRoute = '$settingsRoute/conversation';
|
||||||
const String appearanceRoute = '$settingsRoute/appearance';
|
const String appearanceRoute = '$settingsRoute/appearance';
|
||||||
|
const String stickersRoute = '$settingsRoute/stickers';
|
||||||
const String blocklistRoute = '/blocklist';
|
const String blocklistRoute = '/blocklist';
|
||||||
const String shareSelectionRoute = '/share_selection';
|
const String shareSelectionRoute = '/share_selection';
|
||||||
const String serverInfoRoute = '$profileRoute/server_info';
|
const String serverInfoRoute = '$profileRoute/server_info';
|
||||||
const String devicesRoute = '$profileRoute/devices';
|
const String devicesRoute = '$profileRoute/devices';
|
||||||
const String ownDevicesRoute = '$profileRoute/own_devices';
|
const String ownDevicesRoute = '$profileRoute/own_devices';
|
||||||
|
const String qrCodeScannerRoute = '/util/qr_code_scanner';
|
||||||
|
const String stickerPackRoute = '/stickers/sticker_pack';
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'package:moxxyv2/ui/bloc/conversations_bloc.dart' as conversations;
|
|||||||
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
|
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart' as new_conversation;
|
||||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
import 'package:moxxyv2/ui/bloc/profile_bloc.dart' as profile;
|
||||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart' as sharedmedia;
|
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart' as sharedmedia;
|
||||||
|
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart' as stickers;
|
||||||
import 'package:moxxyv2/ui/prestart.dart';
|
import 'package:moxxyv2/ui/prestart.dart';
|
||||||
import 'package:moxxyv2/ui/service/progress.dart';
|
import 'package:moxxyv2/ui/service/progress.dart';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ void setupEventHandler() {
|
|||||||
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
|
EventTypeMatcher<PreStartDoneEvent>(preStartDone),
|
||||||
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
|
EventTypeMatcher<ServiceReadyEvent>(onServiceReady),
|
||||||
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
|
EventTypeMatcher<MessageNotificationTappedEvent>(onNotificationTappend),
|
||||||
|
EventTypeMatcher<StickerPackAddedEvent>(onStickerPackAdded),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<EventHandler>(handler);
|
GetIt.I.registerSingleton<EventHandler>(handler);
|
||||||
@@ -159,3 +161,9 @@ Future<void> onNotificationTappend(MessageNotificationTappedEvent event, { dynam
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> onStickerPackAdded(StickerPackAddedEvent event, { dynamic extra }) async {
|
||||||
|
GetIt.I.get<stickers.StickersBloc>().add(
|
||||||
|
stickers.StickerPackAddedEvent(event.stickerPack),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
import 'package:better_open_file/better_open_file.dart';
|
||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||||
|
import 'package:fluttertoast/fluttertoast.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/shared/avatar.dart';
|
import 'package:moxxyv2/shared/avatar.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||||
|
import 'package:moxxyv2/ui/redirects.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
|
/// Shows a dialog asking the user if they are sure that they want to proceed with an
|
||||||
/// action. Resolves to true if the user pressed the confirm button. Returns false if
|
/// action. Resolves to true if the user pressed the confirm button. Returns false if
|
||||||
@@ -179,3 +188,190 @@ String localeCodeToLanguageName(String localeCode) {
|
|||||||
assert(false, 'Language code $localeCode has no name');
|
assert(false, 'Language code $localeCode has no name');
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scans QR Codes for an URI with a scheme of xmpp:. Returns the URI when found.
|
||||||
|
/// Returns null if not.
|
||||||
|
Future<Uri?> scanXmppUriQrCode(BuildContext context) async {
|
||||||
|
final value = await Navigator.of(context).pushNamed<String>(
|
||||||
|
qrCodeScannerRoute,
|
||||||
|
arguments: QrCodeScanningArguments(
|
||||||
|
(value) {
|
||||||
|
if (value == null) return false;
|
||||||
|
|
||||||
|
final uri = Uri.tryParse(value);
|
||||||
|
if (uri == null) return false;
|
||||||
|
|
||||||
|
if (uri.scheme == 'xmpp') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (value != null) {
|
||||||
|
return Uri.parse(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog with the given data string encoded as a QR Code.
|
||||||
|
void showQrCode(BuildContext context, String data, { bool embedLogo = true }) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) => Center(
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.all(radiusLarge),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
child: QrImage(
|
||||||
|
data: data,
|
||||||
|
size: 220,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
embeddedImage: embedLogo ?
|
||||||
|
const AssetImage('assets/images/logo.png') :
|
||||||
|
null,
|
||||||
|
embeddedImageStyle: embedLogo ?
|
||||||
|
QrEmbeddedImageStyle(
|
||||||
|
size: const Size(50, 50),
|
||||||
|
) :
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compares the scanned fingerprint (encoded by [scannedUri]) against the device list
|
||||||
|
/// [devices] for the device with id [deviceId] with the JID [deviceJid].
|
||||||
|
///
|
||||||
|
/// Returns the index of the device in [devices] on success. On failure of any kind,
|
||||||
|
/// returns -1.
|
||||||
|
int isVerificationUriValid(List<OmemoDevice> devices, Uri scannedUri, String deviceJid, int deviceId) {
|
||||||
|
if (scannedUri.queryParameters.isEmpty) {
|
||||||
|
// No query parameters
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: t.errors.omemo.verificationInvalidOmemoUrl,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final jid = scannedUri.path;
|
||||||
|
if (deviceJid != jid) {
|
||||||
|
// The Jid is wrong
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: t.errors.omemo.verificationWrongJid,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(PapaTutuWawa): Use an exception safe version of firstWhere
|
||||||
|
final sidParam = scannedUri.queryParameters
|
||||||
|
.keys
|
||||||
|
.firstWhere((param) => param.startsWith('omemo2-sid-'));
|
||||||
|
final id = int.parse(sidParam.replaceFirst('omemo2-sid-', ''));
|
||||||
|
final fp = scannedUri.queryParameters[sidParam];
|
||||||
|
|
||||||
|
if (id != deviceId) {
|
||||||
|
// The scanned device has the wrong Id
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: t.errors.omemo.verificationWrongDevice,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final index = devices.indexWhere((device) => device.deviceId == deviceId);
|
||||||
|
if (index == -1) {
|
||||||
|
// The device is not in the list
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: t.errors.omemo.verificationNotInList,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
final device = devices[index];
|
||||||
|
if (device.fingerprint != fp) {
|
||||||
|
// The fingerprint is not what we expected
|
||||||
|
Fluttertoast.showToast(
|
||||||
|
msg: t.errors.omemo.verificationWrongFingerprint,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse the URI [uriString] and trigger an appropriate UI action.
|
||||||
|
Future<void> handleUri(String uriString) async {
|
||||||
|
final uri = Uri.tryParse(uriString);
|
||||||
|
if (uri == null) return;
|
||||||
|
|
||||||
|
if (uri.scheme == 'xmpp') {
|
||||||
|
final psAction = uri.queryParameters['pubsub;action'];
|
||||||
|
if (psAction != null) {
|
||||||
|
final parts = psAction.split(';');
|
||||||
|
String? node;
|
||||||
|
String? item;
|
||||||
|
|
||||||
|
for (final p in parts) {
|
||||||
|
if (p.startsWith('node=')) {
|
||||||
|
node = p.substring(5);
|
||||||
|
} else if (p.startsWith('item=')) {
|
||||||
|
item = p.substring(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node == moxxmpp.stickersXmlns && item != null) {
|
||||||
|
// Retrieve a sticker pack
|
||||||
|
GetIt.I.get<StickerPackBloc>().add(
|
||||||
|
StickerPackRequested(
|
||||||
|
uri.path,
|
||||||
|
item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await launchUrl(
|
||||||
|
redirectUrl(uri),
|
||||||
|
mode: LaunchMode.externalNonBrowserApplication,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the file [path] using the system native means. Shows a toast if the
|
||||||
|
/// file cannot be opened.
|
||||||
|
Future<void> openFile(String path) async {
|
||||||
|
final result = await OpenFile.open(path);
|
||||||
|
|
||||||
|
if (result.type != ResultType.done) {
|
||||||
|
String message;
|
||||||
|
if (result.type == ResultType.noAppToOpen) {
|
||||||
|
message = t.errors.conversation.openFileNoAppError;
|
||||||
|
} else {
|
||||||
|
message = t.errors.conversation.openFileGenericError;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Fluttertoast.showToast(
|
||||||
|
msg: message,
|
||||||
|
toastLength: Toast.LENGTH_SHORT,
|
||||||
|
gravity: ToastGravity.SNACKBAR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import 'package:moxxyv2/ui/widgets/button.dart';
|
|||||||
import 'package:moxxyv2/ui/widgets/textfield.dart';
|
import 'package:moxxyv2/ui/widgets/textfield.dart';
|
||||||
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
import 'package:moxxyv2/ui/widgets/topbar.dart';
|
||||||
|
|
||||||
class AddContactPage extends StatelessWidget {
|
class AddContactPage extends StatefulWidget {
|
||||||
const AddContactPage({ super.key });
|
const AddContactPage({ super.key });
|
||||||
|
|
||||||
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
static MaterialPageRoute<dynamic> get route => MaterialPageRoute<dynamic>(
|
||||||
@@ -18,15 +18,33 @@ class AddContactPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
AddContactPageState createState() => AddContactPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddContactPageState extends State<AddContactPage> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<AddContactBloc, AddContactState>(
|
return BlocBuilder<AddContactBloc, AddContactState>(
|
||||||
builder: (context, state) => Scaffold(
|
builder: (context, state) => WillPopScope(
|
||||||
|
onWillPop: () async {
|
||||||
|
if (state.isWorking) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.read<AddContactBloc>().add(
|
||||||
|
PageResetEvent(),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
|
appBar: BorderlessTopbar.simple(t.pages.addcontact.title),
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
Visibility(
|
Visibility(
|
||||||
visible: state.working,
|
visible: state.isWorking,
|
||||||
child: const LinearProgressIndicator(),
|
child: const LinearProgressIndicator(),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -37,15 +55,23 @@ class AddContactPage extends StatelessWidget {
|
|||||||
onChanged: (value) => context.read<AddContactBloc>().add(
|
onChanged: (value) => context.read<AddContactBloc>().add(
|
||||||
JidChangedEvent(value),
|
JidChangedEvent(value),
|
||||||
),
|
),
|
||||||
enabled: !state.working,
|
controller: _controller,
|
||||||
|
enabled: !state.isWorking,
|
||||||
cornerRadius: textfieldRadiusRegular,
|
cornerRadius: textfieldRadiusRegular,
|
||||||
borderColor: primaryColor,
|
borderColor: primaryColor,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
errorText: state.jidError,
|
errorText: state.jidError,
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
icon: const Icon(Icons.qr_code),
|
icon: const Icon(Icons.qr_code),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
showNotImplementedDialog('QR-code scanning', context);
|
final jid = await scanXmppUriQrCode(context);
|
||||||
|
if (jid == null) return;
|
||||||
|
|
||||||
|
_controller.text = jid.path;
|
||||||
|
// ignore: use_build_context_synchronously
|
||||||
|
context.read<AddContactBloc>().add(
|
||||||
|
JidChangedEvent(jid.path),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -64,7 +90,7 @@ class AddContactPage extends StatelessWidget {
|
|||||||
child: RoundedButton(
|
child: RoundedButton(
|
||||||
cornerRadius: 32,
|
cornerRadius: 32,
|
||||||
onTap: () => context.read<AddContactBloc>().add(AddedContactEvent()),
|
onTap: () => context.read<AddContactBloc>().add(AddedContactEvent()),
|
||||||
enabled: !state.working,
|
enabled: !state.isWorking,
|
||||||
child: Text(t.pages.addcontact.buttonAddToContact),
|
child: Text(t.pages.addcontact.buttonAddToContact),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -74,6 +100,7 @@ class AddContactPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
Widget _buildListView(BlocklistState state) {
|
Widget _buildListView(BlocklistState state) {
|
||||||
// ignore: non_bool_condition,avoid_dynamic_calls
|
// ignore: non_bool_condition,avoid_dynamic_calls
|
||||||
if (state.blocklist.isEmpty) {
|
if (state.blocklist.isEmpty) {
|
||||||
return Padding(
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (state.isWorking)
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
|
||||||
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
padding: const EdgeInsets.symmetric(horizontal: paddingVeryLarge),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -37,10 +42,18 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.builder(
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (state.isWorking)
|
||||||
|
const LinearProgressIndicator(),
|
||||||
|
|
||||||
|
ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
itemCount: state.blocklist.length,
|
itemCount: state.blocklist.length,
|
||||||
itemBuilder: (BuildContext context, int index) {
|
itemBuilder: (BuildContext context, int index) {
|
||||||
// ignore: avoid_dynamic_calls
|
// ignore: avoid_dynamic_calls
|
||||||
@@ -76,6 +89,8 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +123,7 @@ class BlocklistPage extends StatelessWidget {
|
|||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.more_vert),
|
||||||
itemBuilder: (BuildContext context) => [
|
itemBuilder: (BuildContext context) => [
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
|
enabled: state.blocklist.isNotEmpty,
|
||||||
value: BlocklistOptions.unblockAll,
|
value: BlocklistOptions.unblockAll,
|
||||||
child: Text(t.pages.blocklist.unblockAll),
|
child: Text(t.pages.blocklist.unblockAll),
|
||||||
),
|
),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user