Compare commits
246 Commits
b0f266bb0a
...
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 | |||
| 6a109fe03d | |||
| a783aab229 | |||
| 43dc2285b3 | |||
| eac8e3fb44 | |||
| 035d29fabc | |||
| ad2b10972c | |||
| e65e1f3ec4 | |||
| 10b86812cd | |||
| fe4c794f68 | |||
| 6115d748e3 |
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
|
||||
|
||||
# Build scripts
|
||||
release/
|
||||
|
||||
2
.gitlint
2
.gitlint
@@ -7,7 +7,7 @@ line-length=72
|
||||
[title-trailing-punctuation]
|
||||
[title-hard-tab]
|
||||
[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]
|
||||
|
||||
@@ -46,3 +46,9 @@ See `./LICENSE`.
|
||||
## Special Thanks
|
||||
|
||||
- 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.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -27,13 +27,40 @@
|
||||
"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": {
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "File",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "The message has been retracted",
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it"
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||
"you": "You"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
@@ -41,7 +68,13 @@
|
||||
"notEncryptedForDevice": "This message was not encrypted for this device",
|
||||
"invalidHmac": "Could not decrypt message",
|
||||
"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": {
|
||||
"connectionTimeout": "Could not connect to server"
|
||||
@@ -64,11 +97,19 @@
|
||||
"failedToEncryptFile": "The file could not be encrypted",
|
||||
"failedToDecryptFile": "The file could not be decrypted",
|
||||
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Failed to finalize audio recording",
|
||||
"openFileNoAppError": "No app found to open this file",
|
||||
"openFileGenericError": "Failed to open file"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Could not verify file integrity"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Hold button longer to record a voice message"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
@@ -100,6 +141,7 @@
|
||||
"closeChat": "Close chat",
|
||||
"closeChatConfirmTitle": "Close chat",
|
||||
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
||||
"blockShort": "Block",
|
||||
"blockUser": "Block user",
|
||||
"online": "Online",
|
||||
"retract": "Retract message",
|
||||
@@ -107,7 +149,21 @@
|
||||
"forward": "Forward",
|
||||
"edit": "Edit",
|
||||
"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": {
|
||||
"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?"
|
||||
},
|
||||
"profile": {
|
||||
"self": {
|
||||
"devices": "Devices"
|
||||
"general": {
|
||||
"omemo": "Security"
|
||||
},
|
||||
"conversation": {
|
||||
"muteChatTooltip": "Mute chat",
|
||||
"unmuteChatTooltip": "Unmute chat",
|
||||
"muteChat": "Mute",
|
||||
"unmuteChat": "Unmute",
|
||||
"devices": "Devices"
|
||||
"notifications": "Notifications",
|
||||
"notificationsMuted": "Muted",
|
||||
"notificationsEnabled": "Enabled",
|
||||
"sharedMedia": "Media"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Own Devices",
|
||||
@@ -168,6 +223,18 @@
|
||||
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
||||
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Blur background",
|
||||
"setAsBackground": "Set as background image"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Remove sticker pack",
|
||||
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
|
||||
"installConfirmTitle": "Install sticker pack",
|
||||
"installConfirmBody": "Are you sure you want to install this sticker pack?",
|
||||
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
|
||||
"fetchingFailure": "Could not find the sticker pack"
|
||||
},
|
||||
"settings": {
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -177,12 +244,17 @@
|
||||
"signOutConfirmTitle": "Sign Out",
|
||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||
"miscellaneousSection": "Miscellaneous",
|
||||
"debuggingSection": "Debugging"
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "General"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"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": {
|
||||
"title": "Appearance",
|
||||
@@ -205,7 +277,10 @@
|
||||
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
||||
"newChatsSection": "New Conversations",
|
||||
"newChatsMuteByDefault": "Mute new chats by default",
|
||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental"
|
||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
|
||||
"behaviourSection": "Behaviour",
|
||||
"contactsIntegration": "Contacts integration",
|
||||
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debugging options",
|
||||
@@ -224,6 +299,7 @@
|
||||
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
||||
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
||||
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
||||
"automaticDownloadAlways": "Always",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Mobile data"
|
||||
},
|
||||
@@ -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.",
|
||||
"urlEmpty": "URL cannot be empty",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"image": "Bild",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "Datei",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||
"you": "Du"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
@@ -41,7 +68,13 @@
|
||||
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht"
|
||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||
|
||||
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
|
||||
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Verbindung zum Server nicht möglich"
|
||||
@@ -64,11 +97,19 @@
|
||||
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
|
||||
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
|
||||
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
|
||||
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
||||
"openFileGenericError": "Fehler beim Öffnen der Datei"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
@@ -100,6 +141,7 @@
|
||||
"closeChat": "Chat schließen",
|
||||
"closeChatConfirmTitle": "Chat schließen",
|
||||
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
|
||||
"blockShort": "Blockieren",
|
||||
"blockUser": "Nutzer blockieren",
|
||||
"online": "Online",
|
||||
"retract": "Nachricht löschen",
|
||||
@@ -107,7 +149,21 @@
|
||||
"forward": "Weiterleiten",
|
||||
"edit": "Bearbeiten",
|
||||
"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": {
|
||||
"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?"
|
||||
},
|
||||
"profile": {
|
||||
"self": {
|
||||
"devices": "Geräte"
|
||||
"general": {
|
||||
"omemo": "Sicherheit"
|
||||
},
|
||||
"conversation": {
|
||||
"muteChatTooltip": "Chat stummschalten",
|
||||
"unmuteChatTooltip": "Chat lautstellen",
|
||||
"muteChat": "Stummschalten",
|
||||
"unmuteChat": "Lautstellen",
|
||||
"devices": "Geräte"
|
||||
"notifications": "Benachrichtigungen",
|
||||
"notificationsMuted": "Stumm",
|
||||
"notificationsEnabled": "Eingeschaltet",
|
||||
"sharedMedia": "Medien"
|
||||
},
|
||||
"owndevices": {
|
||||
"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?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Devices",
|
||||
"recreateSessions": "Rebuild sessions",
|
||||
"recreateSessionsConfirmTitle": "Rebuild sessions?",
|
||||
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors."
|
||||
"title": "Geräte",
|
||||
"recreateSessions": "Sessions zurücksetzen",
|
||||
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
|
||||
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
@@ -168,6 +223,18 @@
|
||||
"unblockJidConfirmTitle": "${jid} entblocken?",
|
||||
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Hintergrund weichzeichnen",
|
||||
"setAsBackground": "Als Hintergrundbild festlegen"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Stickerpack entfernen",
|
||||
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
|
||||
"installConfirmTitle": "Stickerpack installieren",
|
||||
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
|
||||
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
|
||||
"fetchingFailure": "Konnte das Stickerpack nicht finden"
|
||||
},
|
||||
"settings": {
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
@@ -177,12 +244,17 @@
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
"miscellaneousSection": "Unterschiedlich",
|
||||
"debuggingSection": "Debugging"
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "Generell"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"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": {
|
||||
"title": "Aussehen",
|
||||
@@ -205,7 +277,10 @@
|
||||
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
||||
"newChatsSection": "Neue Chats",
|
||||
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell"
|
||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
|
||||
"behaviourSection": "Verhalten",
|
||||
"contactsIntegration": "Kontaktintegration",
|
||||
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debuggingoptionen",
|
||||
@@ -224,6 +299,7 @@
|
||||
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
|
||||
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
||||
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
||||
"automaticDownloadAlways": "Immer",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Mobile Daten"
|
||||
},
|
||||
@@ -249,7 +325,20 @@
|
||||
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
||||
"urlEmpty": "URL kann nicht leer sein",
|
||||
"urlInvalid": "Ungültige URL",
|
||||
"redirectDialogTitle": "${serviceName}weiterleitung"
|
||||
"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>Chat backgrounds</li>
|
||||
<li>Runs in the background without Push Notifications</li>
|
||||
<li>OMEMO (Currently not compatible with most apps)</li>
|
||||
<li>Stickers</li>
|
||||
</ul>
|
||||
|
||||
For the best experience, I recommend a server that:
|
||||
<ul>
|
||||
<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 Stream Management</li>
|
||||
<li>Supports Client State Indication</li>
|
||||
|
||||
@@ -36,6 +36,9 @@ files:
|
||||
roster:
|
||||
type: List<RosterItem>?
|
||||
deserialise: true
|
||||
stickers:
|
||||
type: List<StickerPack>?
|
||||
deserialise: true
|
||||
# Returned by [GetMessagesForJidCommand]
|
||||
- name: MessagesResultEvent
|
||||
extends: BackgroundEvent
|
||||
@@ -103,6 +106,13 @@ files:
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
# Triggered in response to a [GetBlocklistCommand]
|
||||
- name: GetBlocklistResultEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
entries: List<String>
|
||||
# Triggered by DownloadService or UploadService.
|
||||
- name: ProgressEvent
|
||||
extends: BackgroundEvent
|
||||
@@ -163,6 +173,7 @@ files:
|
||||
supportsCsi: bool
|
||||
supportsUserBlocking: bool
|
||||
supportsHttpFileUpload: bool
|
||||
supportsCarbons: bool
|
||||
# Returned by [SignOutCommand]
|
||||
- name: SignedOutEvent
|
||||
extends: BackgroundEvent
|
||||
@@ -207,6 +218,53 @@ files:
|
||||
conversationJid: String
|
||||
title: 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
|
||||
builder_name: "Event"
|
||||
builder_baseclass: "BackgroundEvent"
|
||||
@@ -259,6 +317,8 @@ files:
|
||||
quotedMessage:
|
||||
type: Message?
|
||||
deserialise: true
|
||||
editSid: String?
|
||||
editId: int?
|
||||
- name: SendFilesCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -416,6 +476,69 @@ files:
|
||||
conversationJid: String
|
||||
sid: String
|
||||
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
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
@@ -26,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/share_selection_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/events.dart';
|
||||
/*
|
||||
@@ -54,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/privacy/privacy.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/sharedmedia.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/progress.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
void setupLogging() {
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
@@ -75,6 +81,7 @@ void setupLogging() {
|
||||
Future<void> setupUIServices() async {
|
||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||
}
|
||||
|
||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
@@ -82,7 +89,8 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
||||
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
||||
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc()); GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
||||
@@ -93,14 +101,11 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
|
||||
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
|
||||
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 {
|
||||
GetIt.I.registerSingleton<Completer<void>>(Completer());
|
||||
|
||||
setupLogging();
|
||||
await setupUIServices();
|
||||
|
||||
@@ -165,6 +170,12 @@ void main() async {
|
||||
BlocProvider<OwnDevicesBloc>(
|
||||
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
|
||||
),
|
||||
BlocProvider<StickersBloc>(
|
||||
create: (_) => GetIt.I.get<StickersBloc>(),
|
||||
),
|
||||
BlocProvider<StickerPackBloc>(
|
||||
create: (_) => GetIt.I.get<StickerPackBloc>(),
|
||||
),
|
||||
],
|
||||
child: TranslationProvider(
|
||||
child: MyApp(navKey),
|
||||
@@ -174,7 +185,6 @@ void main() async {
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
|
||||
const MyApp(this.navigationKey, { super.key });
|
||||
final GlobalKey<NavigatorState> navigationKey;
|
||||
|
||||
@@ -188,46 +198,18 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initState();
|
||||
}
|
||||
|
||||
/// Async "version" of initState()
|
||||
Future<void> _initState() async {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
// Set up receiving share intents
|
||||
await GetIt.I.get<UISharingService>().initialize();
|
||||
|
||||
// Lift the UI block
|
||||
GetIt.I.get<Completer<void>>().complete();
|
||||
|
||||
_setupSharingHandler();
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -300,6 +282,11 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
case devicesRoute: return DevicesPage.route;
|
||||
case ownDevicesRoute: return OwnDevicesPage.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;
|
||||
|
||||
@@ -3,9 +3,7 @@ import 'dart:io';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:image_size_getter/image_size_getter.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
@@ -14,6 +12,7 @@ import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/avatar.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
|
||||
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
||||
/// avatar data. Returns the cleaned version.
|
||||
@@ -26,56 +25,48 @@ String _cleanBase64String(String original) {
|
||||
return ret;
|
||||
}
|
||||
|
||||
class _AvatarData {
|
||||
const _AvatarData(this.data, this.id);
|
||||
final List<int> data;
|
||||
final String id;
|
||||
}
|
||||
|
||||
class AvatarService {
|
||||
final Logger _log = Logger('AvatarService');
|
||||
|
||||
AvatarService() : _log = Logger('AvatarService');
|
||||
final Logger _log;
|
||||
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
||||
await updateAvatarForJid(
|
||||
event.jid,
|
||||
event.hash,
|
||||
base64Decode(_cleanBase64String(event.base64)),
|
||||
);
|
||||
}
|
||||
|
||||
UserAvatarManager _getUserAvatarManager() => GetIt.I.get<XmppConnection>().getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
|
||||
DiscoManager _getDiscoManager() => GetIt.I.get<XmppConnection>().getManagerById<DiscoManager>(discoManager)!;
|
||||
|
||||
Future<void> updateAvatarForJid(String jid, String hash, String base64) async {
|
||||
Future<void> updateAvatarForJid(String jid, String hash, List<int> data) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final originalConversation = await cs.getConversationByJid(jid);
|
||||
var saved = false;
|
||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||
|
||||
if (originalConversation == null && originalRoster == null) return;
|
||||
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
data,
|
||||
hash,
|
||||
jid,
|
||||
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
||||
);
|
||||
|
||||
// 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(
|
||||
base64Data,
|
||||
hash,
|
||||
jid,
|
||||
originalConversation.avatarUrl,
|
||||
);
|
||||
saved = true;
|
||||
final conv = await cs.updateConversation(
|
||||
originalConversation.id,
|
||||
avatarUrl: avatarPath,
|
||||
);
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: conv));
|
||||
} else {
|
||||
_log.warning('Failed to get conversation');
|
||||
}
|
||||
|
||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||
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(
|
||||
originalRoster.id,
|
||||
avatarUrl: avatarPath,
|
||||
@@ -86,65 +77,72 @@ class AvatarService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final idResult = await am.getAvatarId(jid);
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
if (id == oldHash) return null;
|
||||
|
||||
final avatarResult = await am.getUserAvatar(jid);
|
||||
if (avatarResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final avatar = avatarResult.get<UserAvatar>();
|
||||
|
||||
return _AvatarData(
|
||||
base64Decode(_cleanBase64String(avatar.base64)),
|
||||
avatar.hash,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
|
||||
// Query the vCard
|
||||
final vm = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<VCardManager>(vcardManager)!;
|
||||
final vcardResult = await vm.requestVCard(jid);
|
||||
if (vcardResult.isType<VCardError>()) return null;
|
||||
|
||||
final binval = vcardResult.get<VCard>().photo?.binval;
|
||||
if (binval == null) return null;
|
||||
|
||||
final data = base64Decode(_cleanBase64String(binval));
|
||||
final rawHash = await Sha1().hash(data);
|
||||
final hash = HEX.encode(rawHash.bytes);
|
||||
|
||||
vm.setLastHash(jid, hash);
|
||||
|
||||
return _AvatarData(
|
||||
data,
|
||||
hash,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
||||
final response = await _getDiscoManager().discoItemsQuery(jid);
|
||||
final items = response.isType<DiscoError>() ?
|
||||
<DiscoItem>[] :
|
||||
response.get<List<DiscoItem>>();
|
||||
final itemNodes = items.map((i) => i.node);
|
||||
_AvatarData? data;
|
||||
data ??= await _handleUserAvatar(jid, oldHash);
|
||||
data ??= await _handleVcardAvatar(jid, oldHash);
|
||||
|
||||
_log.finest('Disco items for $jid:');
|
||||
for (final item in itemNodes) {
|
||||
_log.finest('- $item');
|
||||
if (data != null) {
|
||||
await updateAvatarForJid(jid, data.id, data.data);
|
||||
}
|
||||
|
||||
var base64 = '';
|
||||
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
|
||||
final vm = GetIt.I.get<XmppConnection>().getManagerById<VCardManager>(vcardManager)!;
|
||||
final vcard = await vm.requestVCard(jid);
|
||||
if (vcard != null) {
|
||||
final binval = vcard.photo?.binval;
|
||||
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));
|
||||
hash = HEX.encode(rawHash.bytes);
|
||||
|
||||
vm.setLastHash(jid, hash);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await updateAvatarForJid(jid, hash, base64);
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
@@ -158,59 +156,80 @@ class AvatarService {
|
||||
final public = prefs.isAvatarPublic;
|
||||
|
||||
// Read the image metadata
|
||||
final imageSize = ImageSizeGetter.getSize(MemoryInput(bytes));
|
||||
final imageSize = (await getImageSizeFromData(bytes))!;
|
||||
|
||||
// Publish data and metadata
|
||||
final manager = _getUserAvatarManager();
|
||||
await manager.publishUserAvatar(
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
|
||||
_log.finest('Publishing avatar...');
|
||||
final dataResult = await am.publishUserAvatar(
|
||||
base64,
|
||||
hash,
|
||||
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(
|
||||
hash,
|
||||
bytes.length,
|
||||
imageSize.width,
|
||||
imageSize.height,
|
||||
imageSize.width.toInt(),
|
||||
imageSize.height.toInt(),
|
||||
// TODO(PapaTutuWawa): Maybe do a check here
|
||||
'image/png',
|
||||
),
|
||||
public,
|
||||
);
|
||||
if (metadataResult.isType<AvatarError>()) {
|
||||
_log.finest('Avatar metadata publishing failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
_log.finest('Avatar publishing done');
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> requestOwnAvatar() async {
|
||||
final avatar = _getUserAvatarManager();
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final xmpp = GetIt.I.get<XmppService>();
|
||||
final state = await xmpp.getXmppState();
|
||||
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;
|
||||
|
||||
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself');
|
||||
final data = await avatar.getUserAvatar(jid);
|
||||
if (data == null) {
|
||||
final avatarDataResult = await am.getUserAvatar(jid);
|
||||
if (avatarDataResult.isType<AvatarError>()) {
|
||||
_log.severe('Failed to fetch our avatar');
|
||||
return;
|
||||
}
|
||||
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||
|
||||
_log.info('Received data for our own avatar');
|
||||
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
base64Decode(_cleanBase64String(data.base64)),
|
||||
data.hash,
|
||||
base64Decode(_cleanBase64String(avatarData.base64)),
|
||||
avatarData.hash,
|
||||
jid,
|
||||
state.avatarUrl,
|
||||
);
|
||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
||||
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:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
|
||||
@@ -9,35 +12,93 @@ enum BlockPushType {
|
||||
}
|
||||
|
||||
class BlocklistService {
|
||||
BlocklistService();
|
||||
List<String>? _blocklist;
|
||||
bool _requested = false;
|
||||
bool? _supported;
|
||||
final Logger _log = Logger('BlocklistService');
|
||||
|
||||
BlocklistService() :
|
||||
_blocklistCache = List.empty(growable: true),
|
||||
_requestedBlocklist = false;
|
||||
final List<String> _blocklistCache;
|
||||
bool _requestedBlocklist;
|
||||
void onNewConnection() {
|
||||
// Invalidate the caches
|
||||
_blocklist = null;
|
||||
_requested = false;
|
||||
_supported = null;
|
||||
}
|
||||
|
||||
Future<List<String>> _requestBlocklist() async {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
_blocklistCache
|
||||
..clear()
|
||||
..addAll(await manager.getBlocklist());
|
||||
_requestedBlocklist = true;
|
||||
return _blocklistCache;
|
||||
Future<bool> _checkSupport() async {
|
||||
return _supported ??= await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.isSupported();
|
||||
}
|
||||
|
||||
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
|
||||
Future<List<String>> getBlocklist() async {
|
||||
if (!_requestedBlocklist) {
|
||||
_blocklistCache
|
||||
..clear()
|
||||
..addAll(await _requestBlocklist());
|
||||
if (_blocklist == null) {
|
||||
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries();
|
||||
|
||||
if (!_requested) {
|
||||
unawaited(_requestBlocklist());
|
||||
}
|
||||
|
||||
return _blocklist!;
|
||||
}
|
||||
|
||||
return _blocklistCache;
|
||||
if (!_requested) {
|
||||
unawaited(_requestBlocklist());
|
||||
}
|
||||
|
||||
return _blocklist!;
|
||||
}
|
||||
|
||||
void onUnblockAllPush() {
|
||||
_blocklistCache.clear();
|
||||
_blocklist = List<String>.empty(growable: true);
|
||||
sendEvent(
|
||||
BlocklistUnblockAllEvent(),
|
||||
);
|
||||
@@ -45,21 +106,25 @@ class BlocklistService {
|
||||
|
||||
Future<void> onBlocklistPush(BlockPushType type, List<String> items) async {
|
||||
// We will fetch it later when getBlocklist is called
|
||||
if (!_requestedBlocklist) return;
|
||||
if (!_requested) return;
|
||||
|
||||
final newBlocks = List<String>.empty(growable: true);
|
||||
final removedBlocks = List<String>.empty(growable: true);
|
||||
for (final item in items) {
|
||||
switch (type) {
|
||||
case BlockPushType.block: {
|
||||
if (_blocklistCache.contains(item)) continue;
|
||||
_blocklistCache.add(item);
|
||||
if (_blocklist!.contains(item)) continue;
|
||||
_blocklist!.add(item);
|
||||
newBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
case BlockPushType.unblock: {
|
||||
_blocklistCache.removeWhere((i) => i == item);
|
||||
_blocklist!.removeWhere((i) => i == item);
|
||||
removedBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -74,17 +139,47 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
Future<bool> blockJid(String jid) async {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
return manager.block([ jid ]);
|
||||
// Check if blocking is supported
|
||||
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 {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
return manager.unblock([ jid ]);
|
||||
// Check if blocking is supported
|
||||
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 {
|
||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
||||
return manager.unblockAll();
|
||||
// Check if blocking is supported
|
||||
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,9 +2,11 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/shared/cache.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
|
||||
class ConversationService {
|
||||
ConversationService()
|
||||
@@ -55,32 +57,39 @@ class ConversationService {
|
||||
|
||||
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache.
|
||||
Future<Conversation> updateConversation(int id, {
|
||||
String? lastMessageBody,
|
||||
int? lastChangeTimestamp,
|
||||
bool? lastMessageRetracted,
|
||||
int? lastMessageId,
|
||||
Message? lastMessage,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}) async {
|
||||
final conversation = await _getConversationById(id);
|
||||
final newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
||||
final conversation = (await _getConversationById(id))!;
|
||||
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
||||
id,
|
||||
lastMessageBody: lastMessageBody,
|
||||
lastMessageRetracted: lastMessageRetracted,
|
||||
lastMessageId: lastMessageId,
|
||||
lastMessage: lastMessage,
|
||||
lastChangeTimestamp: lastChangeTimestamp,
|
||||
open: open,
|
||||
unreadCounter: unreadCounter,
|
||||
avatarUrl: avatarUrl,
|
||||
chatState: conversation?.chatState ?? ChatState.gone,
|
||||
chatState: conversation.chatState,
|
||||
muted: muted,
|
||||
encrypted: encrypted,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
// Copy over the old lastMessage if a new one was not set
|
||||
if (conversation.lastMessage != null && lastMessage == null) {
|
||||
newConversation = newConversation.copyWith(lastMessage: conversation.lastMessage);
|
||||
}
|
||||
|
||||
_conversationCache.cache(id, newConversation);
|
||||
return newConversation;
|
||||
}
|
||||
@@ -88,9 +97,7 @@ class ConversationService {
|
||||
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the cache.
|
||||
Future<Conversation> addConversationFromData(
|
||||
String title,
|
||||
int lastMessageId,
|
||||
bool lastMessageRetracted,
|
||||
String lastMessageBody,
|
||||
Message? lastMessage,
|
||||
String avatarUrl,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
@@ -98,12 +105,13 @@ class ConversationService {
|
||||
bool open,
|
||||
bool muted,
|
||||
bool encrypted,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
) async {
|
||||
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
||||
title,
|
||||
lastMessageId,
|
||||
lastMessageRetracted,
|
||||
lastMessageBody,
|
||||
lastMessage,
|
||||
avatarUrl,
|
||||
jid,
|
||||
unreadCounter,
|
||||
@@ -111,6 +119,9 @@ class ConversationService {
|
||||
open,
|
||||
muted,
|
||||
encrypted,
|
||||
contactId,
|
||||
contactAvatarPath,
|
||||
contactDisplayName,
|
||||
);
|
||||
|
||||
_conversationCache.cache(newConversation.id, newConversation);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const conversationsTable = 'Conversations';
|
||||
const messsagesTable = 'Messages';
|
||||
const messagesTable = 'Messages';
|
||||
const rosterTable = 'RosterItems';
|
||||
const mediaTable = 'SharedMedia';
|
||||
const preferenceTable = 'Preferences';
|
||||
@@ -9,7 +9,12 @@ const omemoRatchetsTable = 'OmemoSessions';
|
||||
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
||||
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
||||
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
||||
const omemoFingerprintCache = 'OmemoFingerprintCache';
|
||||
const xmppStateTable = 'XmppState';
|
||||
const contactsTable = 'Contacts';
|
||||
const stickersTable = 'Stickers';
|
||||
const stickerPacksTable = 'StickerPacks';
|
||||
const blocklistTable = 'Blocklist';
|
||||
|
||||
const typeString = 0;
|
||||
const typeInt = 1;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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> configureDatabase(Database db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
await db.execute('PRAGMA foreign_keys = OFF');
|
||||
}
|
||||
|
||||
Future<void> createDatabase(Database db, int version) async {
|
||||
@@ -19,7 +20,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
// Messages
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $messsagesTable (
|
||||
CREATE TABLE $messagesTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
body TEXT,
|
||||
@@ -52,7 +53,14 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
isUploading INTEGER NOT NULL,
|
||||
mediaSize INTEGER,
|
||||
isRetracted INTEGER,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messsagesTable (id)
|
||||
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)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -66,15 +74,28 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
avatarUrl TEXT NOT NULL,
|
||||
lastChangeTimestamp INTEGER NOT NULL,
|
||||
unreadCounter INTEGER NOT NULL,
|
||||
lastMessageBody TEXT NOT NULL,
|
||||
open INTEGER NOT NULL,
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER NOT NULL,
|
||||
lastMessageRetracted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER,
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id),
|
||||
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
// Contacts
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $contactsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL
|
||||
)'''
|
||||
);
|
||||
|
||||
// Shared media
|
||||
await db.execute(
|
||||
'''
|
||||
@@ -86,7 +107,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
conversation_id INTEGER NOT NULL,
|
||||
message_id INTEGER,
|
||||
FOREIGN KEY (conversation_id) REFERENCES $conversationsTable (id),
|
||||
FOREIGN KEY (message_id) REFERENCES $messsagesTable (id)
|
||||
FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -100,10 +121,56 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
avatarUrl TEXT NOT NULL,
|
||||
avatarHash 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
|
||||
await db.execute(
|
||||
'''
|
||||
@@ -158,7 +225,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
PRIMARY KEY (jid, id)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceListTable (
|
||||
jid TEXT NOT NULL,
|
||||
@@ -166,6 +233,15 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
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
|
||||
await db.execute(
|
||||
@@ -336,4 +412,28 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
'default',
|
||||
).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,20 +8,45 @@ import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/creation.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0000_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_conversations2.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_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_conversation.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/0001_debug_menu.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
import 'package:moxxyv2/service/not_specified.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/state.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.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:path/path.dart' as path;
|
||||
import 'package:random_string/random_string.dart';
|
||||
@@ -58,9 +83,18 @@ class DatabaseService {
|
||||
_db = await openDatabase(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 6,
|
||||
version: 26,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: configureDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
// keys in the onConfigure phase, but re-enable them here.
|
||||
// See https://github.com/tekartik/sqflite/issues/624#issuecomment-813324273
|
||||
// for the "solution".
|
||||
await db.execute('PRAGMA foreign_keys = OFF');
|
||||
},
|
||||
onOpen: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
if (oldVersion < 2) {
|
||||
_log.finest('Running migration for database version 2');
|
||||
@@ -82,6 +116,86 @@ class DatabaseService {
|
||||
_log.finest('Running migration for database version 6');
|
||||
await upgradeFromV5ToV6(db);
|
||||
}
|
||||
if (oldVersion < 7) {
|
||||
_log.finest('Running migration for database version 7');
|
||||
await upgradeFromV6ToV7(db);
|
||||
}
|
||||
if (oldVersion < 8) {
|
||||
_log.finest('Running migration for database version 8');
|
||||
await upgradeFromV7ToV8(db);
|
||||
}
|
||||
if (oldVersion < 9) {
|
||||
_log.finest('Running migration for database version 9');
|
||||
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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -107,18 +221,22 @@ class DatabaseService {
|
||||
final rosterItem = await GetIt.I.get<RosterService>()
|
||||
.getRosterItemByJid(c['jid']! as String);
|
||||
|
||||
Message? lastMessage;
|
||||
if (c['lastMessageId'] != null) {
|
||||
lastMessage = await getMessageById(c['lastMessageId']! as int);
|
||||
}
|
||||
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem != null,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
sharedMediaRaw,
|
||||
lastMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_log.finest(tmp.toString());
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
@@ -151,16 +269,17 @@ class DatabaseService {
|
||||
|
||||
/// Updates the conversation with id [id] inside the database.
|
||||
Future<Conversation> updateConversation(int id, {
|
||||
String? lastMessageBody,
|
||||
int? lastChangeTimestamp,
|
||||
bool? lastMessageRetracted,
|
||||
int? lastMessageId,
|
||||
Message? lastMessage,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}) async {
|
||||
final cd = (await _db.query(
|
||||
'Conversations',
|
||||
@@ -176,15 +295,8 @@ class DatabaseService {
|
||||
orderBy: 'timestamp DESC',
|
||||
)).map(SharedMedium.fromDatabaseJson);
|
||||
|
||||
//await c.sharedMedia.load();
|
||||
if (lastMessageBody != null) {
|
||||
c['lastMessageBody'] = lastMessageBody;
|
||||
}
|
||||
if (lastMessageRetracted != null) {
|
||||
c['lastMessageRetracted'] = boolToInt(lastMessageRetracted);
|
||||
}
|
||||
if (lastMessageId != null) {
|
||||
c['lastMessageId'] = lastMessageId;
|
||||
if (lastMessage != null) {
|
||||
c['lastMessageId'] = lastMessage.id;
|
||||
}
|
||||
if (lastChangeTimestamp != null) {
|
||||
c['lastChangeTimestamp'] = lastChangeTimestamp;
|
||||
@@ -204,6 +316,15 @@ class DatabaseService {
|
||||
if (encrypted != null) {
|
||||
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(
|
||||
'Conversations',
|
||||
@@ -218,6 +339,7 @@ class DatabaseService {
|
||||
rosterItem != null,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
sharedMedia.map((m) => m.toJson()).toList(),
|
||||
lastMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -225,9 +347,7 @@ class DatabaseService {
|
||||
/// [Conversation] object can carry its database id.
|
||||
Future<Conversation> addConversationFromData(
|
||||
String title,
|
||||
int lastMessageId,
|
||||
bool lastMessageRetracted,
|
||||
String lastMessageBody,
|
||||
Message? lastMessage,
|
||||
String avatarUrl,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
@@ -235,13 +355,14 @@ class DatabaseService {
|
||||
bool open,
|
||||
bool muted,
|
||||
bool encrypted,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
) async {
|
||||
final rosterItem = await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
final conversation = Conversation(
|
||||
title,
|
||||
lastMessageId,
|
||||
lastMessageRetracted,
|
||||
lastMessageBody,
|
||||
lastMessage,
|
||||
avatarUrl,
|
||||
jid,
|
||||
unreadCounter,
|
||||
@@ -249,11 +370,14 @@ class DatabaseService {
|
||||
<SharedMedium>[],
|
||||
-1,
|
||||
open,
|
||||
rosterItem != null,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
muted,
|
||||
encrypted,
|
||||
ChatState.gone,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
return conversation.copyWith(
|
||||
@@ -296,6 +420,7 @@ class DatabaseService {
|
||||
String sid,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
bool containsNoStore,
|
||||
{
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
@@ -316,6 +441,10 @@ class DatabaseService {
|
||||
bool isDownloading = false,
|
||||
bool isUploading = false,
|
||||
int? mediaSize,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}
|
||||
) async {
|
||||
var m = Message(
|
||||
@@ -328,6 +457,7 @@ class DatabaseService {
|
||||
isMedia,
|
||||
isFileUploadNotification,
|
||||
encrypted,
|
||||
containsNoStore,
|
||||
errorType: errorType,
|
||||
warningType: warningType,
|
||||
mediaUrl: mediaUrl,
|
||||
@@ -349,6 +479,10 @@ class DatabaseService {
|
||||
isUploading: isUploading,
|
||||
isDownloading: isDownloading,
|
||||
mediaSize: mediaSize,
|
||||
stickerPackId: stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
);
|
||||
|
||||
if (quoteId != null) {
|
||||
@@ -435,6 +569,8 @@ class DatabaseService {
|
||||
Object? sid = notSpecified,
|
||||
bool? isRetracted,
|
||||
Object? thumbnailData = notSpecified,
|
||||
bool? isEdited,
|
||||
Object? reactions = notSpecified,
|
||||
}) async {
|
||||
final md = (await _db.query(
|
||||
'Messages',
|
||||
@@ -444,6 +580,9 @@ class DatabaseService {
|
||||
)).first;
|
||||
final m = Map<String, dynamic>.from(md);
|
||||
|
||||
if (body != notSpecified) {
|
||||
m['body'] = body as String?;
|
||||
}
|
||||
if (mediaUrl != notSpecified) {
|
||||
m['mediaUrl'] = mediaUrl as String?;
|
||||
}
|
||||
@@ -510,6 +649,17 @@ class DatabaseService {
|
||||
if (thumbnailData != notSpecified) {
|
||||
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(
|
||||
'Messages',
|
||||
@@ -550,6 +700,10 @@ class DatabaseService {
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups = const [],
|
||||
}
|
||||
@@ -563,7 +717,11 @@ class DatabaseService {
|
||||
title,
|
||||
subscription,
|
||||
ask,
|
||||
pseudoRosterItem,
|
||||
<String>[],
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
return i.copyWith(
|
||||
@@ -579,11 +737,15 @@ class DatabaseService {
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem = notSpecified,
|
||||
List<String>? groups,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}
|
||||
) async {
|
||||
final id_ = (await _db.query(
|
||||
'RosterItems',
|
||||
rosterTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
limit: 1,
|
||||
@@ -610,9 +772,21 @@ class DatabaseService {
|
||||
if (ask != null) {
|
||||
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(
|
||||
'RosterItems',
|
||||
rosterTable,
|
||||
i,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
@@ -865,7 +1039,7 @@ class DatabaseService {
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> saveOmemoDevice(Device device) async {
|
||||
Future<void> saveOmemoDevice(OmemoDevice device) async {
|
||||
await _db.insert(
|
||||
omemoDeviceTable,
|
||||
{
|
||||
@@ -877,7 +1051,7 @@ class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<Device?> loadOmemoDevice(String jid) async {
|
||||
Future<OmemoDevice?> loadOmemoDevice(String jid) async {
|
||||
final data = await _db.query(
|
||||
omemoDeviceTable,
|
||||
where: 'jid = ?',
|
||||
@@ -900,7 +1074,7 @@ class DatabaseService {
|
||||
});
|
||||
}
|
||||
deviceJson['opks'] = opks;
|
||||
return Device.fromJson(deviceJson);
|
||||
return OmemoDevice.fromJson(deviceJson);
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> loadOmemoDeviceList() async {
|
||||
@@ -952,4 +1126,190 @@ class DatabaseService {
|
||||
|
||||
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)};',
|
||||
);
|
||||
}
|
||||
12
lib/service/database/migrations/0000_conversations.dart
Normal file
12
lib/service/database/migrations/0000_conversations.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV6ToV7(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable ADD COLUMN lastMessageState INTEGER NOT NULL DEFAULT 0;'
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';"
|
||||
);
|
||||
}
|
||||
17
lib/service/database/migrations/0000_conversations2.dart
Normal file
17
lib/service/database/migrations/0000_conversations2.dart
Normal file
@@ -0,0 +1,17 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV7ToV8(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageState;'
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageSender;"
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageBody;"
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageRetracted;"
|
||||
);
|
||||
}
|
||||
43
lib/service/database/migrations/0000_conversations3.dart
Normal file
43
lib/service/database/migrations/0000_conversations3.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV8ToV9(Database db) async {
|
||||
// Step 1
|
||||
//await db.execute('PRAGMA foreign_keys = 0;');
|
||||
|
||||
// Step 2
|
||||
// Step 4
|
||||
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,
|
||||
CONSTRAINT fk_last_message FOREIGN KEY (lastMessageId) REFERENCES $messagesTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
// Step 5
|
||||
await db.execute('INSERT INTO ${conversationsTable}_new SELECT * from $conversationsTable');
|
||||
|
||||
// Step 6
|
||||
await db.execute('DROP TABLE $conversationsTable;');
|
||||
|
||||
// Step 7
|
||||
await db.execute('ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;');
|
||||
|
||||
// Step 10
|
||||
//await db.execute('PRAGMA foreign_key_check;');
|
||||
|
||||
// Step 11
|
||||
|
||||
// Step 12
|
||||
//await db.execute('PRAGMA foreign_keys=ON;');
|
||||
}
|
||||
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)};'
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
Future<void> upgradeFromV3ToV4(Database db) async {
|
||||
// Mark all messages as not retracted
|
||||
await db.execute(
|
||||
'ALTER TABLE $messsagesTable ADD COLUMN isRetracted INTEGER DEFAULT ${boolToInt(false)};',
|
||||
'ALTER TABLE $messagesTable ADD COLUMN isRetracted INTEGER DEFAULT ${boolToInt(false)};',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,6 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
Future<void> upgradeFromV5ToV6(Database db) async {
|
||||
// Allow shared media to reference a message
|
||||
await db.execute(
|
||||
'ALTER TABLE $mediaTable ADD COLUMN message_id INTEGER REFERENCES $messsagesTable (id);',
|
||||
'ALTER TABLE $mediaTable ADD COLUMN message_id INTEGER REFERENCES $messagesTable (id);',
|
||||
);
|
||||
}
|
||||
|
||||
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/service/avatars.dart';
|
||||
import 'package:moxxyv2/service/blocking.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/helpers.dart';
|
||||
@@ -23,12 +24,17 @@ import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/state.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/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:permission_handler/permission_handler.dart';
|
||||
|
||||
void setupBackgroundEventHandler() {
|
||||
@@ -65,9 +71,21 @@ void setupBackgroundEventHandler() {
|
||||
EventTypeMatcher<RetractMessageCommentCommand>(performMessageRetraction),
|
||||
EventTypeMatcher<MarkConversationAsReadCommand>(performMarkConversationAsRead),
|
||||
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<SynchronizedQueue<Map<String, dynamic>?>>(
|
||||
SynchronizedQueue<Map<String, dynamic>?>(handleUIEvent),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performLogin(LoginCommand command, { dynamic extra }) async {
|
||||
@@ -122,7 +140,7 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
|
||||
permissions.add(Permission.storage.value);
|
||||
|
||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
||||
askedStoragePermission: true,
|
||||
askedStoragePermission: true,
|
||||
),);
|
||||
}
|
||||
|
||||
@@ -136,18 +154,12 @@ Future<PreStartDoneEvent> _buildPreStartDoneEvent(PreferencesState preferences)
|
||||
preferences: preferences,
|
||||
conversations: (await GetIt.I.get<DatabaseService>().loadConversations()).where((c) => c.open).toList(),
|
||||
roster: await GetIt.I.get<RosterService>().loadRosterFromDatabase(),
|
||||
stickers: await GetIt.I.get<StickersService>().getStickerPacks(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> performPreStart(PerformPreStartCommand command, { dynamic extra }) async {
|
||||
final id = extra as String;
|
||||
|
||||
// Prevent a race condition where the UI sends the prestart command before the service
|
||||
// has finished setting everything up
|
||||
GetIt.I.get<Logger>().finest('Waiting for preStart future to complete..');
|
||||
await GetIt.I.get<Completer<void>>().future;
|
||||
GetIt.I.get<Logger>().finest('PreStart future done');
|
||||
|
||||
final preferences = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
|
||||
// Set the locale very early
|
||||
@@ -190,6 +202,7 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
||||
final updatedConversation = await cs.updateConversation(
|
||||
conversation.id,
|
||||
open: true,
|
||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
@@ -207,19 +220,22 @@ Future<void> performAddConversation(AddConversationCommand command, { dynamic ex
|
||||
);
|
||||
return;
|
||||
} 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(
|
||||
command.title,
|
||||
-1,
|
||||
false,
|
||||
command.lastMessageBody,
|
||||
null,
|
||||
command.avatarUrl,
|
||||
command.jid,
|
||||
0,
|
||||
-1,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
true,
|
||||
// TODO(PapaTutuWawa): Take as an argument
|
||||
false,
|
||||
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
|
||||
preferences.defaultMuteState,
|
||||
preferences.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(command.jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
@@ -252,7 +268,23 @@ Future<void> performSetOpenConversation(SetOpenConversationCommand command, { dy
|
||||
}
|
||||
|
||||
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,
|
||||
recipients: command.recipients,
|
||||
chatState: command.chatState.isNotEmpty
|
||||
@@ -292,7 +324,9 @@ Future<void> performSetCSIState(SetCSIStateCommand command, { dynamic extra }) a
|
||||
}
|
||||
|
||||
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
|
||||
if (!kDebugMode) {
|
||||
@@ -300,6 +334,52 @@ Future<void> performSetPreferences(SetPreferencesCommand command, { dynamic extr
|
||||
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
|
||||
final locale = command.preferences.languageLocaleCode == 'default' ?
|
||||
GetIt.I.get<LanguageService>().defaultLocale :
|
||||
@@ -326,6 +406,7 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
||||
final c = await cs.updateConversation(
|
||||
conversation.id,
|
||||
open: true,
|
||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
|
||||
sendEvent(
|
||||
@@ -333,19 +414,22 @@ Future<void> performAddContact(AddContactCommand command, { dynamic extra }) asy
|
||||
id: id,
|
||||
);
|
||||
} 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(
|
||||
jid.split('@')[0],
|
||||
-1,
|
||||
false,
|
||||
'',
|
||||
null,
|
||||
'',
|
||||
jid,
|
||||
0,
|
||||
-1,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
true,
|
||||
// TODO(PapaTutuWawa): Take as an argument
|
||||
false,
|
||||
(await GetIt.I.get<PreferencesService>().getPreferences()).enableOmemoByDefault,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
sendEvent(
|
||||
AddContactResultEvent(conversation: c, added: true),
|
||||
@@ -466,12 +550,14 @@ Future<void> performGetFeatures(GetFeaturesCommand command, { dynamic extra }) a
|
||||
final csi = conn.getNegotiatorById<CSINegotiator>(csiNegotiator)!;
|
||||
final httpFileUpload = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
||||
final userBlocking = conn.getManagerById<BlockingManager>(blockingManager)!;
|
||||
final carbons = conn.getManagerById<CarbonsManager>(carbonsManager)!;
|
||||
sendEvent(
|
||||
GetFeaturesEvent(
|
||||
supportsStreamManagement: sm.isSupported,
|
||||
supportsCsi: csi.isSupported,
|
||||
supportsHttpFileUpload: await httpFileUpload.isSupported(),
|
||||
supportsUserBlocking: await userBlocking.isSupported(),
|
||||
supportsCarbons: await carbons.isSupported(),
|
||||
),
|
||||
id: id,
|
||||
);
|
||||
@@ -534,9 +620,8 @@ Future<void> performRecreateSessions(RecreateSessionsCommand command, { dynamic
|
||||
await GetIt.I.get<OmemoService>().removeAllSessions(command.jid);
|
||||
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
await conn.getManagerById<OmemoManager>(omemoManager)!.sendEmptyMessage(
|
||||
JID.fromString(command.jid),
|
||||
findNewSessions: true,
|
||||
await conn.getManagerById<BaseOmemoManager>(omemoManager)!.sendOmemoHeartbeat(
|
||||
command.jid,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -568,7 +653,7 @@ Future<void> performGetOwnOmemoFingerprints(GetOwnOmemoFingerprintsCommand comma
|
||||
|
||||
Future<void> performRemoveOwnDevice(RemoveOwnDeviceCommand command, { dynamic extra }) async {
|
||||
await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!
|
||||
.getManagerById<BaseOmemoManager>(omemoManager)!
|
||||
.deleteDevice(command.deviceId);
|
||||
}
|
||||
|
||||
@@ -621,14 +706,10 @@ Future<void> performMarkConversationAsRead(MarkConversationAsReadCommand command
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: conversation));
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().getMessageById(
|
||||
conversation.jid,
|
||||
conversation.lastMessageId,
|
||||
);
|
||||
if (msg != null) {
|
||||
if (conversation.lastMessage != null) {
|
||||
await GetIt.I.get<XmppService>().sendReadMarker(
|
||||
conversation.jid,
|
||||
msg.sid,
|
||||
conversation.lastMessage!.sid,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -647,3 +728,208 @@ Future<void> performMarkMessageAsRead(MarkMessageAsReadCommand command, { dynami
|
||||
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;
|
||||
}
|
||||
|
||||
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 'package:dio/dio.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
@@ -43,7 +43,6 @@ bool isRequestOkay(int? statusCode) {
|
||||
}
|
||||
|
||||
class FileMetadata {
|
||||
|
||||
const FileMetadata({ this.mime, this.size });
|
||||
final String? mime;
|
||||
final int? size;
|
||||
@@ -53,15 +52,10 @@ class FileMetadata {
|
||||
/// does not specify the Content-Length header, null is returned.
|
||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||
Future<FileMetadata> peekFile(String url) async {
|
||||
final response = await Dio().headUri<dynamic>(Uri.parse(url));
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
|
||||
|
||||
final contentLengthHeaders = response.headers['Content-Length'];
|
||||
final contentTypeHeaders = response.headers['Content-Type'];
|
||||
final result = await peekUrl(Uri.parse(url));
|
||||
|
||||
return FileMetadata(
|
||||
mime: contentTypeHeaders?.first,
|
||||
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null,
|
||||
mime: result?.contentType,
|
||||
size: result?.contentLength,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:dio/dio.dart' as dio;
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:image_size_getter/file_input.dart';
|
||||
import 'package:image_size_getter/image_size_getter.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mime/mime.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/types.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/jobs.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/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -103,19 +102,17 @@ class HttpFileTransferService {
|
||||
|
||||
/// Queue the download job [job] to be performed.
|
||||
Future<void> downloadFile(FileDownloadJob job) async {
|
||||
var canDownload = false;
|
||||
await _uploadLock.synchronized(() async {
|
||||
if (_currentDownloadJob != null) {
|
||||
_log.finest('Queuing up download task.');
|
||||
_downloadQueue.add(job);
|
||||
} else {
|
||||
_log.finest('Executing download task.');
|
||||
_currentDownloadJob = job;
|
||||
canDownload = true;
|
||||
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
});
|
||||
|
||||
if (canDownload) {
|
||||
unawaited(_performFileDownload(job));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _copyFile(FileUploadJob job) async {
|
||||
@@ -184,7 +181,6 @@ class HttpFileTransferService {
|
||||
}
|
||||
|
||||
final file = File(path);
|
||||
final data = await file.readAsBytes();
|
||||
final stat = file.statSync();
|
||||
|
||||
// Request the upload slot
|
||||
@@ -201,120 +197,110 @@ class HttpFileTransferService {
|
||||
return;
|
||||
}
|
||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||
try {
|
||||
final response = await dio.Dio().putUri<dynamic>(
|
||||
Uri.parse(slot.putUrl),
|
||||
options: dio.Options(
|
||||
headers: slot.headers,
|
||||
contentType: 'application/octet-stream',
|
||||
requestEncoder: (_, __) => data,
|
||||
),
|
||||
data: data,
|
||||
onSendProgress: (count, total) {
|
||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||
// is open.
|
||||
if (job.recipients.length == 1) {
|
||||
final progress = count.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (response.statusCode != 201) {
|
||||
// TODO(PapaTutuWawa): Trigger event
|
||||
_log.severe('Upload failed');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
encryption.key,
|
||||
encryption.iv,
|
||||
encryption.ciphertextHashes,
|
||||
StatelessFileSharingUrlSource(slot.getUrl),
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
final uploadStatusCode = await client.uploadFile(
|
||||
Uri.parse(slot.putUrl),
|
||||
slot.headers,
|
||||
path,
|
||||
(total, current) {
|
||||
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||
// is open.
|
||||
if (job.recipients.length == 1) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||
|
||||
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
}
|
||||
}
|
||||
} on dio.DioError {
|
||||
_log.finest('Upload failed due to connection error');
|
||||
},
|
||||
);
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (!isRequestOkay(uploadStatusCode)) {
|
||||
_log.severe('Upload failed');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
encryption.key,
|
||||
encryption.iv,
|
||||
encryption.ciphertextHashes,
|
||||
StatelessFileSharingUrlSource(slot.getUrl),
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
.hashFile(job.path, HashFunction.sha256);
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||
}
|
||||
}
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
),
|
||||
);
|
||||
_log.finest('Sent message with file upload for ${job.path} to $recipient');
|
||||
|
||||
final isMultiMedia = job.mime?.startsWith('image/') == true || job.mime?.startsWith('video/') == true;
|
||||
if (isMultiMedia) {
|
||||
_log.finest('File appears to be either an image or a video. Copying it to the correct directory...');
|
||||
unawaited(_copyFile(job));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _pickNextUploadTask();
|
||||
@@ -350,7 +336,6 @@ class HttpFileTransferService {
|
||||
/// Actually attempt to download the file described by the job [job].
|
||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||
final filename = job.location.filename;
|
||||
_log.finest('Downloading ${job.location.url} as $filename');
|
||||
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
||||
|
||||
var downloadPath = downloadedPath;
|
||||
@@ -360,13 +345,16 @@ class HttpFileTransferService {
|
||||
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 {
|
||||
response = await dio.Dio().downloadUri(
|
||||
_log.finest('Beginning download...');
|
||||
downloadStatusCode = await client.downloadFile(
|
||||
Uri.parse(job.location.url),
|
||||
downloadPath,
|
||||
onReceiveProgress: (count, total) {
|
||||
final progress = count.toDouble() / total.toDouble();
|
||||
(total, current) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
@@ -375,141 +363,155 @@ class HttpFileTransferService {
|
||||
);
|
||||
},
|
||||
);
|
||||
} on dio.DioError catch(err) {
|
||||
// TODO(PapaTutuWawa): React if we received an error that is not related to the
|
||||
// connection.
|
||||
_log.finest('Download done...');
|
||||
} catch (err) {
|
||||
_log.finest('Failed to download: $err');
|
||||
}
|
||||
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.warning('HTTP GET of ${job.location.url} returned $downloadStatusCode');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) {
|
||||
_log.warning('HTTP GET of ${job.location.url} returned ${response.statusCode}');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
return;
|
||||
} else {
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
),
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
job.location.ciphertextHashes ?? {},
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
job.location.ciphertextHashes ?? {},
|
||||
);
|
||||
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
||||
} catch (ex) {
|
||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
|
||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
||||
} catch (ex) {
|
||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
final notification = GetIt.I.get<NotificationsService>();
|
||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
// TODO(Unknown): Restrict to the library's supported file types
|
||||
Size? size;
|
||||
try {
|
||||
size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to get image size for $downloadedPath: $ex');
|
||||
}
|
||||
|
||||
mediaWidth = size?.width;
|
||||
mediaHeight = size?.height;
|
||||
} else if (mime.startsWith('video/')) {
|
||||
// TODO(Unknown): Also figure out the thumbnail size here
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.id,
|
||||
job.mId,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
sharedMedia: [
|
||||
sharedMedium,
|
||||
...conv.sharedMedia,
|
||||
],
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
final notification = GetIt.I.get<NotificationsService>();
|
||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
||||
|
||||
int? mediaWidth;
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(downloadedPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath');
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();
|
||||
} else if (mime.startsWith('video/')) {
|
||||
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/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
}
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final sharedMedium = await GetIt.I.get<DatabaseService>().addSharedMediumFromData(
|
||||
downloadedPath,
|
||||
msg.timestamp,
|
||||
conv.id,
|
||||
job.mId,
|
||||
mime: mime,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
lastMessage: conv.lastMessage?.id == job.mId ?
|
||||
msg :
|
||||
conv.lastMessage,
|
||||
sharedMedia: [
|
||||
sharedMedium,
|
||||
...conv.sharedMedia,
|
||||
],
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
|
||||
// Free the download resources for the next one
|
||||
await _pickNextDownloadTask();
|
||||
}
|
||||
|
||||
Future<void> _pickNextDownloadTask() async {
|
||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
||||
|
||||
await _downloadLock.synchronized(() async {
|
||||
if (_downloadQueue.isNotEmpty) {
|
||||
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
|
||||
// Only download if we have a connection
|
||||
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) {
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
}
|
||||
} else {
|
||||
_currentDownloadJob = null;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ class MessageService {
|
||||
String sid,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
bool containsNoStore,
|
||||
{
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
@@ -63,6 +64,10 @@ class MessageService {
|
||||
bool isDownloading = false,
|
||||
bool isUploading = false,
|
||||
int? mediaSize,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}
|
||||
) async {
|
||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
||||
@@ -74,6 +79,7 @@ class MessageService {
|
||||
sid,
|
||||
isFileUploadNotification,
|
||||
encrypted,
|
||||
containsNoStore,
|
||||
srcUrl: srcUrl,
|
||||
key: key,
|
||||
iv: iv,
|
||||
@@ -93,6 +99,10 @@ class MessageService {
|
||||
isUploading: isUploading,
|
||||
isDownloading: isDownloading,
|
||||
mediaSize: mediaSize,
|
||||
stickerPackId: stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
);
|
||||
|
||||
// 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 {
|
||||
if (!_messageCache.containsKey(conversationJid)) {
|
||||
await getMessagesForJid(conversationJid);
|
||||
@@ -151,6 +172,8 @@ class MessageService {
|
||||
Object? sid = notSpecified,
|
||||
Object? thumbnailData = notSpecified,
|
||||
bool? isRetracted,
|
||||
bool? isEdited,
|
||||
Object? reactions = notSpecified,
|
||||
}) async {
|
||||
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
||||
id,
|
||||
@@ -177,6 +200,8 @@ class MessageService {
|
||||
isRetracted: isRetracted,
|
||||
isMedia: isMedia,
|
||||
thumbnailData: thumbnailData,
|
||||
isEdited: isEdited,
|
||||
reactions: reactions,
|
||||
);
|
||||
|
||||
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
||||
@@ -239,17 +264,16 @@ class MessageService {
|
||||
mediaSize: null,
|
||||
isRetracted: true,
|
||||
thumbnailData: null,
|
||||
body: '',
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conversation = await cs.getConversationByJid(conversationJid);
|
||||
if (conversation != null) {
|
||||
if (conversation.lastMessageId == msg.id) {
|
||||
var newConversation = await cs.updateConversation(
|
||||
conversation.id,
|
||||
lastMessageBody: '',
|
||||
lastMessageRetracted: true,
|
||||
if (conversation.lastMessage?.id == msg.id) {
|
||||
var newConversation = conversation.copyWith(
|
||||
lastMessage: retractedMessage,
|
||||
);
|
||||
|
||||
if (isMedia) {
|
||||
@@ -260,7 +284,6 @@ class MessageService {
|
||||
return medium.messageId != msg.id;
|
||||
}).toList(),
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConversation);
|
||||
|
||||
// Delete the file if we downloaded it
|
||||
if (mediaUrl != null) {
|
||||
@@ -271,6 +294,7 @@ class MessageService {
|
||||
}
|
||||
}
|
||||
|
||||
cs.setConversation(newConversation);
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConversation,
|
||||
|
||||
@@ -4,15 +4,14 @@ import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
class MoxxyOmemoManager extends OmemoManager {
|
||||
|
||||
class MoxxyOmemoManager extends BaseOmemoManager {
|
||||
MoxxyOmemoManager() : super();
|
||||
|
||||
@override
|
||||
Future<OmemoSessionManager> getSessionManager() async {
|
||||
Future<OmemoManager> getOmemoManager() async {
|
||||
final os = GetIt.I.get<OmemoService>();
|
||||
await os.ensureInitialized();
|
||||
return os.omemoState;
|
||||
return os.omemoManager;
|
||||
}
|
||||
|
||||
@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
|
||||
/// connected. Otherwise, we idle until we have a connection again.
|
||||
class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
|
||||
MoxxyReconnectionPolicy({ bool isTesting = false, this.maxBackoffTime })
|
||||
: _isTesting = isTesting,
|
||||
_timerLock = Lock(),
|
||||
@@ -46,7 +45,7 @@ class MoxxyReconnectionPolicy extends ReconnectionPolicy {
|
||||
// Cancel the timer if it was running
|
||||
await _stopTimer();
|
||||
await setIsReconnecting(false);
|
||||
triggerConnectionLost!();
|
||||
await triggerConnectionLost!();
|
||||
} else if (regained && shouldReconnect) {
|
||||
// We should reconnect
|
||||
_log.finest('Network regained. Attempting reconnection...');
|
||||
|
||||
@@ -1,21 +1,91 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.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/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
class MoxxyRosterManager extends RosterManager {
|
||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
@override
|
||||
Future<void> commitLastRosterVersion(String version) async {
|
||||
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith(
|
||||
lastRosterVersion: version,
|
||||
),);
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
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> loadLastRosterVersion() async {
|
||||
final ver = (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion;
|
||||
if (ver != null) {
|
||||
setRosterVersion(ver);
|
||||
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,
|
||||
),);
|
||||
|
||||
// Remove stale items
|
||||
for (final jid in removed) {
|
||||
await rs.removeRosterItemByJid(jid);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
final rosterAdded = List<RosterItem>.empty(growable: true);
|
||||
for (final item in added) {
|
||||
rosterAdded.add(
|
||||
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:logging/logging.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/events.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
@@ -85,31 +86,46 @@ class NotificationsService {
|
||||
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
||||
/// then Android's BigPicture will be used.
|
||||
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
|
||||
final body = m.isMedia ?
|
||||
mimeTypeToEmoji(m.mediaType) :
|
||||
m.body;
|
||||
String body;
|
||||
if (m.stickerPackId != null) {
|
||||
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(
|
||||
content: NotificationContent(
|
||||
id: m.id,
|
||||
groupKey: c.jid,
|
||||
channelKey: _messageChannelKey,
|
||||
summary: c.title,
|
||||
title: c.title,
|
||||
summary: title,
|
||||
title: title,
|
||||
body: body,
|
||||
largeIcon: c.avatarUrl.isNotEmpty ? 'file://${c.avatarUrl}' : null,
|
||||
notificationLayout: m.thumbnailable ?
|
||||
largeIcon: avatarPath.isNotEmpty ?
|
||||
'file://$avatarPath' :
|
||||
null,
|
||||
notificationLayout: m.isThumbnailable ?
|
||||
NotificationLayout.BigPicture :
|
||||
NotificationLayout.Messaging,
|
||||
category: NotificationCategory.Message,
|
||||
bigPicture: m.thumbnailable ? 'file://${m.mediaUrl}' : null,
|
||||
bigPicture: m.isThumbnailable ? 'file://${m.mediaUrl}' : null,
|
||||
payload: <String, String>{
|
||||
'conversationJid': c.jid,
|
||||
'sid': m.sid,
|
||||
'title': c.title,
|
||||
'avatarUrl': c.avatarUrl,
|
||||
'title': title,
|
||||
'avatarUrl': avatarPath,
|
||||
},
|
||||
),
|
||||
actionButtons: [
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
Future<OmemoSessionManager> generateNewIdentityImpl(String jid) async {
|
||||
return OmemoSessionManager.generateNewIdentity(
|
||||
jid,
|
||||
MoxxyBTBVTrustManager(
|
||||
<RatchetMapKey, BTBVTrustState>{},
|
||||
<RatchetMapKey, bool>{},
|
||||
<String, List<int>>{},
|
||||
),
|
||||
);
|
||||
Future<OmemoDevice> generateNewIdentityImpl(String jid) async {
|
||||
return OmemoDevice.generateNewDevice(jid);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,21 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
||||
import 'package: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:synchronized/synchronized.dart';
|
||||
|
||||
class OmemoDoubleRatchetWrapper {
|
||||
|
||||
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
||||
final OmemoDoubleRatchet ratchet;
|
||||
final int id;
|
||||
@@ -21,14 +26,14 @@ class OmemoDoubleRatchetWrapper {
|
||||
}
|
||||
|
||||
class OmemoService {
|
||||
|
||||
final Logger _log = Logger('OmemoService');
|
||||
|
||||
bool _initialized = false;
|
||||
final Lock _lock = Lock();
|
||||
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 {
|
||||
final done = await _lock.synchronized(() => _initialized);
|
||||
@@ -36,44 +41,73 @@ class OmemoService {
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final device = await db.loadOmemoDevice(jid);
|
||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
final deviceList = <String, List<int>>{};
|
||||
if (device == null) {
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
// Generate the identity in the background
|
||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
||||
|
||||
await commitDevice(await omemoState.getDevice());
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
||||
} else {
|
||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||
ratchetMap[key] = ratchet.ratchet;
|
||||
}
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
omemoState = OmemoSessionManager(
|
||||
device,
|
||||
await db.loadOmemoDeviceList(),
|
||||
ratchetMap,
|
||||
await loadTrustManager(),
|
||||
);
|
||||
deviceList.addAll(await db.loadOmemoDeviceList());
|
||||
}
|
||||
|
||||
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) {
|
||||
await GetIt.I.get<DatabaseService>().saveRatchet(
|
||||
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) {
|
||||
await commitDevice(event.device);
|
||||
|
||||
// Publish it
|
||||
await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!
|
||||
await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.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
|
||||
await _lock.synchronized(() {
|
||||
_initialized = false;
|
||||
});
|
||||
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
final oldId = await omemoState.getDeviceId();
|
||||
final oldId = await omemoManager.getDeviceId();
|
||||
|
||||
// Clear the database
|
||||
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
||||
|
||||
// Regenerate the identity in the background
|
||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
||||
|
||||
await commitDevice(await omemoState.getDevice());
|
||||
final device = await compute(generateNewIdentityImpl, jid);
|
||||
await omemoManager.replaceDevice(device);
|
||||
await commitDevice(device);
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
|
||||
// Remove the old device
|
||||
final omemo = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<OmemoManager>(omemoManager)!;
|
||||
final omemo = GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
await omemo.deleteDevice(oldId);
|
||||
|
||||
// Publish the new one
|
||||
await omemo.publishBundle(await omemoState.getDeviceBundle());
|
||||
await omemo.publishBundle(await omemoManager.getDeviceBundle());
|
||||
|
||||
// Allow access again
|
||||
await _lock.synchronized(() {
|
||||
@@ -126,7 +191,7 @@ class OmemoService {
|
||||
});
|
||||
|
||||
// Return the OmemoDevice
|
||||
return OmemoDevice(
|
||||
return model.OmemoDevice(
|
||||
await getDeviceFingerprint(),
|
||||
true,
|
||||
true,
|
||||
@@ -157,7 +222,7 @@ class OmemoService {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -168,55 +233,108 @@ class OmemoService {
|
||||
await ensureInitialized();
|
||||
_log.finest('publishDeviceIfNeeded: Done');
|
||||
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final omemo = conn.getManagerById<OmemoManager>(omemoManager)!;
|
||||
final dm = conn.getManagerById<DiscoManager>(discoManager)!;
|
||||
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||
final omemo = conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||
final bareJid = conn.getConnectionSettings().jid.toBare();
|
||||
final device = await omemoState.getDevice();
|
||||
final device = await omemoManager.getDevice();
|
||||
|
||||
final bundlesRaw = await dm.discoItemsQuery(
|
||||
bareJid.toString(),
|
||||
node: omemoBundlesXmlns,
|
||||
node: moxxmpp.omemoBundlesXmlns,
|
||||
);
|
||||
if (bundlesRaw.isType<DiscoError>()) {
|
||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||
await omemo.publishBundle(await device.toBundle());
|
||||
return bundlesRaw.get<DiscoError>();
|
||||
return bundlesRaw.get<moxxmpp.DiscoError>();
|
||||
}
|
||||
|
||||
final bundleIds = bundlesRaw
|
||||
.get<List<DiscoItem>>()
|
||||
.get<List<moxxmpp.DiscoItem>>()
|
||||
.where((item) => item.name != null)
|
||||
.map((item) => int.parse(item.name!));
|
||||
if (!bundleIds.contains(device.id)) {
|
||||
final result = await omemo.publishBundle(await device.toBundle());
|
||||
if (result.isType<OmemoError>()) return result.get<OmemoError>();
|
||||
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
|
||||
return null;
|
||||
}
|
||||
|
||||
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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
final fingerprints = await omemoState.getHexFingerprintsForJid(jid);
|
||||
final keys = List<OmemoDevice>.empty(growable: true);
|
||||
for (final fp in fingerprints) {
|
||||
|
||||
// Get finger prints if we have to
|
||||
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
|
||||
|
||||
final keys = List<model.OmemoDevice>.empty(growable: true);
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(jid);
|
||||
|
||||
if (!_fingerprintCache.containsKey(jid)) return [];
|
||||
for (final deviceId in _fingerprintCache[jid]!.keys) {
|
||||
keys.add(
|
||||
OmemoDevice(
|
||||
fp.fingerprint,
|
||||
await omemoState.trustManager.isTrusted(jid, fp.deviceId),
|
||||
// TODO(Unknown): Allow verifying OMEMO keys
|
||||
false,
|
||||
await omemoState.trustManager.isEnabled(jid, fp.deviceId),
|
||||
fp.deviceId,
|
||||
model.OmemoDevice(
|
||||
_fingerprintCache[jid]![deviceId]!,
|
||||
await tm.isTrusted(jid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
await tm.isEnabled(jid, deviceId),
|
||||
deviceId,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -225,7 +343,6 @@ class OmemoService {
|
||||
}
|
||||
|
||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||
|
||||
await GetIt.I.get<DatabaseService>().saveTrustCache(
|
||||
json['trust']! as Map<String, int>,
|
||||
);
|
||||
@@ -248,58 +365,70 @@ class OmemoService {
|
||||
|
||||
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
|
||||
await ensureInitialized();
|
||||
await omemoState.trustManager.setEnabled(jid, deviceId, enabled);
|
||||
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
||||
}
|
||||
|
||||
Future<void> removeAllSessions(String jid) async {
|
||||
await ensureInitialized();
|
||||
await omemoState.removeAllRatchets(jid);
|
||||
await omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
Future<int> getDeviceId() async {
|
||||
await ensureInitialized();
|
||||
return omemoState.getDeviceId();
|
||||
return omemoManager.getDeviceId();
|
||||
}
|
||||
|
||||
Future<String> getDeviceFingerprint() async {
|
||||
return (await omemoState.getHexFingerprintForDevice()).fingerprint;
|
||||
}
|
||||
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
|
||||
|
||||
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
||||
/// published on [ownJid]'s devices PubSub node.
|
||||
/// Note that the list is made so that the current device is excluded.
|
||||
Future<List<OmemoDevice>> getOwnFingerprints(JID ownJid) async {
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
|
||||
final ownId = await getDeviceId();
|
||||
final keys = List<OmemoDevice>.from(
|
||||
final keys = List<model.OmemoDevice>.from(
|
||||
await getOmemoKeysForJid(ownJid.toString()),
|
||||
);
|
||||
final bareJid = ownJid.toBare().toString();
|
||||
|
||||
// TODO(PapaTutuWawa): This should be cached in the database and only requested if
|
||||
// it's not cached.
|
||||
final allDevicesRaw = await conn.getManagerById<OmemoManager>(omemoManager)!
|
||||
.retrieveDeviceBundles(ownJid);
|
||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
||||
// Get fingerprints if we have to
|
||||
await _loadOrFetchFingerprints(ownJid);
|
||||
|
||||
for (final device in allDevices) {
|
||||
// All devices that are publishes that is not the current device
|
||||
if (device.id == ownId) continue;
|
||||
final curveIk = await device.ik.toCurve25519();
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(bareJid);
|
||||
|
||||
keys.add(
|
||||
OmemoDevice(
|
||||
HEX.encode(await curveIk.getBytes()),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
device.id,
|
||||
hasSessionWith: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final deviceId in _fingerprintCache[bareJid]!.keys) {
|
||||
if (deviceId == ownId) continue;
|
||||
if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
|
||||
|
||||
final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
|
||||
keys.add(
|
||||
model.OmemoDevice(
|
||||
fingerprint,
|
||||
await tm.isTrusted(bareJid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
await tm.isEnabled(bareJid, deviceId),
|
||||
deviceId,
|
||||
hasSessionWith: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
Future<void> verifyDevice(int deviceId, String jid) async {
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
await tm.setDeviceTrust(
|
||||
jid,
|
||||
deviceId,
|
||||
BTBVTrustState.verified,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
|
||||
void onNewConnection() {
|
||||
if (_initialized) {
|
||||
omemoManager.onNewConnection();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.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 {
|
||||
|
||||
RosterService()
|
||||
: _rosterCache = HashMap(),
|
||||
_rosterLoaded = false,
|
||||
_log = Logger('RosterService');
|
||||
final HashMap<String, RosterItem> _rosterCache;
|
||||
bool _rosterLoaded;
|
||||
RosterService() : _log = Logger('RosterService');
|
||||
Map<String, RosterItem>? _rosterCache;
|
||||
final Logger _log;
|
||||
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
Future<void> _loadRosterIfNeeded() async {
|
||||
if (_rosterCache == null) {
|
||||
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.
|
||||
@@ -194,6 +33,10 @@ class RosterService {
|
||||
String title,
|
||||
String subscription,
|
||||
String ask,
|
||||
bool pseudoRosterItem,
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
{
|
||||
List<String> groups = const [],
|
||||
}
|
||||
@@ -205,11 +48,15 @@ class RosterService {
|
||||
title,
|
||||
subscription,
|
||||
ask,
|
||||
pseudoRosterItem,
|
||||
contactId,
|
||||
contactAvatarPath,
|
||||
contactDisplayName,
|
||||
groups: groups,
|
||||
);
|
||||
|
||||
// Update the cache
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
|
||||
return item;
|
||||
}
|
||||
@@ -222,7 +69,11 @@ class RosterService {
|
||||
String? title,
|
||||
String? subscription,
|
||||
String? ask,
|
||||
Object pseudoRosterItem = notSpecified,
|
||||
List<String>? groups,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}
|
||||
) async {
|
||||
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
|
||||
@@ -232,30 +83,34 @@ class RosterService {
|
||||
title: title,
|
||||
subscription: subscription,
|
||||
ask: ask,
|
||||
pseudoRosterItem: pseudoRosterItem,
|
||||
groups: groups,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
// Update cache
|
||||
_rosterCache[newItem.jid] = newItem;
|
||||
_rosterCache![newItem.jid] = newItem;
|
||||
|
||||
return newItem;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
||||
Future<void> removeRosterItem(int id) async {
|
||||
// NOTE: This call ensures that _rosterCache != null
|
||||
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
||||
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||
|
||||
/// 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.
|
||||
Future<void> removeRosterItemByJid(String jid) async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
await _loadRosterIfNeeded();
|
||||
|
||||
for (final item in _rosterCache.values) {
|
||||
for (final item in _rosterCache!.values) {
|
||||
if (item.jid == jid) {
|
||||
await removeRosterItem(item.id);
|
||||
return;
|
||||
@@ -265,17 +120,14 @@ class RosterService {
|
||||
|
||||
/// Returns the entire roster
|
||||
Future<List<RosterItem>> getRoster() async {
|
||||
if (!_rosterLoaded) {
|
||||
await loadRosterFromDatabase();
|
||||
}
|
||||
|
||||
return _rosterCache.values.toList();
|
||||
await _loadRosterIfNeeded();
|
||||
return _rosterCache!.values.toList();
|
||||
}
|
||||
|
||||
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
||||
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
||||
if (await isInRoster(jid)) {
|
||||
return _rosterCache[jid];
|
||||
return _rosterCache![jid];
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -286,9 +138,9 @@ class RosterService {
|
||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
||||
|
||||
_rosterLoaded = true;
|
||||
_rosterCache = <String, RosterItem>{};
|
||||
for (final item in items) {
|
||||
_rosterCache[item.jid] = item;
|
||||
_rosterCache![item.jid] = item;
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -298,6 +150,8 @@ class RosterService {
|
||||
/// and, if it was successful, create the database entry. Returns the
|
||||
/// [RosterItem] model object.
|
||||
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(
|
||||
avatarUrl,
|
||||
avatarHash,
|
||||
@@ -305,6 +159,10 @@ class RosterService {
|
||||
title,
|
||||
'none',
|
||||
'',
|
||||
false,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(jid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
final result = await GetIt.I.get<XmppConnection>().getRosterManager().addToRoster(jid, title);
|
||||
if (!result) {
|
||||
@@ -337,59 +195,6 @@ class RosterService {
|
||||
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 {
|
||||
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/connectivity.dart';
|
||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
@@ -30,11 +31,13 @@ import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/logging.dart';
|
||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||
import 'package:moxxyv2/ui/events.dart' as ui_events;
|
||||
|
||||
Future<void> initializeServiceIfNeeded() async {
|
||||
@@ -47,7 +50,7 @@ Future<void> initializeServiceIfNeeded() async {
|
||||
}
|
||||
|
||||
logger.info('Attaching to service...');
|
||||
await handler.attach(ui_events.handleIsolateEvent);
|
||||
await handler.attach(ui_events.receiveIsolateEvent);
|
||||
logger.info('Done');
|
||||
|
||||
// ignore: cascade_invocations
|
||||
@@ -62,7 +65,7 @@ Future<void> initializeServiceIfNeeded() async {
|
||||
logger.info('Service is not running. Initializing service... ');
|
||||
await handler.start(
|
||||
entrypoint,
|
||||
handleUiEvent,
|
||||
receiveUIEvent,
|
||||
ui_events.handleIsolateEvent,
|
||||
);
|
||||
}
|
||||
@@ -72,7 +75,7 @@ Future<void> initializeServiceIfNeeded() async {
|
||||
/// logging what we send.
|
||||
void sendEvent(BackgroundEvent event, { String? id }) {
|
||||
// NOTE: *S*erver to *F*oreground
|
||||
GetIt.I.get<Logger>().fine('S2F: ${event.toJson()}');
|
||||
GetIt.I.get<Logger>().fine('--> ${event.toJson()["type"]}');
|
||||
GetIt.I.get<BackgroundService>().sendEvent(event, id: id);
|
||||
}
|
||||
|
||||
@@ -130,17 +133,14 @@ Future<void> initUDPLogger() async {
|
||||
/// The entrypoint for all platforms after the platform specific initilization is done.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> entrypoint() async {
|
||||
// Register the lock
|
||||
GetIt.I.registerSingleton<Completer<void>>(Completer());
|
||||
setupLogging();
|
||||
setupBackgroundEventHandler();
|
||||
|
||||
// Register singletons
|
||||
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
|
||||
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
|
||||
GetIt.I.registerSingleton<LanguageService>(LanguageService());
|
||||
|
||||
setupLogging();
|
||||
setupBackgroundEventHandler();
|
||||
|
||||
// Initialize the database
|
||||
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
|
||||
await GetIt.I.get<DatabaseService>().initialize();
|
||||
@@ -155,10 +155,13 @@ Future<void> entrypoint() async {
|
||||
GetIt.I.registerSingleton<MessageService>(MessageService());
|
||||
GetIt.I.registerSingleton<OmemoService>(OmemoService());
|
||||
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
||||
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||
GetIt.I.registerSingleton<StickersService>(StickersService());
|
||||
final xmpp = XmppService();
|
||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||
|
||||
await GetIt.I.get<NotificationsService>().init();
|
||||
await GetIt.I.get<ContactsService>().init();
|
||||
|
||||
if (!kDebugMode) {
|
||||
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
|
||||
@@ -175,7 +178,7 @@ Future<void> entrypoint() async {
|
||||
)..registerManagers([
|
||||
MoxxyStreamManagementManager(),
|
||||
MoxxyDiscoManager(),
|
||||
MoxxyRosterManager(),
|
||||
RosterManager(MoxxyRosterStateManager()),
|
||||
MoxxyOmemoManager(),
|
||||
PingManager(),
|
||||
MessageManager(),
|
||||
@@ -199,6 +202,9 @@ Future<void> entrypoint() async {
|
||||
CryptographicHashManager(),
|
||||
DelayedDeliveryManager(),
|
||||
MessageRetractionManager(),
|
||||
LastMessageCorrectionManager(),
|
||||
MessageReactionsManager(),
|
||||
StickersManager(),
|
||||
])
|
||||
..registerFeatureNegotiators([
|
||||
ResourceBindingNegotiator(),
|
||||
@@ -242,13 +248,16 @@ Future<void> entrypoint() async {
|
||||
);
|
||||
}
|
||||
|
||||
GetIt.I.get<Logger>().finest('Resolving startup future');
|
||||
GetIt.I.get<Completer<void>>().complete();
|
||||
|
||||
unawaited(GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock());
|
||||
sendEvent(ServiceReadyEvent());
|
||||
}
|
||||
|
||||
Future<void> handleUiEvent(Map<String, dynamic>? data) async {
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> receiveUIEvent(Map<String, dynamic>? data) async {
|
||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().add(data);
|
||||
}
|
||||
|
||||
Future<void> handleUIEvent(Map<String, dynamic>? data) async {
|
||||
// NOTE: *F*oreground to *S*ervice
|
||||
final log = GetIt.I.get<Logger>();
|
||||
|
||||
|
||||
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 'package:connectivity_plus/connectivity_plus.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:mime/mime.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/connectivity.dart';
|
||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/database.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/service.dart';
|
||||
import 'package:moxxyv2/service/state.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.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:permission_handler/permission_handler.dart';
|
||||
|
||||
@@ -53,7 +55,6 @@ class XmppService {
|
||||
EventTypeMatcher<SubscriptionRequestReceivedEvent>(_onSubscriptionRequestReceived),
|
||||
EventTypeMatcher<DeliveryReceiptReceivedEvent>(_onDeliveryReceiptReceived),
|
||||
EventTypeMatcher<ChatMarkerEvent>(_onChatMarker),
|
||||
EventTypeMatcher<RosterPushEvent>(_onRosterPush),
|
||||
EventTypeMatcher<AvatarUpdatedEvent>(_onAvatarUpdated),
|
||||
EventTypeMatcher<StanzaAckedEvent>(_onStanzaAcked),
|
||||
EventTypeMatcher<MessageEvent>(_onMessage),
|
||||
@@ -127,6 +128,49 @@ class XmppService {
|
||||
/// Returns the JID of the chat that is currently opened. Null, if none is open.
|
||||
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].
|
||||
Future<void> sendMessage({
|
||||
required String body,
|
||||
@@ -134,6 +178,7 @@ class XmppService {
|
||||
Message? quotedMessage,
|
||||
String? commandId,
|
||||
ChatState? chatState,
|
||||
sticker.Sticker? sticker,
|
||||
}) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
@@ -149,18 +194,22 @@ class XmppService {
|
||||
timestamp,
|
||||
conn.getConnectionSettings().jid.toString(),
|
||||
recipient,
|
||||
false,
|
||||
sticker != null,
|
||||
sid,
|
||||
false,
|
||||
conversation!.encrypted,
|
||||
// TODO(Unknown): Maybe make this depend on some setting
|
||||
false,
|
||||
originId: originId,
|
||||
quoteId: quotedMessage?.sid,
|
||||
stickerPackId: sticker?.stickerPackId,
|
||||
stickerHashKey: sticker?.hashKey,
|
||||
srcUrl: sticker?.urlSources.first,
|
||||
mediaType: sticker?.mediaType,
|
||||
);
|
||||
final newConversation = await cs.updateConversation(
|
||||
conversation.id,
|
||||
lastMessageBody: body,
|
||||
lastMessageId: message.id,
|
||||
lastMessageRetracted: false,
|
||||
lastMessage: message,
|
||||
lastChangeTimestamp: timestamp,
|
||||
);
|
||||
|
||||
@@ -182,6 +231,27 @@ class XmppService {
|
||||
quoteId: quotedMessage?.sid,
|
||||
chatState: chatState,
|
||||
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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -353,6 +423,7 @@ class XmppService {
|
||||
// Create a new message
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
|
||||
// Path -> Recipient -> Message
|
||||
@@ -364,7 +435,7 @@ class XmppService {
|
||||
// Recipient -> Should encrypt
|
||||
final encrypt = <String, bool>{};
|
||||
// Recipient -> Last message Id
|
||||
final lastMessageIds = <String, int>{};
|
||||
final lastMessages = <String, Message>{};
|
||||
|
||||
// Create the messages and shared media entries
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
@@ -377,13 +448,13 @@ class XmppService {
|
||||
|
||||
// TODO(Unknown): Do the same for videos
|
||||
if (pathMime != null && pathMime.startsWith('image/')) {
|
||||
try {
|
||||
final imageSize = image_size.ImageSizeGetter.getSize(FileInput(File(path)));
|
||||
final imageSize = await getImageSizeFromPath(path);
|
||||
if (imageSize != null) {
|
||||
dimensions[path] = Size(
|
||||
imageSize.width.toDouble(),
|
||||
imageSize.height.toDouble(),
|
||||
imageSize.width,
|
||||
imageSize.height,
|
||||
);
|
||||
} catch (ex) {
|
||||
} else {
|
||||
_log.warning('Failed to get image dimensions for $path');
|
||||
}
|
||||
}
|
||||
@@ -397,6 +468,8 @@ class XmppService {
|
||||
conn.generateId(),
|
||||
false,
|
||||
encrypt[recipient]!,
|
||||
// TODO(Unknown): Maybe make this depend on some setting
|
||||
false,
|
||||
mediaUrl: path,
|
||||
mediaType: pathMime,
|
||||
originId: conn.generateId(),
|
||||
@@ -412,7 +485,7 @@ class XmppService {
|
||||
}
|
||||
|
||||
if (path == paths.last) {
|
||||
lastMessageIds[recipient] = msg.id;
|
||||
lastMessages[recipient] = msg;
|
||||
}
|
||||
|
||||
sendEvent(MessageAddedEvent(message: msg));
|
||||
@@ -424,14 +497,12 @@ class XmppService {
|
||||
final sharedMediaMap = <String, List<SharedMedium>>{};
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
for (final recipient in recipients) {
|
||||
final lastFileMime = lookupMimeType(paths.last);
|
||||
final conversation = await cs.getConversationByJid(recipient);
|
||||
if (conversation != null) {
|
||||
// Update conversation
|
||||
var updatedConversation = await cs.updateConversation(
|
||||
conversation.id,
|
||||
lastMessageBody: mimeTypeToEmoji(lastFileMime),
|
||||
lastMessageId: lastMessageIds[recipient],
|
||||
lastMessage: lastMessages[recipient],
|
||||
lastChangeTimestamp: DateTime.now().millisecondsSinceEpoch,
|
||||
open: true,
|
||||
);
|
||||
@@ -449,12 +520,11 @@ class XmppService {
|
||||
} else {
|
||||
// Create conversation
|
||||
final rosterItem = await rs.getRosterItemByJid(recipient);
|
||||
final contactId = await css.getContactIdForJid(recipient);
|
||||
var newConversation = await cs.addConversationFromData(
|
||||
// TODO(Unknown): Should we use the JID parser?
|
||||
rosterItem?.title ?? recipient.split('@').first,
|
||||
lastMessageIds[recipient]!,
|
||||
false,
|
||||
mimeTypeToEmoji(lastFileMime),
|
||||
lastMessages[recipient],
|
||||
rosterItem?.avatarUrl ?? '',
|
||||
recipient,
|
||||
0,
|
||||
@@ -462,6 +532,9 @@ class XmppService {
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(recipient),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
sharedMediaMap[recipient] = await _createSharedMedia(messages, paths, recipient, newConversation.id);
|
||||
@@ -591,9 +664,25 @@ class XmppService {
|
||||
unawaited(_initializeOmemoService(settings.jid.toString()));
|
||||
|
||||
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 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
|
||||
final roster = await GetIt.I.get<RosterService>().getRoster();
|
||||
@@ -638,31 +727,27 @@ class XmppService {
|
||||
|
||||
if (!prefs.showSubscriptionRequests) return;
|
||||
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conversation = await cs.getConversationByJid(event.from.toBare().toString());
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
if (conversation != null) {
|
||||
final newConversation = await cs.updateConversation(
|
||||
conversation.id,
|
||||
open: true,
|
||||
lastChangeTimestamp: timestamp,
|
||||
);
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||
} else {
|
||||
if (conversation != null && !conversation.open) {
|
||||
// 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(
|
||||
bare.toString().split('@')[0],
|
||||
-1,
|
||||
false,
|
||||
'',
|
||||
bare.split('@')[0],
|
||||
null,
|
||||
'', // TODO(Unknown): avatarUrl
|
||||
bare.toString(),
|
||||
bare,
|
||||
0,
|
||||
timestamp,
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
prefs.enableOmemoByDefault,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(bare),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
sendEvent(ConversationAddedEvent(conversation: conv));
|
||||
@@ -673,7 +758,9 @@ class XmppService {
|
||||
_log.finest('Received delivery receipt from ${event.from.toString()}');
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final dbMsg = await db.getMessageByXmppId(event.id, event.from.toBare().toString());
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final sender = event.from.toBare().toString();
|
||||
final dbMsg = await db.getMessageByXmppId(event.id, sender);
|
||||
if (dbMsg == null) {
|
||||
_log.warning('Did not find the message with id ${event.id} in the database!');
|
||||
return;
|
||||
@@ -683,8 +770,16 @@ class XmppService {
|
||||
dbMsg.id,
|
||||
received: true,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Update the conversation
|
||||
final conv = await cs.getConversationByJid(sender);
|
||||
if (conv != null && conv.lastMessage?.id == msg.id) {
|
||||
final newConv = conv.copyWith(lastMessage: msg);
|
||||
cs.setConversation(newConv);
|
||||
_log.finest('Updating conversation');
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onChatMarker(ChatMarkerEvent event, { dynamic extra }) async {
|
||||
@@ -693,7 +788,9 @@ class XmppService {
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final dbMsg = await db.getMessageByXmppId(event.id, event.from.toBare().toString());
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final sender = event.from.toBare().toString();
|
||||
final dbMsg = await db.getMessageByXmppId(event.id, sender);
|
||||
if (dbMsg == null) {
|
||||
_log.warning('Did not find the message in the database!');
|
||||
return;
|
||||
@@ -704,8 +801,17 @@ class XmppService {
|
||||
received: dbMsg.received || event.type == 'received' || event.type == 'displayed',
|
||||
displayed: dbMsg.displayed || event.type == 'displayed',
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
|
||||
// Update the conversation
|
||||
final conv = await cs.getConversationByJid(sender);
|
||||
if (conv != null && conv.lastMessage?.id == msg.id) {
|
||||
final newConv = conv.copyWith(lastMessage: msg);
|
||||
cs.setConversation(newConv);
|
||||
_log.finest('Updating conversation');
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onChatState(ChatState state, String jid) async {
|
||||
@@ -775,7 +881,8 @@ class XmppService {
|
||||
// that the message body and the OOB url are the same if the OOB url is not null.
|
||||
return embeddedFile != null
|
||||
&& 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].
|
||||
@@ -841,6 +948,144 @@ class XmppService {
|
||||
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 {
|
||||
// The jid this message event is meant for
|
||||
final conversationJid = event.isCarbon
|
||||
@@ -856,6 +1101,12 @@ class XmppService {
|
||||
// Process the chat state update. Can also be attached to other messages
|
||||
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
|
||||
if (event.funReplacement != null) {
|
||||
await _handleFileUploadNotificationReplacement(event, conversationJid);
|
||||
@@ -867,6 +1118,12 @@ class XmppService {
|
||||
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
|
||||
if (!_isMessageEventMessage(event) && event.other['encryption_error'] == null) return;
|
||||
|
||||
@@ -890,15 +1147,12 @@ class XmppService {
|
||||
// Pre-process the message in case it is a reply to another message
|
||||
String? replyId;
|
||||
var messageBody = event.body;
|
||||
// TODO(Unknown): Implement
|
||||
if (event.reply != null /* && check if event.reply.to is okay */) {
|
||||
if (event.reply != null) {
|
||||
replyId = event.reply!.id;
|
||||
|
||||
// Strip the compatibility fallback, if specified
|
||||
if (event.reply!.start != null && event.reply!.end != null) {
|
||||
messageBody = messageBody.replaceRange(event.reply!.start!, event.reply!.end, '');
|
||||
_log.finest('Removed message reply compatibility fallback from message');
|
||||
}
|
||||
messageBody = event.reply!.removeFallback(messageBody);
|
||||
_log.finest('Removed message reply compatibility fallback from message');
|
||||
}
|
||||
|
||||
// The Url of the file embedded in the message, if there is one.
|
||||
@@ -917,6 +1171,33 @@ class XmppService {
|
||||
var shouldNotify = !(isFileEmbedded && isInRoster && shouldDownload);
|
||||
// A guess for the Mime type of the embedded file.
|
||||
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
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
@@ -926,10 +1207,11 @@ class XmppService {
|
||||
messageTimestamp,
|
||||
event.fromJid.toString(),
|
||||
conversationJid,
|
||||
isFileEmbedded || event.fun != null,
|
||||
isFileEmbedded || event.fun != null || event.stickerPackId != null,
|
||||
event.sid,
|
||||
event.fun != null,
|
||||
event.encrypted,
|
||||
event.messageProcessingHints?.contains(MessageProcessingHint.noStore) ?? false,
|
||||
srcUrl: embeddedFile?.url,
|
||||
filename: event.fun?.name ?? embeddedFile?.filename,
|
||||
key: embeddedFile?.keyBase64,
|
||||
@@ -942,6 +1224,9 @@ class XmppService {
|
||||
quoteId: replyId,
|
||||
originId: event.stanzaId.originId,
|
||||
errorType: errorTypeFromException(event.other['encryption_error']),
|
||||
plaintextHashes: event.sfs?.metadata.hashes,
|
||||
stickerPackId: event.stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
);
|
||||
|
||||
// Attempt to auto-download the embedded file
|
||||
@@ -949,6 +1234,7 @@ class XmppService {
|
||||
final fts = GetIt.I.get<HttpFileTransferService>();
|
||||
final metadata = await peekFile(embeddedFile!.url);
|
||||
|
||||
_log.finest('Advertised file MIME: ${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
|
||||
@@ -974,6 +1260,7 @@ class XmppService {
|
||||
}
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final ns = GetIt.I.get<NotificationsService>();
|
||||
// The body to be displayed in the conversations list
|
||||
final conversationBody = isFileEmbedded || message.isFileUploadNotification ? mimeTypeToEmoji(mimeGuess) : messageBody;
|
||||
@@ -989,10 +1276,8 @@ class XmppService {
|
||||
// The conversation exists, so we can just update it
|
||||
final newConversation = await cs.updateConversation(
|
||||
conversation.id,
|
||||
lastMessageBody: conversationBody,
|
||||
lastMessage: message,
|
||||
lastChangeTimestamp: messageTimestamp,
|
||||
lastMessageId: message.id,
|
||||
lastMessageRetracted: false,
|
||||
// Do not increment the counter for messages we sent ourselves (via Carbons)
|
||||
// or if we have the chat currently opened
|
||||
unreadCounter: isConversationOpened || sent
|
||||
@@ -1015,11 +1300,10 @@ class XmppService {
|
||||
}
|
||||
} else {
|
||||
// The conversation does not exist, so we must create it
|
||||
final contactId = await css.getContactIdForJid(conversationJid);
|
||||
final newConversation = await cs.addConversationFromData(
|
||||
rosterItem?.title ?? conversationJid.split('@')[0],
|
||||
message.id,
|
||||
false,
|
||||
conversationBody,
|
||||
message,
|
||||
rosterItem?.avatarUrl ?? '',
|
||||
conversationJid,
|
||||
sent ? 0 : 1,
|
||||
@@ -1027,6 +1311,9 @@ class XmppService {
|
||||
true,
|
||||
prefs.defaultMuteState,
|
||||
message.encrypted,
|
||||
contactId,
|
||||
await css.getProfilePicturePathForJid(conversationJid),
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
// Notify the UI
|
||||
@@ -1043,13 +1330,15 @@ class XmppService {
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the UI of the message
|
||||
if (message.isDownloading != (event.fun != null)) {
|
||||
// Mark the file as downlading when it includes a File Upload Notification
|
||||
if (event.fun != null) {
|
||||
message = await ms.updateMessage(
|
||||
message.id,
|
||||
isDownloading: event.fun != null,
|
||||
isDownloading: true,
|
||||
);
|
||||
}
|
||||
|
||||
// Notify the UI of the message
|
||||
sendEvent(MessageAddedEvent(message: message));
|
||||
}
|
||||
|
||||
@@ -1097,12 +1386,13 @@ class XmppService {
|
||||
sendEvent(MessageUpdatedEvent(message: message));
|
||||
|
||||
if (shouldDownload) {
|
||||
_log.finest('Advertised file MIME: ${_getMimeGuess(event)}');
|
||||
await GetIt.I.get<HttpFileTransferService>().downloadFile(
|
||||
FileDownloadJob(
|
||||
embeddedFile,
|
||||
message.id,
|
||||
conversationJid,
|
||||
null,
|
||||
_getMimeGuess(event),
|
||||
shouldShowNotification: false,
|
||||
),
|
||||
);
|
||||
@@ -1112,27 +1402,27 @@ 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 {
|
||||
await GetIt.I.get<AvatarService>().updateAvatarForJid(
|
||||
event.jid,
|
||||
event.hash,
|
||||
event.base64,
|
||||
);
|
||||
await GetIt.I.get<AvatarService>().handleAvatarUpdate(event);
|
||||
}
|
||||
|
||||
Future<void> _onStanzaAcked(StanzaAckedEvent event, { dynamic extra }) async {
|
||||
final jid = JID.fromString(event.stanza.to!).toBare().toString();
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final msg = await ms.getMessageByStanzaId(jid, event.stanza.id!);
|
||||
if (msg != null) {
|
||||
// Ack the message
|
||||
final newMsg = await ms.updateMessage(msg.id, acked: true);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: newMsg));
|
||||
|
||||
// Ack the conversation
|
||||
final conv = await cs.getConversationByJid(jid);
|
||||
if (conv != null && conv.lastMessage?.id == newMsg.id) {
|
||||
final newConv = conv.copyWith(lastMessage: msg);
|
||||
cs.setConversation(newConv);
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
}
|
||||
} else {
|
||||
_log.finest('Wanted to mark message as acked but did not find the message to ack');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@ import 'package:moxlib/awaitabledatasender.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.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/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
|
||||
part 'events.moxxy.dart';
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
import 'package:moxxyv2/i18n/strings.g.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:video_thumbnail/video_thumbnail.dart';
|
||||
|
||||
/// Add a leading zero, if required, to ensure that an integer is rendered
|
||||
/// as a two "digit" string.
|
||||
@@ -34,7 +40,7 @@ String formatConversationTimestamp(int timestamp, int now) {
|
||||
return '${hourDifference}h';
|
||||
}
|
||||
} else if (difference <= Duration.millisecondsPerMinute) {
|
||||
return 'Just now';
|
||||
return t.dateTime.justNow;
|
||||
}
|
||||
|
||||
return '${(difference / Duration.millisecondsPerMinute).floor()}min';
|
||||
@@ -52,9 +58,10 @@ String formatMessageTimestamp(int timestamp, int now) {
|
||||
return '${dt.hour}:${padInt(dt.minute)}';
|
||||
} else {
|
||||
if (difference < Duration.millisecondsPerMinute) {
|
||||
return 'Just now';
|
||||
return t.dateTime.justNow;
|
||||
} 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) {
|
||||
switch (day) {
|
||||
case DateTime.monday:
|
||||
return 'Mon';
|
||||
return t.dateTime.mondayAbbrev;
|
||||
case DateTime.tuesday:
|
||||
return 'Tue';
|
||||
return t.dateTime.tuesdayAbbrev;
|
||||
case DateTime.wednesday:
|
||||
return 'Wed';
|
||||
return t.dateTime.wednessdayAbbrev;
|
||||
case DateTime.thursday:
|
||||
return 'Thu';
|
||||
return t.dateTime.thursdayAbbrev;
|
||||
case DateTime.friday:
|
||||
return 'Fri';
|
||||
return t.dateTime.fridayAbbrev;
|
||||
case DateTime.saturday:
|
||||
return 'Sat';
|
||||
return t.dateTime.saturdayAbbrev;
|
||||
case DateTime.sunday:
|
||||
return 'Sun';
|
||||
return t.dateTime.sundayAbbrev;
|
||||
}
|
||||
|
||||
// Should not happen
|
||||
@@ -86,29 +93,29 @@ String weekdayToStringAbbrev(int day) {
|
||||
String monthToString(int month) {
|
||||
switch (month) {
|
||||
case DateTime.january:
|
||||
return 'January';
|
||||
return t.dateTime.january;
|
||||
case DateTime.february:
|
||||
return 'February';
|
||||
return t.dateTime.february;
|
||||
case DateTime.march:
|
||||
return 'March';
|
||||
return t.dateTime.march;
|
||||
case DateTime.april:
|
||||
return 'April';
|
||||
return t.dateTime.april;
|
||||
case DateTime.may:
|
||||
return 'May';
|
||||
return t.dateTime.may;
|
||||
case DateTime.june:
|
||||
return 'June';
|
||||
return t.dateTime.june;
|
||||
case DateTime.july:
|
||||
return 'July';
|
||||
return t.dateTime.july;
|
||||
case DateTime.august:
|
||||
return 'August';
|
||||
return t.dateTime.august;
|
||||
case DateTime.september:
|
||||
return 'September';
|
||||
return t.dateTime.september;
|
||||
case DateTime.october:
|
||||
return 'October';
|
||||
return t.dateTime.october;
|
||||
case DateTime.november:
|
||||
return 'November';
|
||||
return t.dateTime.november;
|
||||
case DateTime.december:
|
||||
return 'December';
|
||||
return t.dateTime.december;
|
||||
}
|
||||
|
||||
// Should not happen
|
||||
@@ -119,9 +126,9 @@ String monthToString(int month) {
|
||||
/// like 'Today', 'Yesterday', 'Fri, 7. August' or '6. August 2022'.
|
||||
String formatDateBubble(DateTime dt, DateTime now) {
|
||||
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) {
|
||||
return 'Yesterday';
|
||||
return t.dateTime.yesterday;
|
||||
} else if (dt.year == now.year) {
|
||||
return '${weekdayToStringAbbrev(dt.weekday)}, ${dt.day}. ${monthToString(dt.month)}';
|
||||
} else {
|
||||
@@ -201,19 +208,46 @@ String? guessMimeTypeFromExtension(String ext) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Return the translated name describing the MIME type [mime]. If [mime] is null or
|
||||
/// the MIME type is neither image, video or audio, then it falls back to the
|
||||
/// translation of "file".
|
||||
String mimeTypeToName(String? mime) {
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image')) {
|
||||
return t.messages.image;
|
||||
} else if (mime.startsWith('audio')) {
|
||||
return t.messages.audio;
|
||||
} else if (mime.startsWith('video')) {
|
||||
return t.messages.video;
|
||||
}
|
||||
}
|
||||
|
||||
return t.messages.file;
|
||||
}
|
||||
|
||||
/// Return an emoji for the MIME type [mime]. If [addTypeName] id true, then a human readable
|
||||
/// name for the MIME type will be appended.
|
||||
String mimeTypeToEmoji(String? mime, {bool addTypeName = true}) {
|
||||
String value;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image')) {
|
||||
return '🖼️${addTypeName ? " ${t.messages.image}" : ""}';
|
||||
value = '🖼️';
|
||||
} else if (mime.startsWith('audio')) {
|
||||
return '🎙${addTypeName ? " ${t.messages.audio}" : ""}';
|
||||
value = '🎙';
|
||||
} else if (mime.startsWith('video')) {
|
||||
return '🎬${addTypeName ? " ${t.messages.video}" : ""}';
|
||||
value = '🎬';
|
||||
} else {
|
||||
value = '📁';
|
||||
}
|
||||
} else {
|
||||
value = '📁';
|
||||
}
|
||||
return '📁${addTypeName ? " ${t.messages.file}" : ""}';
|
||||
|
||||
if (addTypeName) {
|
||||
value += ' ${mimeTypeToName(mime)}';
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Parse an Uri and return the "filename".
|
||||
@@ -290,3 +324,91 @@ String fileSizeToString(int size) {
|
||||
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,7 +1,10 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/media.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
|
||||
part 'conversation.freezed.dart';
|
||||
part 'conversation.g.dart';
|
||||
@@ -18,14 +21,27 @@ class ConversationChatStateConverter implements JsonConverter<ChatState, Map<Str
|
||||
};
|
||||
}
|
||||
|
||||
class ConversationMessageConverter implements JsonConverter<Message?, Map<String, dynamic>> {
|
||||
const ConversationMessageConverter();
|
||||
|
||||
@override
|
||||
Message? fromJson(Map<String, dynamic> json) {
|
||||
if (json['message'] == null) return null;
|
||||
|
||||
return Message.fromJson(json['message']! as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(Message? message) => <String, dynamic>{
|
||||
'message': message?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
@freezed
|
||||
class Conversation with _$Conversation {
|
||||
factory Conversation(
|
||||
String title,
|
||||
// NOTE: The internal database Id of the message
|
||||
int lastMessageId,
|
||||
bool lastMessageRetracted,
|
||||
String lastMessageBody,
|
||||
@ConversationMessageConverter() Message? lastMessage,
|
||||
String avatarUrl,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
@@ -45,6 +61,14 @@ class Conversation with _$Conversation {
|
||||
bool encrypted,
|
||||
// The current chat state
|
||||
@ConversationChatStateConverter() ChatState chatState,
|
||||
{
|
||||
// The id of the contact in the device's phonebook if it exists
|
||||
String? contactId,
|
||||
// The path to the contact avatar, if available
|
||||
String? contactAvatarPath,
|
||||
// The contact's display name, if it exists
|
||||
String? contactDisplayName,
|
||||
}
|
||||
) = _Conversation;
|
||||
|
||||
const Conversation._();
|
||||
@@ -52,7 +76,7 @@ class Conversation with _$Conversation {
|
||||
/// JSON
|
||||
factory Conversation.fromJson(Map<String, dynamic> json) => _$ConversationFromJson(json);
|
||||
|
||||
factory Conversation.fromDatabaseJson(Map<String, dynamic> json, bool inRoster, String subscription, List<Map<String, dynamic>> sharedMedia) {
|
||||
factory Conversation.fromDatabaseJson(Map<String, dynamic> json, bool inRoster, String subscription, List<Map<String, dynamic>> sharedMedia, Message? lastMessage) {
|
||||
return Conversation.fromJson({
|
||||
...json,
|
||||
'muted': intToBool(json['muted']! as int),
|
||||
@@ -62,8 +86,9 @@ class Conversation with _$Conversation {
|
||||
'subscription': subscription,
|
||||
'encrypted': intToBool(json['encrypted']! as int),
|
||||
'chatState': const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||
'lastMessageRetracted': intToBool(json['lastMessageRetracted']! as int)
|
||||
});
|
||||
}).copyWith(
|
||||
lastMessage: lastMessage,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
@@ -72,16 +97,42 @@ class Conversation with _$Conversation {
|
||||
..remove('chatState')
|
||||
..remove('sharedMedia')
|
||||
..remove('inRoster')
|
||||
..remove('subscription');
|
||||
..remove('subscription')
|
||||
..remove('lastMessage');
|
||||
|
||||
return {
|
||||
...map,
|
||||
'open': boolToInt(open),
|
||||
'muted': boolToInt(muted),
|
||||
'encrypted': boolToInt(encrypted),
|
||||
'lastMessageRetracted': boolToInt(lastMessageRetracted),
|
||||
'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.
|
||||
|
||||
@@ -3,39 +3,55 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
|
||||
part 'message.freezed.dart';
|
||||
part 'message.g.dart';
|
||||
|
||||
const pseudoMessageTypeNewDevice = 1;
|
||||
|
||||
Map<String, String>? _optionalJsonDecode(String? data) {
|
||||
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;
|
||||
|
||||
return jsonEncode(data);
|
||||
}
|
||||
|
||||
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
if (data.isEmpty) return null;
|
||||
|
||||
return jsonEncode(data);
|
||||
}
|
||||
|
||||
@freezed
|
||||
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(
|
||||
String sender,
|
||||
String body,
|
||||
int timestamp,
|
||||
String sid,
|
||||
// The database-internal identifier of the message
|
||||
int id,
|
||||
String conversationJid,
|
||||
// True if the message contains some embedded media
|
||||
bool isMedia,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||
bool containsNoStore,
|
||||
{
|
||||
int? errorType,
|
||||
int? warningType,
|
||||
@@ -46,6 +62,7 @@ class Message with _$Message {
|
||||
String? thumbnailData,
|
||||
int? mediaWidth,
|
||||
int? mediaHeight,
|
||||
// If non-null: Indicates where some media entry originated/originates from
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
String? iv,
|
||||
@@ -54,12 +71,18 @@ class Message with _$Message {
|
||||
@Default(false) bool displayed,
|
||||
@Default(false) bool acked,
|
||||
@Default(false) bool isRetracted,
|
||||
@Default(false) bool isEdited,
|
||||
String? originId,
|
||||
Message? quotes,
|
||||
String? filename,
|
||||
Map<String, String>? plaintextHashes,
|
||||
Map<String, String>? ciphertextHashes,
|
||||
int? mediaSize,
|
||||
@Default([]) List<Reaction> reactions,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}
|
||||
) = _Message;
|
||||
|
||||
@@ -82,13 +105,25 @@ class Message with _$Message {
|
||||
'isDownloading': intToBool(json['isDownloading']! as int),
|
||||
'isUploading': intToBool(json['isUploading']! 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() {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('quotes');
|
||||
..remove('quotes')
|
||||
..remove('reactions')
|
||||
..remove('pseudoMessageData');
|
||||
|
||||
return {
|
||||
...map,
|
||||
@@ -105,6 +140,14 @@ class Message with _$Message {
|
||||
'isDownloading': boolToInt(isDownloading),
|
||||
'isUploading': boolToInt(isUploading),
|
||||
'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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
||||
bool canRetract(bool sentBySelf) {
|
||||
return originId != null && sentBySelf && !isFileUploadNotification && !isUploading && !isDownloading;
|
||||
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.
|
||||
/// [sentBySelf] asks whether or not the message was sent by us (the current Jid).
|
||||
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
|
||||
/// 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
|
||||
/// 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
|
||||
/// images.
|
||||
bool get thumbnailable => isMedia && mediaType != null && (
|
||||
bool get isThumbnailable => !isPseudoMessage && isMedia && mediaType != null && (
|
||||
mediaType!.startsWith('image/') ||
|
||||
mediaType!.startsWith('video/')
|
||||
);
|
||||
|
||||
/// 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
|
||||
// be used
|
||||
@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;
|
||||
|
||||
// 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:moxxyv2/service/database/helpers.dart';
|
||||
|
||||
part 'roster.freezed.dart';
|
||||
part 'roster.g.dart';
|
||||
@@ -13,7 +14,18 @@ class RosterItem with _$RosterItem {
|
||||
String title,
|
||||
String subscription,
|
||||
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,
|
||||
{
|
||||
// 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;
|
||||
|
||||
const RosterItem._();
|
||||
@@ -26,13 +38,20 @@ class RosterItem with _$RosterItem {
|
||||
...json,
|
||||
// TODO(PapaTutuWawa): Fix
|
||||
'groups': <String>[],
|
||||
'pseudoRosterItem': intToBool(json['pseudoRosterItem']! as int),
|
||||
});
|
||||
}
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
return toJson()
|
||||
final json = toJson()
|
||||
..remove('id')
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
43
lib/shared/synchronized_queue.dart
Normal file
43
lib/shared/synchronized_queue.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
/// The function of this class is essentially a queue, that processes itself as long as
|
||||
/// _shouldQueue is false. If not, all added items are held until removeQueueLock is
|
||||
/// called. After that point, all added items bypass the lock and get immediately passed
|
||||
/// to the callback.
|
||||
class SynchronizedQueue<T> {
|
||||
SynchronizedQueue(this._callback);
|
||||
final Future<void> Function(T) _callback;
|
||||
final Queue<T> _queue = Queue<T>();
|
||||
final Lock _lock = Lock();
|
||||
// If true, then events queue up
|
||||
bool _shouldQueue = true;
|
||||
|
||||
Future<void> add(T item) async {
|
||||
if (!_shouldQueue) {
|
||||
unawaited(_callback(item));
|
||||
return;
|
||||
}
|
||||
|
||||
await _lock.synchronized(() {
|
||||
if (!_shouldQueue) {
|
||||
unawaited(_callback(item));
|
||||
return;
|
||||
}
|
||||
|
||||
_queue.addLast(item);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeQueueLock() async {
|
||||
await _lock.synchronized(() async {
|
||||
while (_queue.isNotEmpty) {
|
||||
final item = _queue.removeFirst();
|
||||
await _callback(item);
|
||||
}
|
||||
|
||||
_shouldQueue = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,10 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
AddContactBloc() : super(AddContactState()) {
|
||||
on<AddedContactEvent>(_onContactAdded);
|
||||
on<JidChangedEvent>(_onJidChanged);
|
||||
on<PageResetEvent>(_onPageReset);
|
||||
}
|
||||
|
||||
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);
|
||||
if (validation != null) {
|
||||
emit(state.copyWith(jidError: validation));
|
||||
@@ -30,7 +28,7 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
working: true,
|
||||
isWorking: true,
|
||||
jidError: null,
|
||||
),
|
||||
);
|
||||
@@ -42,14 +40,21 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
),
|
||||
) as AddContactResultEvent;
|
||||
|
||||
await _onPageReset(PageResetEvent(), emit);
|
||||
|
||||
if (result.conversation != null) {
|
||||
if (result.added) {
|
||||
GetIt.I.get<ConversationsBloc>().add(ConversationsAddedEvent(result.conversation!));
|
||||
GetIt.I.get<ConversationsBloc>().add(
|
||||
ConversationsAddedEvent(result.conversation!),
|
||||
);
|
||||
} 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(
|
||||
RequestedConversationEvent(
|
||||
result.conversation!.jid,
|
||||
@@ -61,6 +66,20 @@ class AddContactBloc extends Bloc<AddContactEvent, AddContactState> {
|
||||
}
|
||||
|
||||
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
|
||||
class JidChangedEvent extends AddContactEvent {
|
||||
|
||||
JidChangedEvent(this.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({
|
||||
@Default('') String jid,
|
||||
@Default(null) String? jidError,
|
||||
@Default(false) bool working,
|
||||
@Default(false) bool isWorking,
|
||||
}) = _AddContactState;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
part 'blocklist_bloc.freezed.dart';
|
||||
part 'blocklist_event.dart';
|
||||
@@ -9,11 +13,44 @@ part 'blocklist_state.dart';
|
||||
|
||||
class BlocklistBloc extends Bloc<BlocklistEvent, BlocklistState> {
|
||||
BlocklistBloc() : super(BlocklistState()) {
|
||||
on<BlocklistRequestedEvent>(_onBlocklistRequested);
|
||||
on<UnblockedJidEvent>(_onJidUnblocked);
|
||||
on<UnblockedAllEvent>(_onUnblockedAll);
|
||||
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 {
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
UnblockJidCommand(
|
||||
|
||||
@@ -2,9 +2,11 @@ part of 'blocklist_bloc.dart';
|
||||
|
||||
abstract class BlocklistEvent {}
|
||||
|
||||
/// Triggered when the blocklist page has been requested
|
||||
class BlocklistRequestedEvent extends BlocklistEvent {}
|
||||
|
||||
/// Triggered when a JID is unblocked
|
||||
class UnblockedJidEvent extends BlocklistEvent {
|
||||
|
||||
UnblockedJidEvent(this.jid);
|
||||
final String jid;
|
||||
}
|
||||
@@ -16,7 +18,6 @@ class UnblockedAllEvent extends BlocklistEvent {
|
||||
|
||||
/// Triggered when we receive a blocklist push
|
||||
class BlocklistPushedEvent extends BlocklistEvent {
|
||||
|
||||
BlocklistPushedEvent(this.added, this.removed);
|
||||
final List<String> added;
|
||||
final List<String> removed;
|
||||
|
||||
@@ -4,5 +4,6 @@ part of 'blocklist_bloc.dart';
|
||||
class BlocklistState with _$BlocklistState {
|
||||
factory BlocklistState({
|
||||
@Default(<String>[]) List<String> blocklist,
|
||||
@Default(false) bool isWorking,
|
||||
}) = _BlocklistState;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:flutter/services.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:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/events.dart' as events;
|
||||
import 'package:moxxyv2/shared/models/conversation.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/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package: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_event.dart';
|
||||
@@ -42,10 +51,23 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
on<BackgroundChangedEvent>(_onBackgroundChanged);
|
||||
on<ImagePickerRequestedEvent>(_onImagePickerRequested);
|
||||
on<FilePickerRequestedEvent>(_onFilePickerRequested);
|
||||
on<EmojiPickerToggledEvent>(_onEmojiPickerToggled);
|
||||
on<PickerToggledEvent>(_onPickerToggled);
|
||||
on<OwnJidReceivedEvent>(_onOwnJidReceived);
|
||||
on<OmemoSetEvent>(_onOmemoSet);
|
||||
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
|
||||
ChatState _currentChatState;
|
||||
@@ -54,6 +76,10 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
/// The last time the text has been changed
|
||||
int _lastChangeTimestamp;
|
||||
|
||||
/// The audio recorder
|
||||
late Record _audioRecorder;
|
||||
DateTime? _recordingStart;
|
||||
|
||||
void _setLastChangeTimestamp() {
|
||||
_lastChangeTimestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
}
|
||||
@@ -97,7 +123,8 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
state: s.toString().split('.').last,
|
||||
jid: state.conversation!.jid,
|
||||
),
|
||||
awaitable: false,);
|
||||
awaitable: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onInit(InitConversationEvent event, Emitter<ConversationState> emit) async {
|
||||
@@ -115,6 +142,15 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
state.copyWith(
|
||||
conversation: conversation,
|
||||
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();
|
||||
_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(
|
||||
state.copyWith(
|
||||
messageText: event.value,
|
||||
showSendButton: event.value.isNotEmpty,
|
||||
sendButtonState: sendButtonState,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -174,22 +221,28 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
_stopComposeTimer();
|
||||
|
||||
// ignore: cast_nullable_to_non_nullable
|
||||
final result = await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
await MoxplatformPlugin.handler.getDataSender().sendData(
|
||||
SendMessageCommand(
|
||||
recipients: [state.conversation!.jid],
|
||||
body: state.messageText,
|
||||
quotedMessage: state.quotedMessage,
|
||||
chatState: chatStateToString(ChatState.active),
|
||||
editId: state.messageEditingId,
|
||||
editSid: state.messageEditingSid,
|
||||
),
|
||||
) as events.MessageAddedEvent;
|
||||
awaitable: false,
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
messages: List<Message>.from(<Message>[ ...state.messages, result.message ]),
|
||||
messageText: '',
|
||||
quotedMessage: null,
|
||||
showSendButton: false,
|
||||
emojiPickerVisible: false,
|
||||
sendButtonState: defaultSendButtonState,
|
||||
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 {
|
||||
// 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(
|
||||
AddContactCommand(jid: state.conversation!.jid),
|
||||
);
|
||||
@@ -311,9 +373,13 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onEmojiPickerToggled(EmojiPickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.emojiPickerVisible;
|
||||
emit(state.copyWith(emojiPickerVisible: newState));
|
||||
Future<void> _onPickerToggled(PickerToggledEvent event, Emitter<ConversationState> emit) async {
|
||||
final newState = !state.pickerVisible;
|
||||
emit(
|
||||
state.copyWith(
|
||||
pickerVisible: newState,
|
||||
),
|
||||
);
|
||||
|
||||
if (event.handleKeyboard) {
|
||||
if (newState) {
|
||||
@@ -352,4 +418,252 @@ class ConversationBloc extends Bloc<ConversationEvent, ConversationState> {
|
||||
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 {}
|
||||
|
||||
/// Triggered when the emoji button is pressed
|
||||
class EmojiPickerToggledEvent extends ConversationEvent {
|
||||
EmojiPickerToggledEvent({this.handleKeyboard = true});
|
||||
class PickerToggledEvent extends ConversationEvent {
|
||||
PickerToggledEvent({this.handleKeyboard = true});
|
||||
final bool handleKeyboard;
|
||||
}
|
||||
|
||||
@@ -120,3 +120,56 @@ class MessageRetractedEvent extends ConversationEvent {
|
||||
MessageRetractedEvent(this.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';
|
||||
|
||||
enum SendButtonState {
|
||||
multi,
|
||||
send,
|
||||
cancelCorrection,
|
||||
}
|
||||
const defaultSendButtonState = SendButtonState.multi;
|
||||
|
||||
@freezed
|
||||
class ConversationState with _$ConversationState {
|
||||
factory ConversationState({
|
||||
// Our own JID
|
||||
@Default('') String jid,
|
||||
@Default('') String messageText,
|
||||
@Default(false) bool showSendButton,
|
||||
@Default(defaultSendButtonState) SendButtonState sendButtonState,
|
||||
@Default(null) Message? quotedMessage,
|
||||
@Default(<Message>[]) List<Message> messages,
|
||||
@Default(null) Conversation? conversation,
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
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:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
|
||||
part 'crop_bloc.freezed.dart';
|
||||
part 'crop_event.dart';
|
||||
part 'crop_state.dart';
|
||||
|
||||
class CropBloc extends Bloc<CropEvent, CropState> {
|
||||
|
||||
CropBloc() : super(CropState(null)) {
|
||||
CropBloc() : super(CropState()) {
|
||||
on<ImageCroppedEvent>(_onImageCropped);
|
||||
on<ResetImageEvent>(_onImageReset);
|
||||
on<SetImageEvent>(_onImageSet);
|
||||
}
|
||||
late Completer<Uint8List?> _completer;
|
||||
final GlobalKey cropKey = GlobalKey();
|
||||
|
||||
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());
|
||||
|
||||
emit(CropState(null));
|
||||
await _onImageReset(ResetImageEvent(), emit);
|
||||
}
|
||||
|
||||
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 {
|
||||
emit(CropState(event.image));
|
||||
emit(
|
||||
state.copyWith(
|
||||
image: event.image,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _performCropping(String path) async {
|
||||
|
||||
@@ -2,22 +2,11 @@ part of 'crop_bloc.dart';
|
||||
|
||||
abstract class CropEvent {}
|
||||
|
||||
class ImageCroppedEvent extends CropEvent {
|
||||
|
||||
ImageCroppedEvent(this.image);
|
||||
final Uint8List image;
|
||||
}
|
||||
class ImageCroppedEvent extends CropEvent {}
|
||||
|
||||
class ResetImageEvent extends CropEvent {}
|
||||
|
||||
class SetImageEvent extends CropEvent {
|
||||
|
||||
SetImageEvent(this.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:get_it/get_it.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/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
@@ -46,7 +46,7 @@ void _cropImage(List<dynamic> data) {
|
||||
stackBlurRgba(cropped.data, cropped.width, cropped.height, 20);
|
||||
}
|
||||
|
||||
File(destination).writeAsBytesSync(encodePng(cropped));
|
||||
File(destination).writeAsBytesSync(encodeJpg(cropped, quality: 85));
|
||||
port.send(true);
|
||||
}
|
||||
|
||||
@@ -81,13 +81,13 @@ class CropBackgroundBloc extends Bloc<CropBackgroundEvent, CropBackgroundState>
|
||||
);
|
||||
|
||||
final data = await File(event.path).readAsBytes();
|
||||
final imageSize = ImageSizeGetter.getSize(MemoryInput(data));
|
||||
final imageSize = (await getImageSizeFromData(data))!;
|
||||
emit(
|
||||
state.copyWith(
|
||||
image: data,
|
||||
imagePath: event.path,
|
||||
imageWidth: imageSize.width,
|
||||
imageHeight: imageSize.height,
|
||||
imageWidth: imageSize.width.toInt(),
|
||||
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/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
|
||||
part 'devices_bloc.freezed.dart';
|
||||
part 'devices_event.dart';
|
||||
part 'devices_state.dart';
|
||||
|
||||
class DevicesBloc extends Bloc<DevicesEvent, DevicesState> {
|
||||
|
||||
DevicesBloc() : super(DevicesState()) {
|
||||
on<DevicesRequestedEvent>(_onRequested);
|
||||
on<DeviceEnabledSetEvent>(_onDeviceEnabledSet);
|
||||
on<SessionsRecreatedEvent>(_onSessionsRecreated);
|
||||
on<DeviceVerifiedEvent>(_onDeviceVerified);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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
|
||||
class DevicesRequestedEvent extends DevicesEvent {
|
||||
|
||||
DevicesRequestedEvent(this.jid);
|
||||
final String jid;
|
||||
}
|
||||
|
||||
/// Triggered by the UI when we want to enable or disable a key
|
||||
class DeviceEnabledSetEvent extends DevicesEvent {
|
||||
|
||||
DeviceEnabledSetEvent(this.deviceId, this.enabled);
|
||||
final int deviceId;
|
||||
final bool enabled;
|
||||
@@ -19,3 +17,10 @@ class DeviceEnabledSetEvent extends DevicesEvent {
|
||||
|
||||
/// Triggered by the UI when all OMEMO sessions should be recreated
|
||||
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 {}
|
||||
|
||||
class PushedNamedEvent extends NavigationEvent {
|
||||
|
||||
PushedNamedEvent(this.destination);
|
||||
final NavigationDestination destination;
|
||||
}
|
||||
|
||||
class PushedNamedAndRemoveUntilEvent extends NavigationEvent {
|
||||
|
||||
PushedNamedAndRemoveUntilEvent(this.destination, this.predicate);
|
||||
final NavigationDestination destination;
|
||||
final RoutePredicate predicate;
|
||||
}
|
||||
|
||||
class PushedNamedReplaceEvent extends NavigationEvent {
|
||||
|
||||
PushedNamedReplaceEvent(this.destination);
|
||||
final NavigationDestination destination;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ class NewConversationBloc extends Bloc<NewConversationEvent, NewConversationStat
|
||||
final roster = List<RosterItem>.from(event.added);
|
||||
|
||||
for (final item in state.roster) {
|
||||
// Handle removed items
|
||||
if (event.removed.contains(item.jid)) continue;
|
||||
|
||||
// Handle modified items
|
||||
final modified = firstWhereOrNull(
|
||||
event.modified,
|
||||
(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/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/helpers.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
|
||||
part 'own_devices_bloc.freezed.dart';
|
||||
@@ -14,13 +15,13 @@ part 'own_devices_event.dart';
|
||||
part 'own_devices_state.dart';
|
||||
|
||||
class OwnDevicesBloc extends Bloc<OwnDevicesEvent, OwnDevicesState> {
|
||||
|
||||
OwnDevicesBloc() : super(OwnDevicesState()) {
|
||||
on<OwnDevicesRequestedEvent>(_onRequested);
|
||||
on<OwnDeviceEnabledSetEvent>(_onDeviceEnabledSet);
|
||||
on<OwnSessionsRecreatedEvent>(_onSessionsRecreated);
|
||||
on<OwnDeviceRemovedEvent>(_onDeviceRemoved);
|
||||
on<OwnDeviceRegeneratedEvent>(_onDeviceRegenerated);
|
||||
on<DeviceVerifiedEvent>(_onDeviceVerified);
|
||||
}
|
||||
|
||||
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.
|
||||
class OwnDeviceRemovedEvent extends OwnDevicesEvent {
|
||||
|
||||
OwnDeviceRemovedEvent(this.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,9 +6,8 @@ abstract class PreferencesEvent {}
|
||||
/// If [notify] is true, then the background service will be
|
||||
/// notified of this change.
|
||||
class PreferencesChangedEvent extends PreferencesEvent {
|
||||
|
||||
PreferencesChangedEvent(this.preferences, {
|
||||
this.notify = true,
|
||||
this.notify = true,
|
||||
});
|
||||
final PreferencesState preferences;
|
||||
final bool notify;
|
||||
@@ -19,7 +18,6 @@ class SignedOutEvent extends PreferencesEvent {}
|
||||
|
||||
/// Triggered when a background image has been set
|
||||
class BackgroundImageSetEvent extends PreferencesEvent {
|
||||
|
||||
BackgroundImageSetEvent(this.backgroundPath);
|
||||
final String backgroundPath;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ class ServerInfoBloc extends Bloc<ServerInfoEvent, ServerInfoState> {
|
||||
csiSupported: result.supportsCsi,
|
||||
httpFileUploadSupported: result.supportsHttpFileUpload,
|
||||
userBlockingSupported: result.supportsUserBlocking,
|
||||
carbonsSupported: result.supportsCarbons,
|
||||
working: false,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,5 +8,6 @@ class ServerInfoState with _$ServerInfoState {
|
||||
@Default(false) bool userBlockingSupported,
|
||||
@Default(false) bool httpFileUploadSupported,
|
||||
@Default(false) bool csiSupported,
|
||||
@Default(false) bool carbonsSupported,
|
||||
}) = _ServerInfoState;
|
||||
}
|
||||
|
||||
@@ -26,12 +26,26 @@ enum ShareSelectionType {
|
||||
|
||||
/// Create a common ground between Conversations and RosterItems
|
||||
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 jid;
|
||||
final String title;
|
||||
final bool isConversation;
|
||||
final bool isEncrypted;
|
||||
final bool pseudoRosterItem;
|
||||
final String? contactId;
|
||||
final String? contactAvatarPath;
|
||||
final String? contactDisplayName;
|
||||
}
|
||||
|
||||
class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState> {
|
||||
@@ -67,6 +81,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
c.title,
|
||||
true,
|
||||
c.encrypted,
|
||||
false,
|
||||
c.contactId,
|
||||
c.contactAvatarPath,
|
||||
c.contactDisplayName,
|
||||
);
|
||||
}),
|
||||
);
|
||||
@@ -83,6 +101,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
rosterItem.title,
|
||||
false,
|
||||
GetIt.I.get<PreferencesBloc>().state.enableOmemoByDefault,
|
||||
rosterItem.pseudoRosterItem,
|
||||
rosterItem.contactId,
|
||||
rosterItem.contactAvatarPath,
|
||||
rosterItem.contactDisplayName,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@@ -92,6 +114,10 @@ class ShareSelectionBloc extends Bloc<ShareSelectionEvent, ShareSelectionState>
|
||||
rosterItem.title,
|
||||
false,
|
||||
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 {
|
||||
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(
|
||||
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;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user