Compare commits
468 Commits
b0f266bb0a
...
v0.4.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c42c117a0 | |||
| d795cb717e | |||
| 1d5d1fdf86 | |||
| d795c34dab | |||
| b38f5c139f | |||
| b623f32fbf | |||
| 19fd079436 | |||
| 7d70a96533 | |||
| dce6e34289 | |||
| 881f080916 | |||
| 051687535b | |||
| 0b420933e0 | |||
| 0b3876c3f0 | |||
| 9711d45a7a | |||
| 8dcba94de7 | |||
| 226dca8c1a | |||
| ad01a7e3e3 | |||
| adde5a4134 | |||
| 9ae1807225 | |||
| e7f8446c02 | |||
| 7b05bf200c | |||
| e992cb309f | |||
| 0f138678ec | |||
| 35658e611a | |||
| 2a25cd44cf | |||
| 29053df245 | |||
| 78ad02ec80 | |||
| e3f2ef22a6 | |||
| f884e181e3 | |||
| e69d7ed0a2 | |||
| d65e11a3ea | |||
| 294d0ee02c | |||
| 6f4abebb32 | |||
| 5d83796b37 | |||
| a06c697fe3 | |||
| 5de2a8b6af | |||
| 7234f67c42 | |||
| 972f5079f9 | |||
| 27d4ed1781 | |||
| 5f074ef695 | |||
| d0f60519fd | |||
| cd7c495cb7 | |||
| 59317d45f9 | |||
| 7c2c9f978d | |||
| d540f0c2f2 | |||
| 340bbb7ca8 | |||
| 0aaffd1249 | |||
| 04be2e8c88 | |||
| 57dbe83901 | |||
| 60c5328eb0 | |||
| 189d9ca9cd | |||
| 5d797b1e66 | |||
| 2f1a40b4d9 | |||
| 02c0cd5af0 | |||
| f2a70cd137 | |||
| 8d88c25f05 | |||
| c1c5625441 | |||
| 462e800907 | |||
| faa5ee2c4f | |||
| 5dad5730ce | |||
| 5017187927 | |||
| 14e7f72bd3 | |||
| 9ef67f5788 | |||
| 79226f6ca8 | |||
| c8c0239e36 | |||
| f1be10bf8c | |||
| 18c3c9d324 | |||
| 4825fe881d | |||
| 081d20fe50 | |||
| c1a66711db | |||
| b113e78423 | |||
| 470e8aac9c | |||
| 39babfbadd | |||
| 86f7e63f65 | |||
| ecd2a71981 | |||
| 2ece9e6209 | |||
| 9310b9c305 | |||
| abad9897b8 | |||
| 0cfffff94c | |||
| 6c53103345 | |||
| 346ef66bca | |||
| e092201030 | |||
| 3c14521ca0 | |||
| 4b43427bf0 | |||
| b7f39fe8ed | |||
| 1f64569bc2 | |||
| 7c56383601 | |||
| 2de50b012b | |||
| 1de90e3ce1 | |||
| 64a175819f | |||
| 4cc507832c | |||
| fd1e14e4cd | |||
|
|
a78db354ab | ||
|
|
a86d83eeba | ||
|
|
02e73ade5e | ||
| 9d0a84b317 | |||
|
|
0cf237914b | ||
|
|
398c23fccb | ||
| 8f68292dfd | |||
|
|
8ef62e7ff1 | ||
|
|
99257f4b28 | ||
|
|
9f529a3a1c | ||
| 8178a0dd8a | |||
| 0f250b6eae | |||
| 716579cc5e | |||
| 25caf3f4a6 | |||
|
|
1c1b598768 | ||
|
|
7cbb56dc2c | ||
| 7f41ec2aac | |||
|
|
ac5fc38de6 | ||
| 1f3c568d0c | |||
|
|
2a186377df | ||
|
|
d529974cd9 | ||
|
|
f378c60bf5 | ||
|
|
e4523a2d33 | ||
|
|
4aacd36c59 | ||
|
|
a291d9ab07 | ||
|
|
9d73fc3a94 | ||
|
|
8a33d88e31 | ||
|
|
6650686d48 | ||
|
|
8570997cb0 | ||
|
|
31ee7b919b | ||
|
|
30f6ecd2f8 | ||
|
|
9e3700001d | ||
|
|
2928602e8d | ||
|
|
09fc55d2c7 | ||
| b391425d48 | |||
| 3b21486647 | |||
| 641ac01b33 | |||
| 233370b448 | |||
| 45bff04329 | |||
| 6d32387e6c | |||
| 4f51cf1f80 | |||
| 46f7e5beaa | |||
| fee39f56fa | |||
| a3e8758dbd | |||
|
|
2b6ed19847 | ||
|
|
34971950ad | ||
|
|
29b22b7dd9 | ||
| 8bc4771345 | |||
| 314c8f8d18 | |||
| dd3e47e492 | |||
| 7f90f3315a | |||
| ceb43c0f0f | |||
| e225cab90a | |||
| 87793a032c | |||
| b3227129d5 | |||
| 5861c7f8cb | |||
| 1181d1c526 | |||
| b3c02324aa | |||
| 3664b5f8c5 | |||
| d58bf448ef | |||
| 95d1e1ed38 | |||
| bbaa41f389 | |||
| 20bff17c74 | |||
| 31a7d18905 | |||
| c4f04b73be | |||
| 188c6199c9 | |||
| 62413eb8e4 | |||
| 1c4697caa7 | |||
| 785272ba21 | |||
| d28e669b5f | |||
| fe3b07aa2f | |||
| a21ecf9bbf | |||
| 55113543dd | |||
| 76041671eb | |||
| be2d4ec29f | |||
| dfa221768c | |||
| 9b2278a0ff | |||
| 24b0a0c7bb | |||
| 023ad574a8 | |||
| 74772dc6b5 | |||
| 8fc7734827 | |||
| 43659b01bd | |||
| de2e2f3987 | |||
| 28591a6787 | |||
| e78dae0950 | |||
| 5b86f69444 | |||
| 92a7d30e43 | |||
| fa311bfb95 | |||
| c1988a9bcd | |||
| 27185b21b5 | |||
| bad4295aec | |||
| b891f29e11 | |||
| 35a752e565 | |||
| 6c5189744a | |||
| 81e9a7d420 | |||
| 3a01025471 | |||
| e652ecca44 | |||
| c244d54d22 | |||
| cff9000d6b | |||
| dc8804de3a | |||
| 92467630cd | |||
| 452734a433 | |||
| 49c7b18d57 | |||
| f7665403b9 | |||
| 9ae047b2d0 | |||
| 4523d87028 | |||
| c34c0ffd0f | |||
| a179d0f6cc | |||
| 6c1b7c54d0 | |||
| bbb59ac2cc | |||
| f16d33decd | |||
| c4e5504c1d | |||
| 0fb8230e50 | |||
| 86be724246 | |||
| 27b3ad0da5 | |||
| 25167ed078 | |||
| 7fb0cf139b | |||
| 6e8d54c91b | |||
| a6191fd8af | |||
| bfeea6ffa5 | |||
| 48451385e9 | |||
| 0e894f84cc | |||
| 0ca12232a8 | |||
| c2d28efe62 | |||
| 0496c38496 | |||
| dd4c481c4f | |||
| 7f1b5233e8 | |||
|
|
41aae3cab9 | ||
| 9838fbc95f | |||
| f5c59823bf | |||
| 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 artifacts
|
||||||
.android
|
.android
|
||||||
|
|
||||||
|
# Build scripts
|
||||||
|
release-*/
|
||||||
|
|||||||
2
.gitlint
2
.gitlint
@@ -7,7 +7,7 @@ line-length=72
|
|||||||
[title-trailing-punctuation]
|
[title-trailing-punctuation]
|
||||||
[title-hard-tab]
|
[title-hard-tab]
|
||||||
[title-match-regex]
|
[title-match-regex]
|
||||||
regex=^(feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\): .*$
|
regex=^((feat|fix|chore|refactor)\((service|ui|shared|all|tests|i18n|docs|flake)+(,(service|ui|shared|all|tests|i18n|docs|flake))*\)|release): [A-Z0-9].*$
|
||||||
|
|
||||||
|
|
||||||
[body-trailing-whitespace]
|
[body-trailing-whitespace]
|
||||||
|
|||||||
81
CONTRIBUTING.md
Normal file
81
CONTRIBUTING.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# Contribution Guide
|
||||||
|
|
||||||
|
Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working
|
||||||
|
on the Moxxy codebase.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before building or working on Moxxy, please make sure that your development environment is correctly set up.
|
||||||
|
Moxxy requires Flutter 3.7.3, since we use a fork of the Flutter library, and the JDK 17. Building Moxxy
|
||||||
|
is currently only supported for Android.
|
||||||
|
|
||||||
|
### Android Studio
|
||||||
|
|
||||||
|
If you use Android Studio, make sure that you use version "Flamingo Canary 3", as that one comes bundled with
|
||||||
|
JDK 17, instead of JDK 11 ([See here](https://codeberg.org/moxxy/moxxy/issues/252)). If that is
|
||||||
|
not an option, you can manually add a JDK 17 installation in Android Studio and tell the Flutter addon
|
||||||
|
to use that installation instead.
|
||||||
|
|
||||||
|
### NixOS
|
||||||
|
|
||||||
|
If you use NixOS or Nix, you can use the dev shell provided by the Flake in the repository's root. It contains
|
||||||
|
the correct JDK and Flutter version. However, make sure that other environment variables, like
|
||||||
|
`ANDROID_HOME` and `ANDROID_AVD_HOME`, are correctly set.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Currently, Moxxy contains a git submodule. While it is not utilised at the moment, it contains
|
||||||
|
the list of suggested XMPP providers to use for auto-registration. To properly clone the
|
||||||
|
repository, use `git clone --recursive https://codeberg.org/moxxy/moxxy.git`
|
||||||
|
|
||||||
|
In order to build Moxxy, you first have to run the code generator. To do that, first install all dependencies with
|
||||||
|
`flutter pub get`. Next, run the code generator using `flutter pub run build_runner build`. This builds required
|
||||||
|
data classes and the i18n support.
|
||||||
|
|
||||||
|
Finally, you can build Moxxy using `flutter run`, if you want to test a change, or `flutter build apk --release` to build
|
||||||
|
an optimized release build. The release builds found in the repository's releases are build using `flutter build apk --release --split-per-abi`.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
If you want to fix a small issue, you can just fork, create a new branch, and start working right away. However, if you want to work
|
||||||
|
on a bigger feature, please first create an issue (if an issue does not already exist) or join the [development chat](xmpp:moxxy@muc.moxxy.org?join) (xmpp:moxxy@muc.moxxy.org?join)
|
||||||
|
to discuss the feature first.
|
||||||
|
|
||||||
|
Before creating a pull request, please make sure you checked every item on the following checklist:
|
||||||
|
|
||||||
|
- [ ] I formatted the code with the dart formatter (`dart format`) before running the linter
|
||||||
|
- [ ] I ran the linter (`flutter analyze`) and introduced no new linter warnings
|
||||||
|
- [ ] I ran the tests (`flutter test`) and introduced no new failing tests
|
||||||
|
- [ ] I used [gitlint](https://github.com/jorisroovers/gitlint) to ensure propper formatting of my commig messages
|
||||||
|
|
||||||
|
If you think that your code is ready for a pull request, but you are not sure if it is ready, prefix the PR's title with "WIP: ", so that discussion
|
||||||
|
can happen there. If you think your PR is ready for review, remove the "WIP: " prefix.
|
||||||
|
|
||||||
|
### Code Guidelines
|
||||||
|
#### Commit messages
|
||||||
|
|
||||||
|
Commit messages should be uniformly formatted. `gitlint` is a linter for commit messages that enforces those guidelines. They are defined in the `.gitlint` file
|
||||||
|
at the root of the repository. `gitlint` can be installed as a pre-commit hook using
|
||||||
|
`gitlint install-hook`. That way, `gitlint` runs on every commit and warns you if the
|
||||||
|
commit message violates any of the defined rules.
|
||||||
|
|
||||||
|
Commit messages always follow the following format:
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<areas>): <summary>
|
||||||
|
|
||||||
|
<full message>
|
||||||
|
```
|
||||||
|
|
||||||
|
`<type>` is the type of action that was performed in the commit and is one of the following: `feat` (Addition of a feature), `fix` (Fix a bug or other issue), `chore` (Bump dependency versions, fix formatter issues), `refactor` (A bigger "moving around" or rewriting of code), `docs` (Commits that just touch the documentation, be it code or, for example, the README).
|
||||||
|
|
||||||
|
`<areas>` are the areas inside the code that are touched by the change. They are a comma-separated list of one or more of the following: `service` (Everything inside `lib/service`), `ui` (Everything inside `lib/ui`), `shared` (Everything inside `lib/shared`), `all` (A bit of everything is involved), `tests` (Everyting inside `test` or `integration_test`), `i18n` (The translation files have been modified), `docs` (Documentation of any kind), `flake` (The NixOS flake has been modified).
|
||||||
|
|
||||||
|
`<summary>` is the summary of the entire commit in a few words. Make that that the entire
|
||||||
|
first line is not longer than 72 characters. `<summary>` also must start with an uppercase
|
||||||
|
letter or a number.
|
||||||
|
|
||||||
|
The `<full message>` is optional. In case your commit requires more explanation, write it
|
||||||
|
there. Make sure that there is an empty line between the full message and the summary line.
|
||||||
|
|
||||||
|
The exception to these rules is a commit message of the format `release: Release version x.y.z`, as it touches everything and is thus implicitly using `(all)` as an area code.
|
||||||
33
README.md
33
README.md
@@ -2,35 +2,20 @@
|
|||||||
|
|
||||||
An experimental XMPP client that tries to be as easy, modern and beautiful as possible.
|
An experimental XMPP client that tries to be as easy, modern and beautiful as possible.
|
||||||
|
|
||||||
The code is also available on [codeberg](https://codeberg.org/moxxy/moxxyv2).
|
The code is also available on [codeberg](https://codeberg.org/moxxy/moxxy).
|
||||||
|
|
||||||
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80" />](https://apt.izzysoft.de/fdroid/index/apk/org.moxxy.moxxyv2)
|
[<img src="https://gitlab.com/IzzyOnDroid/repo/-/raw/master/assets/IzzyOnDroid.png" alt="Get it on IzzyOnDroid" height="80">](https://apt.izzysoft.de/fdroid/index/apk/org.moxxy.moxxyv2)
|
||||||
|
|
||||||
|
Or [get the latest APK from Codeberg](https://codeberg.org/moxxy/moxxy/releases/latest).
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
|
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/1.png)
|
||||||
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
|
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png" width="20%"></img>](./fastlane/metadata/android/en-US/images/phoneScreenshots/2.png)
|
||||||
|
|
||||||
## Developing and Building
|
## Building and Contributing
|
||||||
|
|
||||||
Clone using `git clone --recursive https://github.com/Polynomdivision/moxxyv2.git`.
|
For build and contribution guidelines, please refer to [`CONTRIBUTING.md`](./CONTRIBUTING.md)
|
||||||
|
|
||||||
In order to build Moxxy, you need to have [Flutter](https://docs.flutter.dev/get-started/install) set
|
|
||||||
up. If you are running NixOS or using Nix, you can also use the Flake at the root of the repository
|
|
||||||
by running `nix develop` to get a development shell including everything that is needed. Note
|
|
||||||
that if you decide to use the Flake, `ANDROID_HOME` and `ANDROID_AVD_HOME` must be set to the respective directories.
|
|
||||||
|
|
||||||
Before building Moxxy, you need to generate all needed data classes. To do this, run
|
|
||||||
`flutter pub get` to install all dependencies. Then run `flutter pub run build_runner build` to generate
|
|
||||||
state classes, data classes and the database schemata. After that is done, you can either
|
|
||||||
build the app with `flutter build apk --debug` to create a debug build,
|
|
||||||
`flutter build apk --release` to create a relase build or just run the app in development
|
|
||||||
mode with `flutter run`.
|
|
||||||
|
|
||||||
After implementing a change or a feature, please ensure that nothing is broken by the change
|
|
||||||
by running `flutter test` afterwards. Also make sure that the code passes the linter by
|
|
||||||
running `flutter analyze`. This project also uses [gitlint](https://github.com/jorisroovers/gitlint)
|
|
||||||
to ensure uniform formatting of commit messages.
|
|
||||||
|
|
||||||
Also, feel free to join the development chat at `moxxy@muc.moxxy.org`.
|
Also, feel free to join the development chat at `moxxy@muc.moxxy.org`.
|
||||||
|
|
||||||
@@ -46,3 +31,9 @@ See `./LICENSE`.
|
|||||||
## Special Thanks
|
## Special Thanks
|
||||||
|
|
||||||
- New logo designed by [Synoh](https://twitter.com/synoh_manda)
|
- New logo designed by [Synoh](https://twitter.com/synoh_manda)
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you like what I do and you want to support me, feel free to donate to me on Ko-Fi.
|
||||||
|
|
||||||
|
[<img src="https://codeberg.org/moxxy/moxxyv2/raw/branch/master/assets/repo/kofi.png" height="36" style="height: 36px; border: 0px;"></img>](https://ko-fi.com/papatutuwawa)
|
||||||
|
|||||||
@@ -6,13 +6,11 @@ linter:
|
|||||||
use_setters_to_change_properties: false
|
use_setters_to_change_properties: false
|
||||||
avoid_positional_boolean_parameters: false
|
avoid_positional_boolean_parameters: false
|
||||||
avoid_bool_literals_in_conditional_expressions: false
|
avoid_bool_literals_in_conditional_expressions: false
|
||||||
|
file_names: false
|
||||||
|
|
||||||
analyzer:
|
analyzer:
|
||||||
exclude:
|
exclude:
|
||||||
- "**/*.g.dart"
|
- "**/*.g.dart"
|
||||||
- "**/*.freezed.dart"
|
- "**/*.freezed.dart"
|
||||||
- "**/*.moxxy.dart"
|
- "**/*.moxxy.dart"
|
||||||
- "test/"
|
|
||||||
- "integration_test/"
|
|
||||||
- "lib/service/database/migrations/*.dart"
|
|
||||||
- "lib/i18n/*.dart"
|
- "lib/i18n/*.dart"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@
|
|||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.VIEW" />
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.6.10'
|
ext.kotlin_version = '1.8.21'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@@ -25,15 +25,45 @@
|
|||||||
"messagesChannelDescription": "The notification channel for received messages",
|
"messagesChannelDescription": "The notification channel for received messages",
|
||||||
"warningChannelName": "Warnings",
|
"warningChannelName": "Warnings",
|
||||||
"warningChannelDescription": "Warnings related to Moxxy"
|
"warningChannelDescription": "Warnings related to Moxxy"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"error": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dateTime": {
|
||||||
|
"justNow": "Just now",
|
||||||
|
"nMinutesAgo": "${min}min ago",
|
||||||
|
"mondayAbbrev": "Mon",
|
||||||
|
"tuesdayAbbrev": "Tue",
|
||||||
|
"wednessdayAbbrev": "Wed",
|
||||||
|
"thursdayAbbrev": "Thu",
|
||||||
|
"fridayAbbrev": "Fri",
|
||||||
|
"saturdayAbbrev": "Sat",
|
||||||
|
"sundayAbbrev": "Sun",
|
||||||
|
"january": "January",
|
||||||
|
"february": "February",
|
||||||
|
"march": "March",
|
||||||
|
"april": "April",
|
||||||
|
"may": "May",
|
||||||
|
"june": "June",
|
||||||
|
"july": "July",
|
||||||
|
"august": "August",
|
||||||
|
"september": "September",
|
||||||
|
"october": "October",
|
||||||
|
"november": "November",
|
||||||
|
"december": "December",
|
||||||
|
"today": "Today",
|
||||||
|
"yesterday": "Yesterday"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"image": "Image",
|
"image": "Image",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"file": "File",
|
"file": "File",
|
||||||
|
"sticker": "Sticker",
|
||||||
"retracted": "The message has been retracted",
|
"retracted": "The message has been retracted",
|
||||||
"retractedFallback": "A previous message has been retracted but your client does not support it"
|
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||||
|
"you": "You"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"omemo": {
|
"omemo": {
|
||||||
@@ -41,10 +71,19 @@
|
|||||||
"notEncryptedForDevice": "This message was not encrypted for this device",
|
"notEncryptedForDevice": "This message was not encrypted for this device",
|
||||||
"invalidHmac": "Could not decrypt message",
|
"invalidHmac": "Could not decrypt message",
|
||||||
"noDecryptionKey": "No decryption key available",
|
"noDecryptionKey": "No decryption key available",
|
||||||
"messageInvalidAfixElement": "Invalid encrypted message"
|
"messageInvalidAfixElement": "Invalid encrypted message",
|
||||||
|
|
||||||
|
"verificationInvalidOmemoUrl": "Invalid OMEMO:2 fingerprint",
|
||||||
|
"verificationWrongJid": "Wrong XMPP-address",
|
||||||
|
"verificationWrongDevice": "Wrong OMEMO:2 device",
|
||||||
|
"verificationNotInList": "Wrong OMEMO:2 device",
|
||||||
|
"verificationWrongFingerprint": "Wrong OMEMO:2 fingerprint"
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"connectionTimeout": "Could not connect to server"
|
"connectionTimeout": "Could not connect to server",
|
||||||
|
"saslAccountDisabled": "Your account is disabled",
|
||||||
|
"saslInvalidCredentials": "Your account credentials are invalid",
|
||||||
|
"unrecoverable": "Connection lost due to unrecoverable error"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"saslFailed": "Invalid login credentials",
|
"saslFailed": "Invalid login credentials",
|
||||||
@@ -64,11 +103,20 @@
|
|||||||
"failedToEncryptFile": "The file could not be encrypted",
|
"failedToEncryptFile": "The file could not be encrypted",
|
||||||
"failedToDecryptFile": "The file could not be decrypted",
|
"failedToDecryptFile": "The file could not be decrypted",
|
||||||
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"audioRecordingError": "Failed to finalize audio recording",
|
||||||
|
"openFileNoAppError": "No app found to open this file",
|
||||||
|
"openFileGenericError": "Failed to open file",
|
||||||
|
"messageErrorDialogTitle": "Error"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"message": {
|
"message": {
|
||||||
"integrityCheckFailed": "Could not verify file integrity"
|
"integrityCheckFailed": "Could not verify file integrity"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "Hold button longer to record a voice message"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@@ -87,6 +135,7 @@
|
|||||||
"conversations": {
|
"conversations": {
|
||||||
"speeddialNewChat": "New chat",
|
"speeddialNewChat": "New chat",
|
||||||
"speeddialJoinGroupchat": "Join groupchat",
|
"speeddialJoinGroupchat": "Join groupchat",
|
||||||
|
"speeddialAddNoteToSelf": "Note to self",
|
||||||
"overlaySettings": "Settings",
|
"overlaySettings": "Settings",
|
||||||
"noOpenChats": "You have no open chats",
|
"noOpenChats": "You have no open chats",
|
||||||
"startChat": "Start a chat",
|
"startChat": "Start a chat",
|
||||||
@@ -100,6 +149,7 @@
|
|||||||
"closeChat": "Close chat",
|
"closeChat": "Close chat",
|
||||||
"closeChatConfirmTitle": "Close chat",
|
"closeChatConfirmTitle": "Close chat",
|
||||||
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
||||||
|
"blockShort": "Block",
|
||||||
"blockUser": "Block user",
|
"blockUser": "Block user",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"retract": "Retract message",
|
"retract": "Retract message",
|
||||||
@@ -107,7 +157,21 @@
|
|||||||
"forward": "Forward",
|
"forward": "Forward",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"quote": "Quote",
|
"quote": "Quote",
|
||||||
"copy": "Copy content"
|
"copy": "Copy content",
|
||||||
|
"addReaction": "Add reaction",
|
||||||
|
"showError": "Show error",
|
||||||
|
"showWarning": "Show warning",
|
||||||
|
"addToContacts": "Add to contacts",
|
||||||
|
"addToContactsTitle": "Add ${jid} to contacts",
|
||||||
|
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
|
||||||
|
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
||||||
|
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
||||||
|
"stickerSettings": "Sticker settings",
|
||||||
|
"newDeviceMessage": "${title} added a new encryption device",
|
||||||
|
"messageHint": "Send a message...",
|
||||||
|
"sendImages": "Send images",
|
||||||
|
"sendFiles": "Send files",
|
||||||
|
"takePhotos": "Take photos"
|
||||||
},
|
},
|
||||||
"addcontact": {
|
"addcontact": {
|
||||||
"title": "Add new contact",
|
"title": "Add new contact",
|
||||||
@@ -129,15 +193,16 @@
|
|||||||
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"self": {
|
"general": {
|
||||||
"devices": "Devices"
|
"omemo": "Security",
|
||||||
|
"profile": "Profile",
|
||||||
|
"media": "Media"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"muteChatTooltip": "Mute chat",
|
"notifications": "Notifications",
|
||||||
"unmuteChatTooltip": "Unmute chat",
|
"notificationsMuted": "Muted",
|
||||||
"muteChat": "Mute",
|
"notificationsEnabled": "Enabled",
|
||||||
"unmuteChat": "Unmute",
|
"sharedMedia": "Media"
|
||||||
"devices": "Devices"
|
|
||||||
},
|
},
|
||||||
"owndevices": {
|
"owndevices": {
|
||||||
"title": "Own Devices",
|
"title": "Own Devices",
|
||||||
@@ -153,10 +218,11 @@
|
|||||||
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
|
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
|
||||||
},
|
},
|
||||||
"devices": {
|
"devices": {
|
||||||
"title": "Devices",
|
"title": "Security",
|
||||||
"recreateSessions": "Rebuild sessions",
|
"recreateSessions": "Rebuild sessions",
|
||||||
"recreateSessionsConfirmTitle": "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."
|
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
||||||
|
"noSessions": "There are no cryptographic sessions that are used for end-to-end encryption."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"blocklist": {
|
"blocklist": {
|
||||||
@@ -168,6 +234,18 @@
|
|||||||
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
||||||
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
||||||
},
|
},
|
||||||
|
"cropbackground": {
|
||||||
|
"blur": "Blur background",
|
||||||
|
"setAsBackground": "Set as background image"
|
||||||
|
},
|
||||||
|
"stickerPack": {
|
||||||
|
"removeConfirmTitle": "Remove sticker pack",
|
||||||
|
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
|
||||||
|
"installConfirmTitle": "Install sticker pack",
|
||||||
|
"installConfirmBody": "Are you sure you want to install this sticker pack?",
|
||||||
|
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
|
||||||
|
"fetchingFailure": "Could not find the sticker pack"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -177,12 +255,17 @@
|
|||||||
"signOutConfirmTitle": "Sign Out",
|
"signOutConfirmTitle": "Sign Out",
|
||||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||||
"miscellaneousSection": "Miscellaneous",
|
"miscellaneousSection": "Miscellaneous",
|
||||||
"debuggingSection": "Debugging"
|
"debuggingSection": "Debugging",
|
||||||
|
"general": "General"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "About",
|
"title": "About",
|
||||||
"licensed": "Licensed under GPL3",
|
"licensed": "Licensed under GPL3",
|
||||||
"viewSourceCode": "View source code"
|
"version": "Version ${version}",
|
||||||
|
"viewSourceCode": "View source code",
|
||||||
|
"nMoreToGo": "${n} more to go...",
|
||||||
|
"debugMenuShown": "You are now a developer!",
|
||||||
|
"debugMenuAlreadyShown": "You are already a developer!"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Appearance",
|
"title": "Appearance",
|
||||||
@@ -205,7 +288,10 @@
|
|||||||
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
||||||
"newChatsSection": "New Conversations",
|
"newChatsSection": "New Conversations",
|
||||||
"newChatsMuteByDefault": "Mute new chats by default",
|
"newChatsMuteByDefault": "Mute new chats by default",
|
||||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental"
|
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
|
||||||
|
"behaviourSection": "Behaviour",
|
||||||
|
"contactsIntegration": "Contacts integration",
|
||||||
|
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
|
||||||
},
|
},
|
||||||
"debugging": {
|
"debugging": {
|
||||||
"title": "Debugging options",
|
"title": "Debugging options",
|
||||||
@@ -224,6 +310,7 @@
|
|||||||
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
||||||
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
||||||
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
||||||
|
"automaticDownloadAlways": "Always",
|
||||||
"wifi": "Wifi",
|
"wifi": "Wifi",
|
||||||
"mobileData": "Mobile data"
|
"mobileData": "Mobile data"
|
||||||
},
|
},
|
||||||
@@ -234,8 +321,6 @@
|
|||||||
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
|
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
|
||||||
"profilePictureVisibility": "Make profile picture public",
|
"profilePictureVisibility": "Make profile picture public",
|
||||||
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
|
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
|
||||||
"autoAcceptSubscriptionRequests": "Auto-accept subscription requests",
|
|
||||||
"autoAcceptSubscriptionRequestsSubtext": "If enabled, subscription requests will be automatically accepted if the user is in the contact list.",
|
|
||||||
"conversationsSection": "Conversation",
|
"conversationsSection": "Conversation",
|
||||||
"sendChatMarkers": "Send chat markers",
|
"sendChatMarkers": "Send chat markers",
|
||||||
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
|
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
|
||||||
@@ -249,7 +334,20 @@
|
|||||||
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
||||||
"urlEmpty": "URL cannot be empty",
|
"urlEmpty": "URL cannot be empty",
|
||||||
"urlInvalid": "Invalid URL",
|
"urlInvalid": "Invalid URL",
|
||||||
"redirectDialogTitle": "$serviceName Redirect"
|
"redirectDialogTitle": "$serviceName Redirect",
|
||||||
|
"stickersPrivacy": "Keep sticker list public",
|
||||||
|
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
|
||||||
|
},
|
||||||
|
"stickers": {
|
||||||
|
"title": "Stickers",
|
||||||
|
"stickerSection": "Sticker",
|
||||||
|
"displayStickers": "Display stickers in chat",
|
||||||
|
"autoDownload": "Automatically download stickers",
|
||||||
|
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
|
||||||
|
"stickerPacksSection": "Sticker packs",
|
||||||
|
"importStickerPack": "Import sticker pack",
|
||||||
|
"importSuccess": "Sticker pack successfully imported",
|
||||||
|
"importFailure": "Failed to import sticker pack"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,45 @@
|
|||||||
"messagesChannelDescription": "Empfangene Nachrichten",
|
"messagesChannelDescription": "Empfangene Nachrichten",
|
||||||
"warningChannelName": "Warnungen",
|
"warningChannelName": "Warnungen",
|
||||||
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
||||||
|
},
|
||||||
|
"titles": {
|
||||||
|
"error": "Fehler"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dateTime": {
|
||||||
|
"justNow": "Gerade",
|
||||||
|
"nMinutesAgo": "vor ${min}min",
|
||||||
|
"mondayAbbrev": "Mon",
|
||||||
|
"tuesdayAbbrev": "Die",
|
||||||
|
"wednessdayAbbrev": "Mit",
|
||||||
|
"thursdayAbbrev": "Don",
|
||||||
|
"fridayAbbrev": "Fre",
|
||||||
|
"saturdayAbbrev": "Sam",
|
||||||
|
"sundayAbbrev": "Son",
|
||||||
|
"january": "Januar",
|
||||||
|
"february": "Februar",
|
||||||
|
"march": "März",
|
||||||
|
"april": "April",
|
||||||
|
"may": "Mai",
|
||||||
|
"june": "Juni",
|
||||||
|
"july": "Juli",
|
||||||
|
"august": "August",
|
||||||
|
"september": "September",
|
||||||
|
"october": "Oktober",
|
||||||
|
"november": "November",
|
||||||
|
"december": "Dezember",
|
||||||
|
"today": "Heute",
|
||||||
|
"yesterday": "Gestern"
|
||||||
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"image": "Bild",
|
"image": "Bild",
|
||||||
"video": "Video",
|
"video": "Video",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
"file": "Datei",
|
"file": "Datei",
|
||||||
|
"sticker": "Sticker",
|
||||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
|
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||||
|
"you": "Du"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"omemo": {
|
"omemo": {
|
||||||
@@ -41,10 +71,19 @@
|
|||||||
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
||||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht"
|
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||||
|
|
||||||
|
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||||
|
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||||
|
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||||
|
"verificationNotInList": "OMEMO:2 Gerät unbekannt",
|
||||||
|
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
|
||||||
},
|
},
|
||||||
"connection": {
|
"connection": {
|
||||||
"connectionTimeout": "Verbindung zum Server nicht möglich"
|
"connectionTimeout": "Verbindung zum Server nicht möglich",
|
||||||
|
"saslAccountDisabled": "Dein Account ist deaktiviert",
|
||||||
|
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
|
||||||
|
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
|
||||||
},
|
},
|
||||||
"login": {
|
"login": {
|
||||||
"saslFailed": "Ungültige Logindaten",
|
"saslFailed": "Ungültige Logindaten",
|
||||||
@@ -64,11 +103,20 @@
|
|||||||
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
|
"failedToEncryptFile": "Die Datei konnte nicht verschlüsselt werden",
|
||||||
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
|
"failedToDecryptFile": "Die Datei konnte nicht entschlüsselt werden",
|
||||||
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
|
"fileNotEncrypted": "Der Chat ist verschlüsselt, aber die Datei wurde unverschlüsselt übertragen"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
|
||||||
|
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
||||||
|
"openFileGenericError": "Fehler beim Öffnen der Datei",
|
||||||
|
"messageErrorDialogTitle": "Fehler"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"warnings": {
|
"warnings": {
|
||||||
"message": {
|
"message": {
|
||||||
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
|
"integrityCheckFailed": "Konnte Integrität der Datei nicht überprüfen"
|
||||||
|
},
|
||||||
|
"conversation": {
|
||||||
|
"holdForLonger": "Button länger gedrückt halten, um eine Sprachnachricht aufzunehmen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
@@ -87,6 +135,7 @@
|
|||||||
"conversations": {
|
"conversations": {
|
||||||
"speeddialNewChat": "Neuer chat",
|
"speeddialNewChat": "Neuer chat",
|
||||||
"speeddialJoinGroupchat": "Gruppenchat beitreten",
|
"speeddialJoinGroupchat": "Gruppenchat beitreten",
|
||||||
|
"speeddialAddNoteToSelf": "Notiz an mich",
|
||||||
"overlaySettings": "Einstellungen",
|
"overlaySettings": "Einstellungen",
|
||||||
"noOpenChats": "Du hast keine offenen chats",
|
"noOpenChats": "Du hast keine offenen chats",
|
||||||
"startChat": "Einen chat anfangen",
|
"startChat": "Einen chat anfangen",
|
||||||
@@ -100,6 +149,7 @@
|
|||||||
"closeChat": "Chat schließen",
|
"closeChat": "Chat schließen",
|
||||||
"closeChatConfirmTitle": "Chat schließen",
|
"closeChatConfirmTitle": "Chat schließen",
|
||||||
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
|
"closeChatConfirmSubtext": "Bist Du dir sicher, dass du den Chat schließen möchtest?",
|
||||||
|
"blockShort": "Blockieren",
|
||||||
"blockUser": "Nutzer blockieren",
|
"blockUser": "Nutzer blockieren",
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"retract": "Nachricht löschen",
|
"retract": "Nachricht löschen",
|
||||||
@@ -107,7 +157,21 @@
|
|||||||
"forward": "Weiterleiten",
|
"forward": "Weiterleiten",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"quote": "Zitieren",
|
"quote": "Zitieren",
|
||||||
"copy": "Inhalt kopieren"
|
"copy": "Inhalt kopieren",
|
||||||
|
"addReaction": "Reaktion hinzufügen",
|
||||||
|
"showError": "Fehler anzeigen",
|
||||||
|
"showWarning": "Warnung anzeigen",
|
||||||
|
"addToContacts": "Zu Kontaken hinzufügen",
|
||||||
|
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
|
||||||
|
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
||||||
|
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||||
|
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||||
|
"stickerSettings": "Stickereinstellungen",
|
||||||
|
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
|
||||||
|
"messageHint": "Nachricht senden...",
|
||||||
|
"sendImages": "Bilder senden",
|
||||||
|
"sendFiles": "Dateien senden",
|
||||||
|
"takePhotos": "Bilder aufnehmen"
|
||||||
},
|
},
|
||||||
"addcontact": {
|
"addcontact": {
|
||||||
"title": "Neuen Kontakt hinzufügen",
|
"title": "Neuen Kontakt hinzufügen",
|
||||||
@@ -129,15 +193,16 @@
|
|||||||
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
|
"confirmBody": "Einer oder mehr Chats sind unverschlüsselt. Das bedeutet, dass die Dateien dem Server unverschlüsselt vorliegen. Dateien trotzdem senden?"
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
"self": {
|
"general": {
|
||||||
"devices": "Geräte"
|
"omemo": "Sicherheit",
|
||||||
|
"profile": "Profil",
|
||||||
|
"media": "Medien"
|
||||||
},
|
},
|
||||||
"conversation": {
|
"conversation": {
|
||||||
"muteChatTooltip": "Chat stummschalten",
|
"notifications": "Benachrichtigungen",
|
||||||
"unmuteChatTooltip": "Chat lautstellen",
|
"notificationsMuted": "Stumm",
|
||||||
"muteChat": "Stummschalten",
|
"notificationsEnabled": "Eingeschaltet",
|
||||||
"unmuteChat": "Lautstellen",
|
"sharedMedia": "Medien"
|
||||||
"devices": "Geräte"
|
|
||||||
},
|
},
|
||||||
"owndevices": {
|
"owndevices": {
|
||||||
"title": "Eigene Geräte",
|
"title": "Eigene Geräte",
|
||||||
@@ -153,10 +218,11 @@
|
|||||||
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
|
"recreateOwnDeviceConfirmBody": "Das wird die kryptographische Identität dieses Geräts neu erstellen. Wenn Kontakte die kryptographische Indentität verifiziert haben, dann müssen diese es erneut tun. Fortfahren?"
|
||||||
},
|
},
|
||||||
"devices": {
|
"devices": {
|
||||||
"title": "Devices",
|
"title": "Sicherheit",
|
||||||
"recreateSessions": "Rebuild sessions",
|
"recreateSessions": "Sessions zurücksetzen",
|
||||||
"recreateSessionsConfirmTitle": "Rebuild sessions?",
|
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
|
||||||
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors."
|
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen.",
|
||||||
|
"noSessions": "Es sind keine kryptographischen Sessions vorhanden, die für Ende-zu-Ende-Verschlüsselung verwendet werden."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"blocklist": {
|
"blocklist": {
|
||||||
@@ -168,6 +234,18 @@
|
|||||||
"unblockJidConfirmTitle": "${jid} entblocken?",
|
"unblockJidConfirmTitle": "${jid} entblocken?",
|
||||||
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
|
"unblockJidConfirmBody": "Bist du dir sicher, dass du ${jid} entblocken möchtest? Du wirst wieder Nachrichten von dieser Person erhalten können."
|
||||||
},
|
},
|
||||||
|
"cropbackground": {
|
||||||
|
"blur": "Hintergrund weichzeichnen",
|
||||||
|
"setAsBackground": "Als Hintergrundbild festlegen"
|
||||||
|
},
|
||||||
|
"stickerPack": {
|
||||||
|
"removeConfirmTitle": "Stickerpack entfernen",
|
||||||
|
"removeConfirmBody": "Bist Du Dir sicher, dass du das Stickerpack entfernen möchtest?",
|
||||||
|
"installConfirmTitle": "Stickerpack installieren",
|
||||||
|
"installConfirmBody": "Bist Du Dir sicher, dass Du das Stickerpack installieren möchtest?",
|
||||||
|
"restricted": "Dieses Stickerpack ist eingeschränkt. Das bedeutet, dass es im Chat angezeigt wird, jedoch nicht versendet werden kann.",
|
||||||
|
"fetchingFailure": "Konnte das Stickerpack nicht finden"
|
||||||
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Einstellungen",
|
"title": "Einstellungen",
|
||||||
@@ -177,12 +255,17 @@
|
|||||||
"signOutConfirmTitle": "Abmelden",
|
"signOutConfirmTitle": "Abmelden",
|
||||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||||
"miscellaneousSection": "Unterschiedlich",
|
"miscellaneousSection": "Unterschiedlich",
|
||||||
"debuggingSection": "Debugging"
|
"debuggingSection": "Debugging",
|
||||||
|
"general": "Generell"
|
||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"title": "Über",
|
"title": "Über",
|
||||||
"licensed": "Lizensiert unter GPL3",
|
"licensed": "Lizensiert unter GPL3",
|
||||||
"viewSourceCode": "Quellcode anschauen"
|
"version": "Version ${version}",
|
||||||
|
"viewSourceCode": "Quellcode anschauen",
|
||||||
|
"nMoreToGo": "Noch ${n}...",
|
||||||
|
"debugMenuShown": "Du bist jetzt ein Entwickler!",
|
||||||
|
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"title": "Aussehen",
|
"title": "Aussehen",
|
||||||
@@ -205,7 +288,10 @@
|
|||||||
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
"removeBackgroundImageConfirmBody": "Bist Du dir sicher, dass Du das Hintergrundbild entfernen möchtest?",
|
||||||
"newChatsSection": "Neue Chats",
|
"newChatsSection": "Neue Chats",
|
||||||
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
"newChatsMuteByDefault": "Neue Chats standardmäßig stummschalten",
|
||||||
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell"
|
"newChatsE2EE": "Ende-zu-Ende-Verschlüsselung standardmäßig aktivieren. WARNUNG: Experimentell",
|
||||||
|
"behaviourSection": "Verhalten",
|
||||||
|
"contactsIntegration": "Kontaktintegration",
|
||||||
|
"contactsIntegrationBody": "Wenn aktiviert, dann werden Kontakte aus dem Kontaktbuch verwendet, um Chatnamen und Profilbilder anzuzeigen. Dabei werden keine Daten an den Server gesendet."
|
||||||
},
|
},
|
||||||
"debugging": {
|
"debugging": {
|
||||||
"title": "Debuggingoptionen",
|
"title": "Debuggingoptionen",
|
||||||
@@ -224,6 +310,7 @@
|
|||||||
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
|
"automaticDownloadsText": "Moxxy läd Dateien automatisch herunter, wenn verbunden mit...",
|
||||||
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
||||||
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
||||||
|
"automaticDownloadAlways": "Immer",
|
||||||
"wifi": "Wifi",
|
"wifi": "Wifi",
|
||||||
"mobileData": "Mobile Daten"
|
"mobileData": "Mobile Daten"
|
||||||
},
|
},
|
||||||
@@ -234,8 +321,6 @@
|
|||||||
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
|
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
|
||||||
"profilePictureVisibility": "Öffentliches Profilbild",
|
"profilePictureVisibility": "Öffentliches Profilbild",
|
||||||
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
|
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
|
||||||
"autoAcceptSubscriptionRequests": "Subscriptionanfragen automatisch annehmen",
|
|
||||||
"autoAcceptSubscriptionRequestsSubtext": "Wenn aktiviert, dann werden Subscriptionanfragen automatisch angenommen, wenn die Person in deiner Kontaktliste ist",
|
|
||||||
"conversationsSection": "Unterhaltungen",
|
"conversationsSection": "Unterhaltungen",
|
||||||
"sendChatMarkers": "Chatmarker senden",
|
"sendChatMarkers": "Chatmarker senden",
|
||||||
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
|
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
|
||||||
@@ -249,7 +334,20 @@
|
|||||||
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
"cannotEnableRedirectSubtext": "Du must zuerst einen Proxydienst auswählen. Dazu berühre das Feld neben dem Schalter.",
|
||||||
"urlEmpty": "URL kann nicht leer sein",
|
"urlEmpty": "URL kann nicht leer sein",
|
||||||
"urlInvalid": "Ungültige URL",
|
"urlInvalid": "Ungültige URL",
|
||||||
"redirectDialogTitle": "${serviceName}weiterleitung"
|
"redirectDialogTitle": "${serviceName}weiterleitung",
|
||||||
|
"stickersPrivacy": "Stickerliste öffentlich halten",
|
||||||
|
"stickersPrivacySubtext": "Wenn eingeschaltet, dann kann jeder die Liste Deiner installierten Stickerpacks sehen."
|
||||||
|
},
|
||||||
|
"stickers": {
|
||||||
|
"title": "Stickers",
|
||||||
|
"stickerSection": "Sticker",
|
||||||
|
"displayStickers": "Sticker im Chat anzeigen",
|
||||||
|
"autoDownload": "Sticker automatisch herunterladen",
|
||||||
|
"autoDownloadBody": "Wenn aktiviert, dann werden Sticker automatisch heruntergeladen, wenn der Sender in der Kontaktliste ist.",
|
||||||
|
"stickerPacksSection": "Stickerpacks",
|
||||||
|
"importStickerPack": "Stickerpack importieren",
|
||||||
|
"importSuccess": "Stickerpack erfolgreich importiert",
|
||||||
|
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
assets/images/empty.png
Normal file
BIN
assets/images/empty.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
assets/repo/kofi.png
Normal file
BIN
assets/repo/kofi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
43
docs/stickerpacks.md
Normal file
43
docs/stickerpacks.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# Sticker Packs
|
||||||
|
|
||||||
|
Moxxy supports sending and receiving sticker packs using XEP-0449 version 0.1.1. Sticker
|
||||||
|
packs can also be imported using a Moxxy specific format.
|
||||||
|
|
||||||
|
## File Format
|
||||||
|
|
||||||
|
A Moxxy sticker pack is a flat tar archive that contains the following files:
|
||||||
|
|
||||||
|
- `urn.xmpp.stickers.0.xml`
|
||||||
|
- The sticker files
|
||||||
|
|
||||||
|
### `urn.xmpp.stickers.0.xml`
|
||||||
|
|
||||||
|
This file is the sticker pack's metadata file. It describes the sticker pack the same
|
||||||
|
way as the examples in XEP-0449 do. There are, however, some differences:
|
||||||
|
|
||||||
|
- Each `<file />` element must contain a `<name />` element that matches with a file in the tar archive
|
||||||
|
- Each sticker MUST contain at least one HTTP(s) source
|
||||||
|
- The `<hash />` of the `<pack />` element is ignored as Moxxy computes it itself, so it can be omitted
|
||||||
|
|
||||||
|
An example for the metadata file is the following:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<pack xmlns='urn:xmpp:stickers:0'>
|
||||||
|
<name>Example</name>
|
||||||
|
<summary>Example sticker pack.</summary>
|
||||||
|
<item>
|
||||||
|
<file xmlns='urn:xmpp:file:metadata:0'>
|
||||||
|
<media-type>image/png</media-type>
|
||||||
|
<desc>:some-sticker:</desc>
|
||||||
|
<name>suprise.png</name>
|
||||||
|
<size>531910</size>
|
||||||
|
<dimensions>1030x1030</dimensions>
|
||||||
|
<hash xmlns='urn:xmpp:hashes:2' algo='sha-256'>1Ha4okUGNRAA04KibwWUmklqqBqdhg7+20dfsr/wLik=</hash>
|
||||||
|
</file>
|
||||||
|
<sources xmlns='urn:xmpp:sfs:0'>
|
||||||
|
<url-data xmlns='http://jabber.org/protocol/url-data' target='...' />
|
||||||
|
</sources>
|
||||||
|
</item>
|
||||||
|
<!-- ... -->
|
||||||
|
</pack>
|
||||||
|
```
|
||||||
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
|
||||||
|
* Maybe fix a connection race condition
|
||||||
|
* Allow sharing media with the app when it was closed
|
||||||
|
* Make quotes prettier
|
||||||
|
* Make the bottom part of the conversation page prettier
|
||||||
|
* Fix roster fetching
|
||||||
|
* Fix OMEMO key generation
|
||||||
@@ -10,12 +10,14 @@ Currently supported features include:
|
|||||||
<li>Typing indicators and message markers</li>
|
<li>Typing indicators and message markers</li>
|
||||||
<li>Chat backgrounds</li>
|
<li>Chat backgrounds</li>
|
||||||
<li>Runs in the background without Push Notifications</li>
|
<li>Runs in the background without Push Notifications</li>
|
||||||
|
<li>OMEMO (Currently not compatible with most apps)</li>
|
||||||
|
<li>Stickers</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
For the best experience, I recommend a server that:
|
For the best experience, I recommend a server that:
|
||||||
<ul>
|
<ul>
|
||||||
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
||||||
<li>Supports SCRAM-SHA-1 or SCRAM-SHA-256</li>
|
<li>Supports SCRAM-SHA-1, SCRAM-SHA-256 or SCRAM-SHA-512</li>
|
||||||
<li>Supports HTTP File Upload</li>
|
<li>Supports HTTP File Upload</li>
|
||||||
<li>Supports Stream Management</li>
|
<li>Supports Stream Management</li>
|
||||||
<li>Supports Client State Indication</li>
|
<li>Supports Client State Indication</li>
|
||||||
|
|||||||
12
flake.lock
generated
12
flake.lock
generated
@@ -17,16 +17,16 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1669165918,
|
"lastModified": 1676076353,
|
||||||
"narHash": "sha256-hIVruk2+0wmw/Kfzy11rG3q7ev3VTi/IKVODeHcVjFo=",
|
"narHash": "sha256-mdUtE8Tp40cZETwcq5tCwwLqkJVV1ULJQ5GKRtbshag=",
|
||||||
"owner": "NixOS",
|
"owner": "AtaraxiaSjel",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "3b400a525d92e4085e46141ff48cbf89fd89739e",
|
"rev": "5deb99bdccbbb97e7562dee4ba8a3ee3021688e6",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"owner": "NixOS",
|
"owner": "AtaraxiaSjel",
|
||||||
"ref": "nixpkgs-unstable",
|
"ref": "update/flutter",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
description = "Moxxy v2";
|
description = "Moxxy v2";
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
nixpkgs.url = "github:AtaraxiaSjel/nixpkgs/update/flutter";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
useGoogleAPIs = false;
|
useGoogleAPIs = false;
|
||||||
useGoogleTVAddOns = false;
|
useGoogleTVAddOns = false;
|
||||||
};
|
};
|
||||||
pinnedJDK = pkgs.jdk;
|
pinnedJDK = pkgs.jdk17;
|
||||||
|
|
||||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||||
requests pyyaml # For the build scripts
|
requests pyyaml # For the build scripts
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
|
||||||
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
|
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
|
||||||
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
|
|
||||||
class StubConnectivityService extends ConnectivityService {
|
|
||||||
StubConnectivityService() : super();
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConnectivityResult get currentState => ConnectivityResult.wifi;
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
Logger.root.level = Level.ALL;
|
|
||||||
Logger.root.onRecord.listen((record) {
|
|
||||||
print('${record.level.name}: ${record.time}: ${record.message}');
|
|
||||||
});
|
|
||||||
final log = Logger('FailureReconnectionTest');
|
|
||||||
GetIt.I.registerSingleton<ConnectivityService>(StubConnectivityService());
|
|
||||||
|
|
||||||
test('Failing an awaited connection with MoxxyReconnectionPolicy', () async {
|
|
||||||
var errors = 0;
|
|
||||||
final connection = XmppConnection(
|
|
||||||
MoxxyReconnectionPolicy(maxBackoffTime: 1),
|
|
||||||
TCPSocketWrapper(false),
|
|
||||||
);
|
|
||||||
connection.registerFeatureNegotiators([
|
|
||||||
StartTlsNegotiator(),
|
|
||||||
]);
|
|
||||||
connection.registerManagers([
|
|
||||||
DiscoManager(),
|
|
||||||
RosterManager(),
|
|
||||||
PingManager(),
|
|
||||||
MessageManager(),
|
|
||||||
PresenceManager('http://moxxmpp.example'),
|
|
||||||
]);
|
|
||||||
connection.asBroadcastStream().listen((event) {
|
|
||||||
if (event is ConnectionStateChangedEvent) {
|
|
||||||
if (event.state == XmppConnectionState.error) {
|
|
||||||
errors++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.setConnectionSettings(
|
|
||||||
ConnectionSettings(
|
|
||||||
jid: JID.fromString('testuser@no-sasl.badxmpp.eu'),
|
|
||||||
password: 'abc123',
|
|
||||||
useDirectTLS: true,
|
|
||||||
allowPlainAuth: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await connection.connectAwaitable();
|
|
||||||
log.info('Connection failed as expected');
|
|
||||||
expect(result.success, false);
|
|
||||||
expect(errors, 1);
|
|
||||||
|
|
||||||
log.info('Waiting 20 seconds for unexpected reconnections');
|
|
||||||
await Future.delayed(const Duration(seconds: 20));
|
|
||||||
expect(errors, 1);
|
|
||||||
}, timeout: Timeout.factor(2));
|
|
||||||
}
|
|
||||||
@@ -36,14 +36,8 @@ files:
|
|||||||
roster:
|
roster:
|
||||||
type: List<RosterItem>?
|
type: List<RosterItem>?
|
||||||
deserialise: true
|
deserialise: true
|
||||||
# Returned by [GetMessagesForJidCommand]
|
stickers:
|
||||||
- name: MessagesResultEvent
|
type: List<StickerPack>?
|
||||||
extends: BackgroundEvent
|
|
||||||
implements:
|
|
||||||
- JsonImplementation
|
|
||||||
attributes:
|
|
||||||
messages:
|
|
||||||
type: List<Message>
|
|
||||||
deserialise: true
|
deserialise: true
|
||||||
# Triggered if a conversation has been added.
|
# Triggered if a conversation has been added.
|
||||||
# Also returned by [AddConversationCommand]
|
# Also returned by [AddConversationCommand]
|
||||||
@@ -71,7 +65,7 @@ files:
|
|||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
# Send by the service if a message has been received or returned by # [SendMessageCommand].
|
# Send by the service if a message has been received or returned by [SendMessageCommand].
|
||||||
- name: MessageAddedEvent
|
- name: MessageAddedEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
@@ -103,6 +97,13 @@ files:
|
|||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
|
# Triggered in response to a [GetBlocklistCommand]
|
||||||
|
- name: GetBlocklistResultEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
entries: List<String>
|
||||||
# Triggered by DownloadService or UploadService.
|
# Triggered by DownloadService or UploadService.
|
||||||
- name: ProgressEvent
|
- name: ProgressEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
@@ -163,6 +164,7 @@ files:
|
|||||||
supportsCsi: bool
|
supportsCsi: bool
|
||||||
supportsUserBlocking: bool
|
supportsUserBlocking: bool
|
||||||
supportsHttpFileUpload: bool
|
supportsHttpFileUpload: bool
|
||||||
|
supportsCarbons: bool
|
||||||
# Returned by [SignOutCommand]
|
# Returned by [SignOutCommand]
|
||||||
- name: SignedOutEvent
|
- name: SignedOutEvent
|
||||||
extends: BackgroundEvent
|
extends: BackgroundEvent
|
||||||
@@ -207,6 +209,71 @@ files:
|
|||||||
conversationJid: String
|
conversationJid: String
|
||||||
title: String
|
title: String
|
||||||
avatarUrl: String
|
avatarUrl: String
|
||||||
|
- name: StickerPackImportSuccessEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
- name: StickerPackImportFailureEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
- name: FetchStickerPackSuccessResult
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
- name: FetchStickerPackFailureResult
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
- name: StickerPackInstallSuccessEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
- name: StickerPackInstallFailureEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
- name: StickerPackAddedEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPack:
|
||||||
|
type: StickerPack
|
||||||
|
deserialise: true
|
||||||
|
# Returned by [GetPagedMessagesCommand]
|
||||||
|
- name: PagedMessagesResultEvent
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messages:
|
||||||
|
type: List<Message>
|
||||||
|
deserialise: true
|
||||||
|
# Returned by [GetReactionsForMessageCommand]
|
||||||
|
- name: ReactionsForMessageResult
|
||||||
|
extends: BackgroundEvent
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
reactions:
|
||||||
|
type: List<ReactionGroup>
|
||||||
|
deserialise: true
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
builder_name: "Event"
|
builder_name: "Event"
|
||||||
builder_baseclass: "BackgroundEvent"
|
builder_baseclass: "BackgroundEvent"
|
||||||
@@ -236,12 +303,7 @@ files:
|
|||||||
lastMessageBody: String
|
lastMessageBody: String
|
||||||
avatarUrl: String
|
avatarUrl: String
|
||||||
jid: String
|
jid: String
|
||||||
- name: GetMessagesForJidCommand
|
conversationType: String
|
||||||
extends: BackgroundCommand
|
|
||||||
implements:
|
|
||||||
- JsonImplementation
|
|
||||||
attributes:
|
|
||||||
jid: String
|
|
||||||
- name: SetOpenConversationCommand
|
- name: SetOpenConversationCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@@ -259,6 +321,9 @@ files:
|
|||||||
quotedMessage:
|
quotedMessage:
|
||||||
type: Message?
|
type: Message?
|
||||||
deserialise: true
|
deserialise: true
|
||||||
|
editSid: String?
|
||||||
|
editId: int?
|
||||||
|
currentConversationJid: String?
|
||||||
- name: SendFilesCommand
|
- name: SendFilesCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@@ -302,6 +367,12 @@ files:
|
|||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
jid: String
|
jid: String
|
||||||
|
- name: RemoveContactCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
jid: String
|
||||||
- name: RequestDownloadCommand
|
- name: RequestDownloadCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@@ -407,7 +478,7 @@ files:
|
|||||||
implements:
|
implements:
|
||||||
- JsonImplementation
|
- JsonImplementation
|
||||||
attributes:
|
attributes:
|
||||||
conversationId: int
|
conversationJid: String
|
||||||
- name: MarkMessageAsReadCommand
|
- name: MarkMessageAsReadCommand
|
||||||
extends: BackgroundCommand
|
extends: BackgroundCommand
|
||||||
implements:
|
implements:
|
||||||
@@ -416,6 +487,95 @@ files:
|
|||||||
conversationJid: String
|
conversationJid: String
|
||||||
sid: String
|
sid: String
|
||||||
newUnreadCounter: int
|
newUnreadCounter: int
|
||||||
|
- name: AddReactionToMessageCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messageId: int
|
||||||
|
conversationJid: String
|
||||||
|
emoji: String
|
||||||
|
- name: RemoveReactionFromMessageCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messageId: int
|
||||||
|
conversationJid: String
|
||||||
|
emoji: String
|
||||||
|
- name: MarkOmemoDeviceAsVerifiedCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
deviceId: int
|
||||||
|
jid: String
|
||||||
|
- name: ImportStickerPackCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
path: String
|
||||||
|
- name: RemoveStickerPackCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
stickerPackId: String
|
||||||
|
- name: SendStickerCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
sticker:
|
||||||
|
type: Sticker
|
||||||
|
deserialise: true
|
||||||
|
recipient: String
|
||||||
|
quotes:
|
||||||
|
type: Message?
|
||||||
|
deserialise: true
|
||||||
|
- 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:
|
||||||
|
- name: GetPagedMessagesCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
conversationJid: String
|
||||||
|
olderThan: bool
|
||||||
|
timestamp: int?
|
||||||
|
- name: GetPagedSharedMediaCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
conversationJid: String
|
||||||
|
olderThan: bool
|
||||||
|
timestamp: int?
|
||||||
|
- name: GetReactionsForMessageCommand
|
||||||
|
extends: BackgroundCommand
|
||||||
|
implements:
|
||||||
|
- JsonImplementation
|
||||||
|
attributes:
|
||||||
|
messageId: int
|
||||||
generate_builder: true
|
generate_builder: true
|
||||||
# get${builder_Name}FromJson
|
# get${builder_Name}FromJson
|
||||||
builder_name: "Command"
|
builder_name: "Command"
|
||||||
|
|||||||
199
lib/main.dart
199
lib/main.dart
@@ -9,6 +9,7 @@ import 'package:moxplatform/moxplatform.dart';
|
|||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
|
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||||
@@ -25,8 +26,10 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
|||||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
||||||
|
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||||
import 'package:moxxyv2/ui/constants.dart';
|
import 'package:moxxyv2/ui/constants.dart';
|
||||||
|
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||||
import 'package:moxxyv2/ui/events.dart';
|
import 'package:moxxyv2/ui/events.dart';
|
||||||
/*
|
/*
|
||||||
import "package:moxxyv2/ui/pages/register/register.dart";
|
import "package:moxxyv2/ui/pages/register/register.dart";
|
||||||
@@ -54,20 +57,25 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
|
|||||||
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
||||||
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
||||||
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
||||||
import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
//import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||||
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||||
|
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||||
import 'package:moxxyv2/ui/service/data.dart';
|
import 'package:moxxyv2/ui/service/data.dart';
|
||||||
import 'package:moxxyv2/ui/service/progress.dart';
|
import 'package:moxxyv2/ui/service/progress.dart';
|
||||||
|
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||||
import 'package:moxxyv2/ui/theme.dart';
|
import 'package:moxxyv2/ui/theme.dart';
|
||||||
import 'package:page_transition/page_transition.dart';
|
import 'package:page_transition/page_transition.dart';
|
||||||
import 'package:share_handler/share_handler.dart';
|
|
||||||
|
|
||||||
void setupLogging() {
|
void setupLogging() {
|
||||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||||
Logger.root.onRecord.listen((record) {
|
Logger.root.onRecord.listen((record) {
|
||||||
// ignore: avoid_print
|
// ignore: avoid_print
|
||||||
print('[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}');
|
print(
|
||||||
|
'[${record.level.name}] (${record.loggerName}) ${record.time}: ${record.message}',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
GetIt.I.registerSingleton<Logger>(Logger('MoxxyMain'));
|
GetIt.I.registerSingleton<Logger>(Logger('MoxxyMain'));
|
||||||
}
|
}
|
||||||
@@ -75,17 +83,19 @@ void setupLogging() {
|
|||||||
Future<void> setupUIServices() async {
|
Future<void> setupUIServices() async {
|
||||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||||
|
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||||
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: navKey));
|
GetIt.I
|
||||||
|
.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: navKey));
|
||||||
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
||||||
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
||||||
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
||||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc()); GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||||
|
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||||
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
|
||||||
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
||||||
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
||||||
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
||||||
@@ -93,14 +103,11 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
|||||||
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
|
GetIt.I.registerSingleton<ServerInfoBloc>(ServerInfoBloc());
|
||||||
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
|
GetIt.I.registerSingleton<DevicesBloc>(DevicesBloc());
|
||||||
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
|
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
|
||||||
|
GetIt.I.registerSingleton<StickersBloc>(StickersBloc());
|
||||||
|
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
|
|
||||||
// Padding(padding: ..., child: Column(children: [ ... ]))
|
|
||||||
// TODO(Unknown): Theme the switches
|
|
||||||
void main() async {
|
void main() async {
|
||||||
GetIt.I.registerSingleton<Completer<void>>(Completer());
|
|
||||||
|
|
||||||
setupLogging();
|
setupLogging();
|
||||||
await setupUIServices();
|
await setupUIServices();
|
||||||
|
|
||||||
@@ -111,6 +118,8 @@ void main() async {
|
|||||||
|
|
||||||
await initializeServiceIfNeeded();
|
await initializeServiceIfNeeded();
|
||||||
|
|
||||||
|
imageCache.maximumSizeBytes = 500 * 1024 * 1024;
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiBlocProvider(
|
MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -141,9 +150,6 @@ void main() async {
|
|||||||
BlocProvider<AddContactBloc>(
|
BlocProvider<AddContactBloc>(
|
||||||
create: (_) => GetIt.I.get<AddContactBloc>(),
|
create: (_) => GetIt.I.get<AddContactBloc>(),
|
||||||
),
|
),
|
||||||
BlocProvider<SharedMediaBloc>(
|
|
||||||
create: (_) => GetIt.I.get<SharedMediaBloc>(),
|
|
||||||
),
|
|
||||||
BlocProvider<CropBloc>(
|
BlocProvider<CropBloc>(
|
||||||
create: (_) => GetIt.I.get<CropBloc>(),
|
create: (_) => GetIt.I.get<CropBloc>(),
|
||||||
),
|
),
|
||||||
@@ -165,6 +171,12 @@ void main() async {
|
|||||||
BlocProvider<OwnDevicesBloc>(
|
BlocProvider<OwnDevicesBloc>(
|
||||||
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
|
create: (_) => GetIt.I.get<OwnDevicesBloc>(),
|
||||||
),
|
),
|
||||||
|
BlocProvider<StickersBloc>(
|
||||||
|
create: (_) => GetIt.I.get<StickersBloc>(),
|
||||||
|
),
|
||||||
|
BlocProvider<StickerPackBloc>(
|
||||||
|
create: (_) => GetIt.I.get<StickerPackBloc>(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: TranslationProvider(
|
child: TranslationProvider(
|
||||||
child: MyApp(navKey),
|
child: MyApp(navKey),
|
||||||
@@ -174,8 +186,7 @@ void main() async {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class MyApp extends StatefulWidget {
|
class MyApp extends StatefulWidget {
|
||||||
|
const MyApp(this.navigationKey, {super.key});
|
||||||
const MyApp(this.navigationKey, { super.key });
|
|
||||||
final GlobalKey<NavigatorState> navigationKey;
|
final GlobalKey<NavigatorState> navigationKey;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -188,46 +199,20 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Async "version" of initState()
|
||||||
|
Future<void> _initState() async {
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
|
// Set up receiving share intents
|
||||||
|
await GetIt.I.get<UISharingService>().initialize();
|
||||||
|
|
||||||
// Lift the UI block
|
// Lift the UI block
|
||||||
GetIt.I.get<Completer<void>>().complete();
|
await GetIt.I
|
||||||
|
.get<SynchronizedQueue<Map<String, dynamic>?>>()
|
||||||
_setupSharingHandler();
|
.removeQueueLock();
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleSharedMedia(SharedMedia media) async {
|
|
||||||
final attachments = media.attachments ?? [];
|
|
||||||
GetIt.I.get<ShareSelectionBloc>().add(
|
|
||||||
ShareSelectionRequestedEvent(
|
|
||||||
attachments.map((a) => a!.path).toList(),
|
|
||||||
media.content,
|
|
||||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _setupSharingHandler() async {
|
|
||||||
final handler = ShareHandlerPlatform.instance;
|
|
||||||
final media = await handler.getInitialSharedMedia();
|
|
||||||
|
|
||||||
// Shared while the app was closed
|
|
||||||
if (media != null) {
|
|
||||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
|
||||||
await _handleSharedMedia(media);
|
|
||||||
}
|
|
||||||
|
|
||||||
await handler.resetInitialSharedMedia();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared while the app is stil running
|
|
||||||
handler.sharedMediaStream.listen((SharedMedia media) async {
|
|
||||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
|
||||||
await _handleSharedMedia(media);
|
|
||||||
}
|
|
||||||
|
|
||||||
await handler.resetInitialSharedMedia();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -246,17 +231,19 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
sender.sendData(
|
sender.sendData(
|
||||||
SetCSIStateCommand(active: false),
|
SetCSIStateCommand(active: false),
|
||||||
);
|
);
|
||||||
GetIt.I.get<ConversationBloc>().add(AppStateChanged(false));
|
BidirectionalConversationController.currentController
|
||||||
break;
|
?.handleAppStateChange(false);
|
||||||
|
break;
|
||||||
case AppLifecycleState.resumed:
|
case AppLifecycleState.resumed:
|
||||||
sender.sendData(
|
sender.sendData(
|
||||||
SetCSIStateCommand(active: true),
|
SetCSIStateCommand(active: true),
|
||||||
);
|
);
|
||||||
GetIt.I.get<ConversationBloc>().add(AppStateChanged(true));
|
BidirectionalConversationController.currentController
|
||||||
break;
|
?.handleAppStateChange(true);
|
||||||
|
break;
|
||||||
case AppLifecycleState.detached:
|
case AppLifecycleState.detached:
|
||||||
case AppLifecycleState.inactive:
|
case AppLifecycleState.inactive:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,34 +259,72 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
|||||||
navigatorKey: widget.navigationKey,
|
navigatorKey: widget.navigationKey,
|
||||||
onGenerateRoute: (settings) {
|
onGenerateRoute: (settings) {
|
||||||
switch (settings.name) {
|
switch (settings.name) {
|
||||||
case introRoute: return Intro.route;
|
case introRoute:
|
||||||
case loginRoute: return Login.route;
|
return Intro.route;
|
||||||
case conversationsRoute: return ConversationsPage.route;
|
case loginRoute:
|
||||||
case newConversationRoute: return NewConversationPage.route;
|
return Login.route;
|
||||||
case conversationRoute: return PageTransition<dynamic>(
|
case conversationsRoute:
|
||||||
type: PageTransitionType.rightToLeft,
|
return ConversationsPage.route;
|
||||||
settings: settings,
|
case newConversationRoute:
|
||||||
child: const ConversationPage(),
|
return NewConversationPage.route;
|
||||||
);
|
case conversationRoute:
|
||||||
case sharedMediaRoute: return SharedMediaPage.route;
|
return PageTransition<dynamic>(
|
||||||
case blocklistRoute: return BlocklistPage.route;
|
type: PageTransitionType.rightToLeft,
|
||||||
case profileRoute: return ProfilePage.route;
|
settings: settings,
|
||||||
case settingsRoute: return SettingsPage.route;
|
child: ConversationPage(
|
||||||
case aboutRoute: return SettingsAboutPage.route;
|
conversationJid: settings.arguments! as String,
|
||||||
case licensesRoute: return SettingsLicensesPage.route;
|
),
|
||||||
case networkRoute: return NetworkPage.route;
|
);
|
||||||
case privacyRoute: return PrivacyPage.route;
|
// case sharedMediaRoute:
|
||||||
case debuggingRoute: return DebuggingPage.route;
|
// return SharedMediaPage.getRoute(
|
||||||
case addContactRoute: return AddContactPage.route;
|
// settings.arguments! as SharedMediaPageArguments,
|
||||||
case cropRoute: return CropPage.route;
|
// );
|
||||||
case sendFilesRoute: return SendFilesPage.route;
|
case blocklistRoute:
|
||||||
case backgroundCroppingRoute: return CropBackgroundPage.route;
|
return BlocklistPage.route;
|
||||||
case shareSelectionRoute: return ShareSelectionPage.route;
|
case profileRoute:
|
||||||
case serverInfoRoute: return ServerInfoPage.route;
|
return ProfilePage.getRoute(
|
||||||
case conversationSettingsRoute: return ConversationSettingsPage.route;
|
settings.arguments! as ProfileArguments,
|
||||||
case devicesRoute: return DevicesPage.route;
|
);
|
||||||
case ownDevicesRoute: return OwnDevicesPage.route;
|
case settingsRoute:
|
||||||
case appearanceRoute: return AppearanceSettingsPage.route;
|
return SettingsPage.route;
|
||||||
|
case aboutRoute:
|
||||||
|
return SettingsAboutPage.route;
|
||||||
|
case licensesRoute:
|
||||||
|
return SettingsLicensesPage.route;
|
||||||
|
case networkRoute:
|
||||||
|
return NetworkPage.route;
|
||||||
|
case privacyRoute:
|
||||||
|
return PrivacyPage.route;
|
||||||
|
case debuggingRoute:
|
||||||
|
return DebuggingPage.route;
|
||||||
|
case addContactRoute:
|
||||||
|
return AddContactPage.route;
|
||||||
|
case cropRoute:
|
||||||
|
return CropPage.route;
|
||||||
|
case sendFilesRoute:
|
||||||
|
return SendFilesPage.route;
|
||||||
|
case backgroundCroppingRoute:
|
||||||
|
return CropBackgroundPage.route;
|
||||||
|
case shareSelectionRoute:
|
||||||
|
return ShareSelectionPage.route;
|
||||||
|
case serverInfoRoute:
|
||||||
|
return ServerInfoPage.route;
|
||||||
|
case conversationSettingsRoute:
|
||||||
|
return ConversationSettingsPage.route;
|
||||||
|
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;
|
return null;
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ import 'dart:io';
|
|||||||
import 'package:cryptography/cryptography.dart';
|
import 'package:cryptography/cryptography.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
import 'package:image_size_getter/image_size_getter.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/avatar.dart';
|
import 'package:moxxyv2/shared/avatar.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
|
||||||
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
||||||
/// avatar data. Returns the cleaned version.
|
/// avatar data. Returns the cleaned version.
|
||||||
@@ -26,56 +25,60 @@ String _cleanBase64String(String original) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AvatarData {
|
||||||
|
const _AvatarData(this.data, this.id);
|
||||||
|
final List<int> data;
|
||||||
|
final String id;
|
||||||
|
}
|
||||||
|
|
||||||
class AvatarService {
|
class AvatarService {
|
||||||
|
final Logger _log = Logger('AvatarService');
|
||||||
|
|
||||||
AvatarService() : _log = Logger('AvatarService');
|
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
||||||
final Logger _log;
|
await updateAvatarForJid(
|
||||||
|
event.jid,
|
||||||
|
event.hash,
|
||||||
|
base64Decode(_cleanBase64String(event.base64)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
UserAvatarManager _getUserAvatarManager() => GetIt.I.get<XmppConnection>().getManagerById<UserAvatarManager>(userAvatarManager)!;
|
Future<void> updateAvatarForJid(
|
||||||
|
String jid,
|
||||||
DiscoManager _getDiscoManager() => GetIt.I.get<XmppConnection>().getManagerById<DiscoManager>(discoManager)!;
|
String hash,
|
||||||
|
List<int> data,
|
||||||
Future<void> updateAvatarForJid(String jid, String hash, String base64) async {
|
) async {
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final rs = GetIt.I.get<RosterService>();
|
final rs = GetIt.I.get<RosterService>();
|
||||||
final originalConversation = await cs.getConversationByJid(jid);
|
final originalConversation = await cs.getConversationByJid(jid);
|
||||||
var saved = false;
|
|
||||||
|
|
||||||
// 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);
|
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||||
if (originalRoster != null) {
|
|
||||||
var avatarPath = '';
|
if (originalConversation == null && originalRoster == null) return;
|
||||||
if (saved) {
|
|
||||||
avatarPath = await getAvatarPath(jid, hash);
|
final avatarPath = await saveAvatarInCache(
|
||||||
} else {
|
data,
|
||||||
avatarPath = await saveAvatarInCache(
|
hash,
|
||||||
base64Data,
|
jid,
|
||||||
hash,
|
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
||||||
jid,
|
);
|
||||||
originalRoster.avatarUrl,
|
|
||||||
|
if (originalConversation != null) {
|
||||||
|
final conversation = await cs.createOrUpdateConversation(
|
||||||
|
jid,
|
||||||
|
update: (c) async {
|
||||||
|
return cs.updateConversation(
|
||||||
|
jid,
|
||||||
|
avatarUrl: avatarPath,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (conversation != null) {
|
||||||
|
sendEvent(
|
||||||
|
ConversationUpdatedEvent(conversation: conversation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (originalRoster != null) {
|
||||||
final roster = await rs.updateRosterItem(
|
final roster = await rs.updateRosterItem(
|
||||||
originalRoster.id,
|
originalRoster.id,
|
||||||
avatarUrl: avatarPath,
|
avatarUrl: avatarPath,
|
||||||
@@ -86,65 +89,78 @@ 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.fromString(jid));
|
||||||
|
if (idResult.isType<AvatarError>()) {
|
||||||
|
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final id = idResult.get<String>();
|
||||||
|
if (id == oldHash) return null;
|
||||||
|
|
||||||
|
final avatarResult = await am.getUserAvatar(jid);
|
||||||
|
if (avatarResult.isType<AvatarError>()) {
|
||||||
|
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final avatar = avatarResult.get<UserAvatar>();
|
||||||
|
|
||||||
|
return _AvatarData(
|
||||||
|
base64Decode(_cleanBase64String(avatar.base64)),
|
||||||
|
avatar.hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
|
||||||
|
// Query the vCard
|
||||||
|
final vm = GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<VCardManager>(vcardManager)!;
|
||||||
|
final vcardResult = await vm.requestVCard(jid);
|
||||||
|
if (vcardResult.isType<VCardError>()) return null;
|
||||||
|
|
||||||
|
final binval = vcardResult.get<VCard>().photo?.binval;
|
||||||
|
if (binval == null) return null;
|
||||||
|
|
||||||
|
final data = base64Decode(_cleanBase64String(binval));
|
||||||
|
final rawHash = await Sha1().hash(data);
|
||||||
|
final hash = HEX.encode(rawHash.bytes);
|
||||||
|
|
||||||
|
vm.setLastHash(jid, hash);
|
||||||
|
|
||||||
|
return _AvatarData(
|
||||||
|
data,
|
||||||
|
hash,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
||||||
final response = await _getDiscoManager().discoItemsQuery(jid);
|
_AvatarData? data;
|
||||||
final items = response.isType<DiscoError>() ?
|
data ??= await _handleUserAvatar(jid, oldHash);
|
||||||
<DiscoItem>[] :
|
data ??= await _handleVcardAvatar(jid, oldHash);
|
||||||
response.get<List<DiscoItem>>();
|
|
||||||
final itemNodes = items.map((i) => i.node);
|
|
||||||
|
|
||||||
_log.finest('Disco items for $jid:');
|
if (data != null) {
|
||||||
for (final item in itemNodes) {
|
await updateAvatarForJid(jid, data.id, data.data);
|
||||||
_log.finest('- $item');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
Future<bool> subscribeJid(String jid) async {
|
||||||
return _getUserAvatarManager().subscribe(jid);
|
return (await GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||||
|
.subscribe(jid))
|
||||||
|
.isType<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> unsubscribeJid(String jid) async {
|
Future<bool> unsubscribeJid(String jid) async {
|
||||||
return _getUserAvatarManager().unsubscribe(jid);
|
return (await GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||||
|
.unsubscribe(jid))
|
||||||
|
.isType<bool>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Publishes the data at [path] as an avatar with PubSub ID
|
/// Publishes the data at [path] as an avatar with PubSub ID
|
||||||
@@ -158,59 +174,86 @@ class AvatarService {
|
|||||||
final public = prefs.isAvatarPublic;
|
final public = prefs.isAvatarPublic;
|
||||||
|
|
||||||
// Read the image metadata
|
// Read the image metadata
|
||||||
final imageSize = ImageSizeGetter.getSize(MemoryInput(bytes));
|
final imageSize = (await getImageSizeFromData(bytes))!;
|
||||||
|
|
||||||
// Publish data and metadata
|
// Publish data and metadata
|
||||||
final manager = _getUserAvatarManager();
|
final am = GetIt.I
|
||||||
await manager.publishUserAvatar(
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
|
|
||||||
|
_log.finest('Publishing avatar...');
|
||||||
|
final dataResult = await am.publishUserAvatar(
|
||||||
base64,
|
base64,
|
||||||
hash,
|
hash,
|
||||||
public,
|
public,
|
||||||
);
|
);
|
||||||
await manager.publishUserAvatarMetadata(
|
if (dataResult.isType<AvatarError>()) {
|
||||||
|
_log.finest('Avatar data publishing failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(Unknown): Make sure that the image is not too large.
|
||||||
|
final metadataResult = await am.publishUserAvatarMetadata(
|
||||||
UserAvatarMetadata(
|
UserAvatarMetadata(
|
||||||
hash,
|
hash,
|
||||||
bytes.length,
|
bytes.length,
|
||||||
imageSize.width,
|
imageSize.width.toInt(),
|
||||||
imageSize.height,
|
imageSize.height.toInt(),
|
||||||
// TODO(PapaTutuWawa): Maybe do a check here
|
// TODO(PapaTutuWawa): Maybe do a check here
|
||||||
'image/png',
|
'image/png',
|
||||||
),
|
),
|
||||||
public,
|
public,
|
||||||
);
|
);
|
||||||
|
if (metadataResult.isType<AvatarError>()) {
|
||||||
|
_log.finest('Avatar metadata publishing failed');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.finest('Avatar publishing done');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> requestOwnAvatar() async {
|
Future<void> requestOwnAvatar() async {
|
||||||
final avatar = _getUserAvatarManager();
|
final am = GetIt.I
|
||||||
final xmpp = GetIt.I.get<XmppService>();
|
.get<XmppConnection>()
|
||||||
final state = await xmpp.getXmppState();
|
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||||
|
final xss = GetIt.I.get<XmppStateService>();
|
||||||
|
final state = await xss.getXmppState();
|
||||||
final jid = state.jid!;
|
final jid = state.jid!;
|
||||||
final id = await avatar.getAvatarId(jid);
|
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||||
|
if (idResult.isType<AvatarError>()) {
|
||||||
|
_log.info('Error while getting latest avatar id for own avatar');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final id = idResult.get<String>();
|
||||||
|
|
||||||
if (id == state.avatarHash) return;
|
if (id == state.avatarHash) return;
|
||||||
|
|
||||||
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself');
|
_log.info(
|
||||||
final data = await avatar.getUserAvatar(jid);
|
'Mismatch between saved avatar data and server-side avatar data about ourself',
|
||||||
if (data == null) {
|
);
|
||||||
|
final avatarDataResult = await am.getUserAvatar(jid);
|
||||||
|
if (avatarDataResult.isType<AvatarError>()) {
|
||||||
_log.severe('Failed to fetch our avatar');
|
_log.severe('Failed to fetch our avatar');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||||
|
|
||||||
_log.info('Received data for our own avatar');
|
_log.info('Received data for our own avatar');
|
||||||
|
|
||||||
final avatarPath = await saveAvatarInCache(
|
final avatarPath = await saveAvatarInCache(
|
||||||
base64Decode(_cleanBase64String(data.base64)),
|
base64Decode(_cleanBase64String(avatarData.base64)),
|
||||||
data.hash,
|
avatarData.hash,
|
||||||
jid,
|
jid,
|
||||||
state.avatarUrl,
|
state.avatarUrl,
|
||||||
);
|
);
|
||||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
await xss.modifyXmppState(
|
||||||
avatarUrl: avatarPath,
|
(state) => state.copyWith(
|
||||||
avatarHash: data.hash,
|
avatarUrl: avatarPath,
|
||||||
),);
|
avatarHash: avatarData.hash,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: data.hash));
|
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,125 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
|
||||||
enum BlockPushType {
|
enum BlockPushType { block, unblock }
|
||||||
block,
|
|
||||||
unblock
|
|
||||||
}
|
|
||||||
|
|
||||||
class BlocklistService {
|
class BlocklistService {
|
||||||
|
BlocklistService();
|
||||||
|
List<String>? _blocklist;
|
||||||
|
bool _requested = false;
|
||||||
|
bool? _supported;
|
||||||
|
final Logger _log = Logger('BlocklistService');
|
||||||
|
|
||||||
BlocklistService() :
|
Future<void> _removeBlocklistEntry(String jid) async {
|
||||||
_blocklistCache = List.empty(growable: true),
|
await GetIt.I.get<DatabaseService>().database.delete(
|
||||||
_requestedBlocklist = false;
|
blocklistTable,
|
||||||
final List<String> _blocklistCache;
|
where: 'jid = ?',
|
||||||
bool _requestedBlocklist;
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<String>> _requestBlocklist() async {
|
Future<void> _addBlocklistEntry(String jid) async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
_blocklistCache
|
blocklistTable,
|
||||||
..clear()
|
{
|
||||||
..addAll(await manager.getBlocklist());
|
'jid': jid,
|
||||||
_requestedBlocklist = true;
|
},
|
||||||
return _blocklistCache;
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void onNewConnection() {
|
||||||
|
// Invalidate the caches
|
||||||
|
_blocklist = null;
|
||||||
|
_requested = false;
|
||||||
|
_supported = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
for (final item in blocklist) {
|
||||||
|
if (!_blocklist!.contains(item)) {
|
||||||
|
await _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 _removeBlocklistEntry(item);
|
||||||
|
_blocklist!.remove(item);
|
||||||
|
removedItems.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_requested = true;
|
||||||
|
|
||||||
|
// Trigger an UI event if we have anything to tell the UI
|
||||||
|
if (newItems.isNotEmpty || removedItems.isNotEmpty) {
|
||||||
|
sendEvent(
|
||||||
|
BlocklistPushEvent(
|
||||||
|
added: newItems,
|
||||||
|
removed: removedItems,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the blocklist from the database
|
/// Returns the blocklist from the database
|
||||||
Future<List<String>> getBlocklist() async {
|
Future<List<String>> getBlocklist() async {
|
||||||
if (!_requestedBlocklist) {
|
if (_blocklist == null) {
|
||||||
_blocklistCache
|
final blocklistRaw =
|
||||||
..clear()
|
await GetIt.I.get<DatabaseService>().database.query(blocklistTable);
|
||||||
..addAll(await _requestBlocklist());
|
_blocklist = blocklistRaw.map((m) => m['jid']! as String).toList();
|
||||||
|
|
||||||
|
if (!_requested) {
|
||||||
|
unawaited(_requestBlocklist());
|
||||||
|
}
|
||||||
|
|
||||||
|
return _blocklist!;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _blocklistCache;
|
if (!_requested) {
|
||||||
|
unawaited(_requestBlocklist());
|
||||||
|
}
|
||||||
|
|
||||||
|
return _blocklist!;
|
||||||
}
|
}
|
||||||
|
|
||||||
void onUnblockAllPush() {
|
void onUnblockAllPush() {
|
||||||
_blocklistCache.clear();
|
_blocklist = List<String>.empty(growable: true);
|
||||||
sendEvent(
|
sendEvent(
|
||||||
BlocklistUnblockAllEvent(),
|
BlocklistUnblockAllEvent(),
|
||||||
);
|
);
|
||||||
@@ -45,23 +127,29 @@ class BlocklistService {
|
|||||||
|
|
||||||
Future<void> onBlocklistPush(BlockPushType type, List<String> items) async {
|
Future<void> onBlocklistPush(BlockPushType type, List<String> items) async {
|
||||||
// We will fetch it later when getBlocklist is called
|
// We will fetch it later when getBlocklist is called
|
||||||
if (!_requestedBlocklist) return;
|
if (!_requested) return;
|
||||||
|
|
||||||
final newBlocks = List<String>.empty(growable: true);
|
final newBlocks = List<String>.empty(growable: true);
|
||||||
final removedBlocks = List<String>.empty(growable: true);
|
final removedBlocks = List<String>.empty(growable: true);
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case BlockPushType.block: {
|
case BlockPushType.block:
|
||||||
if (_blocklistCache.contains(item)) continue;
|
{
|
||||||
_blocklistCache.add(item);
|
if (_blocklist!.contains(item)) continue;
|
||||||
newBlocks.add(item);
|
_blocklist!.add(item);
|
||||||
}
|
newBlocks.add(item);
|
||||||
break;
|
|
||||||
case BlockPushType.unblock: {
|
await _addBlocklistEntry(item);
|
||||||
_blocklistCache.removeWhere((i) => i == item);
|
}
|
||||||
removedBlocks.add(item);
|
break;
|
||||||
}
|
case BlockPushType.unblock:
|
||||||
break;
|
{
|
||||||
|
_blocklist!.removeWhere((i) => i == item);
|
||||||
|
removedBlocks.add(item);
|
||||||
|
|
||||||
|
await _removeBlocklistEntry(item);
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,17 +162,50 @@ class BlocklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> blockJid(String jid) async {
|
Future<bool> blockJid(String jid) async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
// Check if blocking is supported
|
||||||
return manager.block([ jid ]);
|
if (!(await _checkSupport())) {
|
||||||
|
_log.warning('Blocking $jid requested but server does not support it.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blocklist!.add(jid);
|
||||||
|
await _addBlocklistEntry(jid);
|
||||||
|
return GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
.block([jid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> unblockJid(String jid) async {
|
Future<bool> unblockJid(String jid) async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
// Check if blocking is supported
|
||||||
return manager.unblock([ jid ]);
|
if (!(await _checkSupport())) {
|
||||||
|
_log.warning('Unblocking $jid requested but server does not support it.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blocklist!.remove(jid);
|
||||||
|
await _removeBlocklistEntry(jid);
|
||||||
|
return GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
.unblock([jid]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> unblockAll() async {
|
Future<bool> unblockAll() async {
|
||||||
final manager = GetIt.I.get<XmppConnection>().getManagerById<BlockingManager>(blockingManager)!;
|
// Check if blocking is supported
|
||||||
return manager.unblockAll();
|
if (!(await _checkSupport())) {
|
||||||
|
_log.warning(
|
||||||
|
'Unblocking all JIDs requested but server does not support it.',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_blocklist!.clear();
|
||||||
|
await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
|
||||||
|
|
||||||
|
return GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getManagerById<BlockingManager>(blockingManager)!
|
||||||
|
.unblockAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
import 'dart:io' show Platform;
|
import 'dart:async';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
class ConnectivityEvent {
|
||||||
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
|
const ConnectivityEvent(this.regained, this.lost);
|
||||||
|
final bool regained;
|
||||||
|
final bool lost;
|
||||||
|
}
|
||||||
|
|
||||||
class ConnectivityService {
|
class ConnectivityService {
|
||||||
ConnectivityService() : _log = Logger('ConnectivityService');
|
/// The internal stream controller
|
||||||
final Logger _log;
|
final StreamController<ConnectivityEvent> _controller =
|
||||||
|
StreamController<ConnectivityEvent>.broadcast();
|
||||||
|
|
||||||
|
/// The logger
|
||||||
|
final Logger _log = Logger('ConnectivityService');
|
||||||
|
|
||||||
/// Caches the current connectivity state
|
/// Caches the current connectivity state
|
||||||
late ConnectivityResult _connectivity;
|
late ConnectivityResult _connectivity;
|
||||||
|
|
||||||
|
Stream<ConnectivityEvent> get stream => _controller.stream;
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
void setConnectivity(ConnectivityResult result) {
|
void setConnectivity(ConnectivityResult result) {
|
||||||
_log.warning('Internal connectivity state changed by request originating from outside ConnectivityService');
|
_log.warning(
|
||||||
|
'Internal connectivity state changed by request originating from outside ConnectivityService',
|
||||||
|
);
|
||||||
_connectivity = result;
|
_connectivity = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,23 +34,24 @@ class ConnectivityService {
|
|||||||
final conn = Connectivity();
|
final conn = Connectivity();
|
||||||
_connectivity = await conn.checkConnectivity();
|
_connectivity = await conn.checkConnectivity();
|
||||||
|
|
||||||
// TODO(Unknown): At least on Android, the stream fires directly after listening although the
|
conn.onConnectivityChanged.listen((ConnectivityResult result) {
|
||||||
// network does not change. So just skip it.
|
final regained = _connectivity == ConnectivityResult.none &&
|
||||||
// See https://github.com/fluttercommunity/plus_plugins/issues/567
|
result != ConnectivityResult.none;
|
||||||
final skipAmount = Platform.isAndroid ? 1 : 0;
|
|
||||||
conn.onConnectivityChanged.skip(skipAmount).listen((ConnectivityResult result) {
|
|
||||||
final regained = _connectivity == ConnectivityResult.none && result != ConnectivityResult.none;
|
|
||||||
final lost = result == ConnectivityResult.none;
|
final lost = result == ConnectivityResult.none;
|
||||||
_connectivity = result;
|
_connectivity = result;
|
||||||
|
|
||||||
// TODO(PapaTutuWawa): Should we use Streams?
|
_controller.add(
|
||||||
// Notify other services
|
ConnectivityEvent(
|
||||||
(GetIt.I.get<XmppConnection>().reconnectionPolicy as MoxxyReconnectionPolicy)
|
regained,
|
||||||
.onConnectivityChanged(regained, lost);
|
lost,
|
||||||
|
),
|
||||||
GetIt.I.get<HttpFileTransferService>().onConnectivityChanged(regained);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectivityResult get currentState => _connectivity;
|
ConnectivityResult get currentState => _connectivity;
|
||||||
|
|
||||||
|
Future<bool> hasConnection() async {
|
||||||
|
return _connectivity != ConnectivityResult.none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,75 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/notifications.dart';
|
import 'package:moxxyv2/service/notifications.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class ConnectivityWatcherService {
|
class ConnectivityWatcherService {
|
||||||
|
/// Logger.
|
||||||
|
final Logger _log = Logger('ConnectivityWatcherService');
|
||||||
|
|
||||||
ConnectivityWatcherService() : _log = Logger('ConnectivityWatcherService');
|
/// Timer counting how much time has passed since we were last connected.
|
||||||
final Logger _log;
|
|
||||||
|
|
||||||
// Timer counting how much time has passed since we were last connected
|
|
||||||
Timer? _timer;
|
Timer? _timer;
|
||||||
|
|
||||||
|
/// Lock for accessing _timer
|
||||||
|
final Lock _lock = Lock();
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
GetIt.I.get<ConnectivityService>().stream.listen(_onConnectivityEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnectivityEvent(ConnectivityEvent event) async {
|
||||||
|
if (event.lost) {
|
||||||
|
_log.finest('Network connection lost. Stopping timer');
|
||||||
|
await _stopTimer();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onTimerElapsed() async {
|
Future<void> _onTimerElapsed() async {
|
||||||
|
await _stopTimer();
|
||||||
await GetIt.I.get<NotificationsService>().showWarningNotification(
|
await GetIt.I.get<NotificationsService>().showWarningNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
t.errors.connection.connectionTimeout,
|
t.errors.connection.connectionTimeout,
|
||||||
);
|
);
|
||||||
_stopTimer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops the currently running timer, if there is one.
|
/// Stops the currently running timer, if there is one.
|
||||||
void _stopTimer() {
|
Future<void> _stopTimer() async {
|
||||||
if (_timer != null) {
|
await _lock.synchronized(() {
|
||||||
_timer!.cancel();
|
_timer?.cancel();
|
||||||
_timer = null;
|
_timer = null;
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the timer. If it is already running, it stops the currently running one before
|
/// Starts the timer. If it is already running, it stops the currently running one before
|
||||||
/// starting the new one.
|
/// starting the new one.
|
||||||
void _startTimer() {
|
Future<void> _startTimer() async {
|
||||||
_stopTimer();
|
await _stopTimer();
|
||||||
_timer = Timer(const Duration(minutes: 30), _onTimerElapsed);
|
_timer = Timer(const Duration(minutes: 30), _onTimerElapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when the XMPP connection state changed
|
/// Called when the XMPP connection state changed
|
||||||
Future<void> onConnectionStateChanged(XmppConnectionState before, XmppConnectionState current) async {
|
Future<void> onConnectionStateChanged(
|
||||||
if (before == XmppConnectionState.connected && current != XmppConnectionState.connected) {
|
XmppConnectionState before,
|
||||||
|
XmppConnectionState current,
|
||||||
|
) async {
|
||||||
|
if (before == XmppConnectionState.connected &&
|
||||||
|
current != XmppConnectionState.connected) {
|
||||||
// We somehow lost connection
|
// We somehow lost connection
|
||||||
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) {
|
if (await GetIt.I.get<ConnectivityService>().hasConnection()) {
|
||||||
_log.finest('Lost connection to server. Starting warning timer...');
|
_log.finest('Lost connection to server. Starting warning timer...');
|
||||||
_startTimer();
|
await _startTimer();
|
||||||
} else {
|
} else {
|
||||||
_log.finest('Lost connection to server but no network connectivity available. Stopping warning timer...');
|
_log.finest(
|
||||||
_stopTimer();
|
'Lost connection to server but no network connectivity available. Stopping warning timer...',
|
||||||
|
);
|
||||||
|
await _stopTimer();
|
||||||
}
|
}
|
||||||
} else if (current == XmppConnectionState.connected) {
|
} else if (current == XmppConnectionState.connected) {
|
||||||
_stopTimer();
|
await _stopTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
331
lib/service/contacts.dart
Normal file
331
lib/service/contacts.dart
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
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/constants.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logger.
|
||||||
|
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> initialize() 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!;
|
||||||
|
|
||||||
|
// TODO(Unknown): Can we just .cast<String, String>() here?
|
||||||
|
_contactIds = Map<String, String>.fromEntries(
|
||||||
|
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
|
||||||
|
(item) => MapEntry(
|
||||||
|
item['jid']! as String,
|
||||||
|
item['id']! as String,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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 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 GetIt.I.get<DatabaseService>().database.delete(
|
||||||
|
contactsTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [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 conversation = await cs.createOrUpdateConversation(
|
||||||
|
jid,
|
||||||
|
update: (c) async {
|
||||||
|
return cs.updateConversation(
|
||||||
|
jid,
|
||||||
|
contactId: null,
|
||||||
|
contactAvatarPath: null,
|
||||||
|
contactDisplayName: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (conversation != null) {
|
||||||
|
sendEvent(
|
||||||
|
ConversationUpdatedEvent(
|
||||||
|
conversation: conversation,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
contactsTable,
|
||||||
|
<String, String>{
|
||||||
|
'id': contact.id,
|
||||||
|
'jid': 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 conversation = await cs.createOrUpdateConversation(
|
||||||
|
contact.jid,
|
||||||
|
update: (c) async {
|
||||||
|
return cs.updateConversation(
|
||||||
|
contact.jid,
|
||||||
|
contactId: contact.id,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contact.displayName,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (conversation != null) {
|
||||||
|
sendEvent(
|
||||||
|
ConversationUpdatedEvent(
|
||||||
|
conversation: conversation,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +1,220 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/message.dart';
|
||||||
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
import 'package:moxxyv2/shared/cache.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
typedef CreateConversationCallback = Future<Conversation> Function();
|
||||||
|
|
||||||
|
typedef UpdateConversationCallback = Future<Conversation> Function(
|
||||||
|
Conversation,
|
||||||
|
);
|
||||||
|
|
||||||
|
typedef PreRunConversationCallback = Future<void> Function(Conversation?);
|
||||||
|
|
||||||
class ConversationService {
|
class ConversationService {
|
||||||
ConversationService()
|
/// The list of known conversations.
|
||||||
: _conversationCache = LRUCache(100),
|
Map<String, Conversation>? _conversationCache;
|
||||||
_loadedConversations = false;
|
|
||||||
|
|
||||||
final LRUCache<int, Conversation> _conversationCache;
|
/// The lock for accessing _conversationCache
|
||||||
bool _loadedConversations;
|
final Lock _lock = Lock();
|
||||||
|
|
||||||
|
/// When called with a JID [jid], then first, if non-null, [preRun] is
|
||||||
|
/// executed.
|
||||||
|
/// Next, if a conversation with JID [jid] exists, [update] is called with
|
||||||
|
/// the conversation as its argument. If not, then [create] is executed.
|
||||||
|
/// Returns either the result of [create], [update] or null.
|
||||||
|
Future<Conversation?> createOrUpdateConversation(
|
||||||
|
String jid, {
|
||||||
|
CreateConversationCallback? create,
|
||||||
|
UpdateConversationCallback? update,
|
||||||
|
PreRunConversationCallback? preRun,
|
||||||
|
}) async {
|
||||||
|
return _lock.synchronized(() async {
|
||||||
|
final conversation = await _getConversationByJid(jid);
|
||||||
|
|
||||||
|
// Pre run
|
||||||
|
if (preRun != null) {
|
||||||
|
await preRun(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conversation != null) {
|
||||||
|
// Conversation exists
|
||||||
|
if (update != null) {
|
||||||
|
return update(conversation);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Conversation does not exist
|
||||||
|
if (create != null) {
|
||||||
|
return create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads all conversations from the database and adds them to the state and cache.
|
||||||
|
Future<List<Conversation>> loadConversations() async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final conversationsRaw = await db.query(
|
||||||
|
conversationsTable,
|
||||||
|
orderBy: 'lastChangeTimestamp DESC',
|
||||||
|
);
|
||||||
|
|
||||||
|
final tmp = List<Conversation>.empty(growable: true);
|
||||||
|
for (final c in conversationsRaw) {
|
||||||
|
final jid = c['jid']! as String;
|
||||||
|
final rosterItem =
|
||||||
|
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||||
|
|
||||||
|
Message? lastMessage;
|
||||||
|
if (c['lastMessageId'] != null) {
|
||||||
|
lastMessage = await GetIt.I.get<MessageService>().getMessageById(
|
||||||
|
c['lastMessageId']! as int,
|
||||||
|
jid,
|
||||||
|
queryReactionPreview: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp.add(
|
||||||
|
Conversation.fromDatabaseJson(
|
||||||
|
c,
|
||||||
|
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||||
|
rosterItem?.subscription ?? 'none',
|
||||||
|
lastMessage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmp;
|
||||||
|
}
|
||||||
|
|
||||||
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
||||||
/// to the cache.
|
/// to the cache.
|
||||||
Future<void> _loadConversations() async {
|
Future<void> _loadConversationsIfNeeded() async {
|
||||||
final conversations = await GetIt.I.get<DatabaseService>().loadConversations();
|
if (_conversationCache != null) return;
|
||||||
for (final c in conversations) {
|
|
||||||
_conversationCache.cache(c.id, c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the conversation with jid [jid] or null if not found.
|
final conversations = await loadConversations();
|
||||||
Future<Conversation?> getConversationByJid(String jid) async {
|
_conversationCache = Map<String, Conversation>.fromEntries(
|
||||||
if (!_loadedConversations) {
|
conversations.map((c) => MapEntry(c.jid, c)),
|
||||||
await _loadConversations();
|
|
||||||
_loadedConversations = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return firstWhereOrNull(
|
|
||||||
// TODO(Unknown): Maybe have it accept an iterable
|
|
||||||
_conversationCache.getValues(),
|
|
||||||
(Conversation c) => c.jid == jid,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the conversation by its database id or null if it does not exist.
|
/// Returns the conversation with jid [jid] or null if not found.
|
||||||
Future<Conversation?> _getConversationById(int id) async {
|
Future<Conversation?> _getConversationByJid(String jid) async {
|
||||||
if (!_loadedConversations) {
|
await _loadConversationsIfNeeded();
|
||||||
await _loadConversations();
|
return _conversationCache![jid];
|
||||||
_loadedConversations = true;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return _conversationCache.getValue(id);
|
/// Wrapper around [ConversationService._getConversationByJid] that aquires
|
||||||
|
/// the lock for the cache.
|
||||||
|
Future<Conversation?> getConversationByJid(String jid) async {
|
||||||
|
return _lock.synchronized(() async => _getConversationByJid(jid));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// For modifying the cache without writing it to disk. Useful, for example, when
|
/// For modifying the cache without writing it to disk. Useful, for example, when
|
||||||
/// changing the chat state.
|
/// changing the chat state.
|
||||||
void setConversation(Conversation conversation) {
|
void setConversation(Conversation conversation) {
|
||||||
_conversationCache.cache(conversation.id, conversation);
|
_conversationCache![conversation.jid] = conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache.
|
/// Updates the conversation with JID [jid] inside the database.
|
||||||
Future<Conversation> updateConversation(int id, {
|
///
|
||||||
String? lastMessageBody,
|
/// To prevent issues with the cache, only call from within
|
||||||
|
/// [ConversationService.createOrUpdateConversation].
|
||||||
|
Future<Conversation> updateConversation(
|
||||||
|
String jid, {
|
||||||
int? lastChangeTimestamp,
|
int? lastChangeTimestamp,
|
||||||
bool? lastMessageRetracted,
|
Message? lastMessage,
|
||||||
int? lastMessageId,
|
|
||||||
bool? open,
|
bool? open,
|
||||||
int? unreadCounter,
|
int? unreadCounter,
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
bool? muted,
|
bool? muted,
|
||||||
bool? encrypted,
|
bool? encrypted,
|
||||||
|
Object? contactId = notSpecified,
|
||||||
|
Object? contactAvatarPath = notSpecified,
|
||||||
|
Object? contactDisplayName = notSpecified,
|
||||||
}) async {
|
}) async {
|
||||||
final conversation = await _getConversationById(id);
|
final conversation = (await _getConversationByJid(jid))!;
|
||||||
final newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
|
||||||
id,
|
final c = <String, dynamic>{};
|
||||||
lastMessageBody: lastMessageBody,
|
|
||||||
lastMessageRetracted: lastMessageRetracted,
|
if (lastMessage != null) {
|
||||||
lastMessageId: lastMessageId,
|
c['lastMessageId'] = lastMessage.id;
|
||||||
lastChangeTimestamp: lastChangeTimestamp,
|
}
|
||||||
open: open,
|
if (lastChangeTimestamp != null) {
|
||||||
unreadCounter: unreadCounter,
|
c['lastChangeTimestamp'] = lastChangeTimestamp;
|
||||||
avatarUrl: avatarUrl,
|
}
|
||||||
chatState: conversation?.chatState ?? ChatState.gone,
|
if (open != null) {
|
||||||
muted: muted,
|
c['open'] = boolToInt(open);
|
||||||
encrypted: encrypted,
|
}
|
||||||
|
if (unreadCounter != null) {
|
||||||
|
c['unreadCounter'] = unreadCounter;
|
||||||
|
}
|
||||||
|
if (avatarUrl != null) {
|
||||||
|
c['avatarUrl'] = avatarUrl;
|
||||||
|
}
|
||||||
|
if (muted != null) {
|
||||||
|
c['muted'] = boolToInt(muted);
|
||||||
|
}
|
||||||
|
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?;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
|
||||||
|
conversationsTable,
|
||||||
|
c,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
);
|
);
|
||||||
|
|
||||||
_conversationCache.cache(id, newConversation);
|
final rosterItem =
|
||||||
|
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||||
|
var newConversation = Conversation.fromDatabaseJson(
|
||||||
|
result,
|
||||||
|
rosterItem != null,
|
||||||
|
rosterItem?.subscription ?? 'none',
|
||||||
|
lastMessage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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![jid] = newConversation;
|
||||||
return newConversation;
|
return newConversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s [addConversationFromData] that updates the cache.
|
/// Creates a [Conversation] inside the database given the data. This is so that the
|
||||||
|
/// [Conversation] object can carry its database id.
|
||||||
|
///
|
||||||
|
/// To prevent issues with the cache, only call from within
|
||||||
|
/// [ConversationService.createOrUpdateConversation].
|
||||||
Future<Conversation> addConversationFromData(
|
Future<Conversation> addConversationFromData(
|
||||||
String title,
|
String title,
|
||||||
int lastMessageId,
|
Message? lastMessage,
|
||||||
bool lastMessageRetracted,
|
ConversationType type,
|
||||||
String lastMessageBody,
|
|
||||||
String avatarUrl,
|
String avatarUrl,
|
||||||
String jid,
|
String jid,
|
||||||
int unreadCounter,
|
int unreadCounter,
|
||||||
@@ -98,22 +222,39 @@ class ConversationService {
|
|||||||
bool open,
|
bool open,
|
||||||
bool muted,
|
bool muted,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
|
String? contactId,
|
||||||
|
String? contactAvatarPath,
|
||||||
|
String? contactDisplayName,
|
||||||
) async {
|
) async {
|
||||||
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
final rosterItem =
|
||||||
|
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||||
|
final newConversation = Conversation(
|
||||||
title,
|
title,
|
||||||
lastMessageId,
|
lastMessage,
|
||||||
lastMessageRetracted,
|
|
||||||
lastMessageBody,
|
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
jid,
|
jid,
|
||||||
unreadCounter,
|
unreadCounter,
|
||||||
|
type,
|
||||||
lastChangeTimestamp,
|
lastChangeTimestamp,
|
||||||
open,
|
open,
|
||||||
|
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||||
|
rosterItem?.subscription ?? 'none',
|
||||||
muted,
|
muted,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
ChatState.gone,
|
||||||
|
contactId: contactId,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
);
|
);
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
conversationsTable,
|
||||||
|
newConversation.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_conversationCache != null) {
|
||||||
|
_conversationCache![newConversation.jid] = newConversation;
|
||||||
|
}
|
||||||
|
|
||||||
_conversationCache.cache(newConversation.id, newConversation);
|
|
||||||
return newConversation;
|
return newConversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,24 +20,30 @@ List<int> _randomBuffer(int length) {
|
|||||||
|
|
||||||
CipherAlgorithm _sfsToCipher(SFSEncryptionType type) {
|
CipherAlgorithm _sfsToCipher(SFSEncryptionType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SFSEncryptionType.aes128GcmNoPadding: return CipherAlgorithm.aes128GcmNoPadding;
|
case SFSEncryptionType.aes128GcmNoPadding:
|
||||||
case SFSEncryptionType.aes256GcmNoPadding: return CipherAlgorithm.aes256GcmNoPadding;
|
return CipherAlgorithm.aes128GcmNoPadding;
|
||||||
case SFSEncryptionType.aes256CbcPkcs7: return CipherAlgorithm.aes256CbcPkcs7;
|
case SFSEncryptionType.aes256GcmNoPadding:
|
||||||
|
return CipherAlgorithm.aes256GcmNoPadding;
|
||||||
|
case SFSEncryptionType.aes256CbcPkcs7:
|
||||||
|
return CipherAlgorithm.aes256CbcPkcs7;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CryptographyService {
|
class CryptographyService {
|
||||||
|
|
||||||
CryptographyService() : _log = Logger('CryptographyService');
|
CryptographyService() : _log = Logger('CryptographyService');
|
||||||
final Logger _log;
|
final Logger _log;
|
||||||
|
|
||||||
/// Encrypt the file at path [source] and write the encrypted data to [dest]. For the
|
/// Encrypt the file at path [source] and write the encrypted data to [dest]. For the
|
||||||
/// encryption, use the algorithm indicated by [encryption].
|
/// encryption, use the algorithm indicated by [encryption].
|
||||||
Future<EncryptionResult> encryptFile(String source, String dest, SFSEncryptionType encryption) async {
|
Future<EncryptionResult> encryptFile(
|
||||||
|
String source,
|
||||||
|
String dest,
|
||||||
|
SFSEncryptionType encryption,
|
||||||
|
) async {
|
||||||
_log.finest('Beginning encryption routine for $source');
|
_log.finest('Beginning encryption routine for $source');
|
||||||
final key = encryption == SFSEncryptionType.aes128GcmNoPadding ?
|
final key = encryption == SFSEncryptionType.aes128GcmNoPadding
|
||||||
_randomBuffer(16) :
|
? _randomBuffer(16)
|
||||||
_randomBuffer(32);
|
: _randomBuffer(32);
|
||||||
final iv = _randomBuffer(12);
|
final iv = _randomBuffer(12);
|
||||||
final result = (await MoxplatformPlugin.crypto.encryptFile(
|
final result = (await MoxplatformPlugin.crypto.encryptFile(
|
||||||
source,
|
source,
|
||||||
@@ -52,11 +58,11 @@ class CryptographyService {
|
|||||||
return EncryptionResult(
|
return EncryptionResult(
|
||||||
key,
|
key,
|
||||||
iv,
|
iv,
|
||||||
<String, String>{
|
<HashFunction, String>{
|
||||||
hashSha256: base64Encode(result.plaintextHash),
|
HashFunction.sha256: base64Encode(result.plaintextHash),
|
||||||
},
|
},
|
||||||
<String, String>{
|
<HashFunction, String>{
|
||||||
hashSha256: base64Encode(result.ciphertextHash),
|
HashFunction.sha256: base64Encode(result.ciphertextHash),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,8 +76,8 @@ class CryptographyService {
|
|||||||
SFSEncryptionType encryption,
|
SFSEncryptionType encryption,
|
||||||
List<int> key,
|
List<int> key,
|
||||||
List<int> iv,
|
List<int> iv,
|
||||||
Map<String, String> plaintextHashes,
|
Map<HashFunction, String> plaintextHashes,
|
||||||
Map<String, String> ciphertextHashes,
|
Map<HashFunction, String> ciphertextHashes,
|
||||||
) async {
|
) async {
|
||||||
_log.finest('Beginning decryption for $source');
|
_log.finest('Beginning decryption for $source');
|
||||||
final result = await MoxplatformPlugin.crypto.encryptFile(
|
final result = await MoxplatformPlugin.crypto.encryptFile(
|
||||||
@@ -88,7 +94,7 @@ class CryptographyService {
|
|||||||
var passedPlaintextIntegrityCheck = true;
|
var passedPlaintextIntegrityCheck = true;
|
||||||
var passedCiphertextIntegrityCheck = true;
|
var passedCiphertextIntegrityCheck = true;
|
||||||
for (final entry in plaintextHashes.entries) {
|
for (final entry in plaintextHashes.entries) {
|
||||||
if (entry.key == hashSha256) {
|
if (entry.key == HashFunction.sha256) {
|
||||||
if (base64Encode(result!.plaintextHash) != entry.value) {
|
if (base64Encode(result!.plaintextHash) != entry.value) {
|
||||||
passedPlaintextIntegrityCheck = false;
|
passedPlaintextIntegrityCheck = false;
|
||||||
} else {
|
} else {
|
||||||
@@ -98,8 +104,8 @@ class CryptographyService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (final entry in ciphertextHashes.entries) {
|
for (final entry in ciphertextHashes.entries) {
|
||||||
if (entry.key == hashSha256) {
|
if (entry.key == HashFunction.sha256) {
|
||||||
if (base64Encode(result!.ciphertextHash) != entry.value) {
|
if (base64Encode(result!.ciphertextHash) != entry.value) {
|
||||||
passedCiphertextIntegrityCheck = false;
|
passedCiphertextIntegrityCheck = false;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:cryptography/cryptography.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
|
||||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
|
||||||
|
|
||||||
Future<List<int>> hashFileImpl(HashRequest request) async {
|
|
||||||
final data = await File(request.path).readAsBytes();
|
|
||||||
|
|
||||||
return CryptographicHashManager.hashFromData(data, request.hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<EncryptionResult> encryptFileImpl(EncryptionRequest request) async {
|
|
||||||
Cipher algorithm;
|
|
||||||
switch (request.encryption) {
|
|
||||||
case SFSEncryptionType.aes128GcmNoPadding:
|
|
||||||
algorithm = AesGcm.with128bits();
|
|
||||||
break;
|
|
||||||
case SFSEncryptionType.aes256GcmNoPadding:
|
|
||||||
algorithm = AesGcm.with256bits();
|
|
||||||
break;
|
|
||||||
case SFSEncryptionType.aes256CbcPkcs7:
|
|
||||||
// TODO(Unknown): Implement
|
|
||||||
throw Exception();
|
|
||||||
// ignore: dead_code
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a key and an IV for the file
|
|
||||||
final key = await algorithm.newSecretKey();
|
|
||||||
final iv = algorithm.newNonce();
|
|
||||||
final plaintext = await File(request.source).readAsBytes();
|
|
||||||
final secretBox = await algorithm.encrypt(
|
|
||||||
plaintext,
|
|
||||||
secretKey: key,
|
|
||||||
nonce: iv,
|
|
||||||
);
|
|
||||||
final ciphertext = [
|
|
||||||
...secretBox.cipherText,
|
|
||||||
...secretBox.mac.bytes,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Write the file
|
|
||||||
await File(request.dest).writeAsBytes(ciphertext);
|
|
||||||
|
|
||||||
return EncryptionResult(
|
|
||||||
await key.extractBytes(),
|
|
||||||
iv,
|
|
||||||
{
|
|
||||||
hashSha256: base64Encode(
|
|
||||||
await CryptographicHashManager.hashFromData(plaintext, HashFunction.sha256),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
hashSha256: base64Encode(
|
|
||||||
await CryptographicHashManager.hashFromData(ciphertext, HashFunction.sha256),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(PapaTutuWawa): Somehow fail when the ciphertext hash is not matching the provided data
|
|
||||||
Future<DecryptionResult> decryptFileImpl(DecryptionRequest request) async {
|
|
||||||
Cipher algorithm;
|
|
||||||
switch (request.encryption) {
|
|
||||||
case SFSEncryptionType.aes128GcmNoPadding:
|
|
||||||
algorithm = AesGcm.with128bits();
|
|
||||||
break;
|
|
||||||
case SFSEncryptionType.aes256GcmNoPadding:
|
|
||||||
algorithm = AesGcm.with256bits();
|
|
||||||
break;
|
|
||||||
case SFSEncryptionType.aes256CbcPkcs7:
|
|
||||||
// TODO(Unknown): Implement
|
|
||||||
throw Exception();
|
|
||||||
// ignore: dead_code
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final ciphertextRaw = await File(request.source).readAsBytes();
|
|
||||||
final mac = List<int>.empty(growable: true);
|
|
||||||
final ciphertext = List<int>.empty(growable: true);
|
|
||||||
// TODO(PapaTutuWawa): Somehow handle aes256CbcPkcs7
|
|
||||||
if (request.encryption == SFSEncryptionType.aes128GcmNoPadding ||
|
|
||||||
request.encryption == SFSEncryptionType.aes256GcmNoPadding) {
|
|
||||||
mac.addAll(ciphertextRaw.sublist(ciphertextRaw.length - 16));
|
|
||||||
ciphertext.addAll(ciphertextRaw.sublist(0, ciphertextRaw.length - 16));
|
|
||||||
}
|
|
||||||
|
|
||||||
var passedCiphertextIntegrityCheck = true;
|
|
||||||
var passedPlaintextIntegrityCheck = true;
|
|
||||||
// Try to find one hash we can verify
|
|
||||||
for (final entry in request.ciphertextHashes.entries) {
|
|
||||||
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
|
|
||||||
final hash = await CryptographicHashManager.hashFromData(
|
|
||||||
ciphertext,
|
|
||||||
hashFunctionFromName(entry.key),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (base64Encode(hash) == entry.value) {
|
|
||||||
passedCiphertextIntegrityCheck = true;
|
|
||||||
} else {
|
|
||||||
passedCiphertextIntegrityCheck = false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final secretBox = SecretBox(
|
|
||||||
ciphertext,
|
|
||||||
nonce: request.iv,
|
|
||||||
mac: Mac(mac),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final data = await algorithm.decrypt(
|
|
||||||
secretBox,
|
|
||||||
secretKey: SecretKey(request.key),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final entry in request.plaintextHashes.entries) {
|
|
||||||
if ([hashSha256, hashSha512, hashBlake2b512].contains(entry.key)) {
|
|
||||||
final hash = await CryptographicHashManager.hashFromData(
|
|
||||||
data,
|
|
||||||
hashFunctionFromName(entry.key),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (base64Encode(hash) == entry.value) {
|
|
||||||
passedPlaintextIntegrityCheck = true;
|
|
||||||
} else {
|
|
||||||
passedPlaintextIntegrityCheck = false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await File(request.dest).writeAsBytes(data);
|
|
||||||
} catch (_) {
|
|
||||||
return DecryptionResult(
|
|
||||||
false,
|
|
||||||
passedPlaintextIntegrityCheck,
|
|
||||||
passedCiphertextIntegrityCheck,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return DecryptionResult(
|
|
||||||
true,
|
|
||||||
passedPlaintextIntegrityCheck,
|
|
||||||
passedCiphertextIntegrityCheck,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,18 +3,21 @@ import 'package:moxxmpp/moxxmpp.dart';
|
|||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class EncryptionResult {
|
class EncryptionResult {
|
||||||
|
const EncryptionResult(
|
||||||
const EncryptionResult(this.key, this.iv, this.plaintextHashes, this.ciphertextHashes);
|
this.key,
|
||||||
|
this.iv,
|
||||||
|
this.plaintextHashes,
|
||||||
|
this.ciphertextHashes,
|
||||||
|
);
|
||||||
final List<int> key;
|
final List<int> key;
|
||||||
final List<int> iv;
|
final List<int> iv;
|
||||||
|
|
||||||
final Map<String, String> plaintextHashes;
|
final Map<HashFunction, String> plaintextHashes;
|
||||||
final Map<String, String> ciphertextHashes;
|
final Map<HashFunction, String> ciphertextHashes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class EncryptionRequest {
|
class EncryptionRequest {
|
||||||
|
|
||||||
const EncryptionRequest(this.source, this.dest, this.encryption);
|
const EncryptionRequest(this.source, this.dest, this.encryption);
|
||||||
final String source;
|
final String source;
|
||||||
final String dest;
|
final String dest;
|
||||||
@@ -23,7 +26,6 @@ class EncryptionRequest {
|
|||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class DecryptionResult {
|
class DecryptionResult {
|
||||||
|
|
||||||
const DecryptionResult(
|
const DecryptionResult(
|
||||||
this.decryptionOkay,
|
this.decryptionOkay,
|
||||||
this.plaintextOkay,
|
this.plaintextOkay,
|
||||||
@@ -36,7 +38,6 @@ class DecryptionResult {
|
|||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class DecryptionRequest {
|
class DecryptionRequest {
|
||||||
|
|
||||||
const DecryptionRequest(
|
const DecryptionRequest(
|
||||||
this.source,
|
this.source,
|
||||||
this.dest,
|
this.dest,
|
||||||
@@ -51,14 +52,6 @@ class DecryptionRequest {
|
|||||||
final SFSEncryptionType encryption;
|
final SFSEncryptionType encryption;
|
||||||
final List<int> key;
|
final List<int> key;
|
||||||
final List<int> iv;
|
final List<int> iv;
|
||||||
final Map<String, String> plaintextHashes;
|
final Map<HashFunction, String> plaintextHashes;
|
||||||
final Map<String, String> ciphertextHashes;
|
final Map<HashFunction, String> ciphertextHashes;
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class HashRequest {
|
|
||||||
|
|
||||||
const HashRequest(this.path, this.hash);
|
|
||||||
final String path;
|
|
||||||
final HashFunction hash;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const conversationsTable = 'Conversations';
|
const conversationsTable = 'Conversations';
|
||||||
const messsagesTable = 'Messages';
|
const messagesTable = 'Messages';
|
||||||
const rosterTable = 'RosterItems';
|
const rosterTable = 'RosterItems';
|
||||||
const mediaTable = 'SharedMedia';
|
const mediaTable = 'SharedMedia';
|
||||||
const preferenceTable = 'Preferences';
|
const preferenceTable = 'Preferences';
|
||||||
@@ -9,7 +9,16 @@ const omemoRatchetsTable = 'OmemoSessions';
|
|||||||
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
||||||
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
||||||
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
||||||
|
const omemoFingerprintCache = 'OmemoFingerprintCache';
|
||||||
const xmppStateTable = 'XmppState';
|
const xmppStateTable = 'XmppState';
|
||||||
|
const contactsTable = 'Contacts';
|
||||||
|
const stickersTable = 'Stickers';
|
||||||
|
const stickerPacksTable = 'StickerPacks';
|
||||||
|
const blocklistTable = 'Blocklist';
|
||||||
|
const subscriptionsTable = 'SubscriptionRequests';
|
||||||
|
const fileMetadataTable = 'FileMetadata';
|
||||||
|
const fileMetadataHashesTable = 'FileMetadataHashes';
|
||||||
|
const reactionsTable = 'Reactions';
|
||||||
|
|
||||||
const typeString = 0;
|
const typeString = 0;
|
||||||
const typeInt = 1;
|
const typeInt = 1;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import 'package:moxxyv2/service/database/constants.dart';
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/preference.dart';
|
import 'package:moxxyv2/shared/models/preference.dart';
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
Future<void> configureDatabase(Database db) async {
|
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 {
|
Future<void> createDatabase(Database db, int version) async {
|
||||||
@@ -17,78 +18,117 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Messages
|
// Messages
|
||||||
await db.execute(
|
await db.execute('''
|
||||||
'''
|
CREATE TABLE $messagesTable (
|
||||||
CREATE TABLE $messsagesTable (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
sender TEXT NOT NULL,
|
sender TEXT NOT NULL,
|
||||||
body TEXT,
|
body TEXT,
|
||||||
timestamp INTEGER NOT NULL,
|
timestamp INTEGER NOT NULL,
|
||||||
sid TEXT NOT NULL,
|
sid TEXT NOT NULL,
|
||||||
conversationJid TEXT NOT NULL,
|
conversationJid TEXT NOT NULL,
|
||||||
isMedia INTEGER NOT NULL,
|
|
||||||
isFileUploadNotification INTEGER NOT NULL,
|
isFileUploadNotification INTEGER NOT NULL,
|
||||||
encrypted INTEGER NOT NULL,
|
encrypted INTEGER NOT NULL,
|
||||||
errorType INTEGER,
|
errorType INTEGER,
|
||||||
warningType INTEGER,
|
warningType INTEGER,
|
||||||
mediaUrl TEXT,
|
|
||||||
mediaType TEXT,
|
|
||||||
thumbnailData TEXT,
|
|
||||||
mediaWidth INTEGER,
|
|
||||||
mediaHeight INTEGER,
|
|
||||||
srcUrl TEXT,
|
|
||||||
key TEXT,
|
|
||||||
iv TEXT,
|
|
||||||
encryptionScheme TEXT,
|
|
||||||
received INTEGER,
|
received INTEGER,
|
||||||
displayed INTEGER,
|
displayed INTEGER,
|
||||||
acked INTEGER,
|
acked INTEGER,
|
||||||
originId TEXT,
|
originId TEXT,
|
||||||
quote_id INTEGER,
|
quote_id INTEGER,
|
||||||
filename TEXT,
|
file_metadata_id TEXT,
|
||||||
plaintextHashes TEXT,
|
|
||||||
ciphertextHashes TEXT,
|
|
||||||
isDownloading INTEGER NOT NULL,
|
isDownloading INTEGER NOT NULL,
|
||||||
isUploading INTEGER NOT NULL,
|
isUploading INTEGER NOT NULL,
|
||||||
mediaSize INTEGER,
|
|
||||||
isRetracted INTEGER,
|
isRetracted INTEGER,
|
||||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messsagesTable (id)
|
isEdited INTEGER NOT NULL,
|
||||||
)''',
|
containsNoStore INTEGER NOT NULL,
|
||||||
|
stickerPackId TEXT,
|
||||||
|
pseudoMessageType INTEGER,
|
||||||
|
pseudoMessageData TEXT,
|
||||||
|
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||||
|
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||||
|
)''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reactions
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $reactionsTable (
|
||||||
|
senderJid TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||||
|
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// File metadata
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $fileMetadataTable (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
path TEXT,
|
||||||
|
sourceUrls TEXT,
|
||||||
|
mimeType TEXT,
|
||||||
|
thumbnailType TEXT,
|
||||||
|
thumbnailData TEXT,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
plaintextHashes TEXT,
|
||||||
|
encryptionKey TEXT,
|
||||||
|
encryptionIv TEXT,
|
||||||
|
encryptionScheme TEXT,
|
||||||
|
cipherTextHashes TEXT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size INTEGER
|
||||||
|
)''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $fileMetadataHashesTable (
|
||||||
|
algorithm TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
|
||||||
|
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''');
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Conversations
|
// Conversations
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
CREATE TABLE $conversationsTable (
|
CREATE TABLE $conversationsTable (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
jid TEXT NOT NULL PRIMARY KEY,
|
||||||
jid TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
avatarUrl TEXT NOT NULL,
|
avatarUrl TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
lastChangeTimestamp INTEGER NOT NULL,
|
lastChangeTimestamp INTEGER NOT NULL,
|
||||||
unreadCounter INTEGER NOT NULL,
|
unreadCounter INTEGER NOT NULL,
|
||||||
lastMessageBody TEXT NOT NULL,
|
|
||||||
open INTEGER NOT NULL,
|
open INTEGER NOT NULL,
|
||||||
muted INTEGER NOT NULL,
|
muted INTEGER NOT NULL,
|
||||||
encrypted INTEGER NOT NULL,
|
encrypted INTEGER NOT NULL,
|
||||||
lastMessageId INTEGER NOT NULL,
|
lastMessageId INTEGER,
|
||||||
lastMessageRetracted INTEGER NOT NULL,
|
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
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
|
||||||
|
);
|
||||||
|
|
||||||
// Shared media
|
// Contacts
|
||||||
await db.execute(
|
await db.execute('''
|
||||||
'''
|
CREATE TABLE $contactsTable (
|
||||||
CREATE TABLE $mediaTable (
|
id TEXT PRIMARY KEY,
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
jid TEXT NOT NULL
|
||||||
path TEXT NOT NULL,
|
)''');
|
||||||
mime TEXT,
|
|
||||||
timestamp INTEGER NOT NULL,
|
|
||||||
conversation_id INTEGER NOT NULL,
|
|
||||||
message_id INTEGER,
|
|
||||||
FOREIGN KEY (conversation_id) REFERENCES $conversationsTable (id),
|
|
||||||
FOREIGN KEY (message_id) REFERENCES $messsagesTable (id)
|
|
||||||
)''',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Roster
|
// Roster
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -100,10 +140,57 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
avatarUrl TEXT NOT NULL,
|
avatarUrl TEXT NOT NULL,
|
||||||
avatarHash TEXT NOT NULL,
|
avatarHash TEXT NOT NULL,
|
||||||
subscription TEXT NOT NULL,
|
subscription TEXT NOT NULL,
|
||||||
ask TEXT NOT NULL
|
ask TEXT NOT NULL,
|
||||||
|
contactId TEXT,
|
||||||
|
contactAvatarPath TEXT,
|
||||||
|
contactDisplayName TEXT,
|
||||||
|
pseudoRosterItem INTEGER NOT NULL,
|
||||||
|
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||||
|
ON DELETE SET NULL
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Stickers
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $stickersTable (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
desc TEXT NOT NULL,
|
||||||
|
suggests TEXT NOT NULL,
|
||||||
|
file_metadata_id TEXT NOT NULL,
|
||||||
|
stickerPackId TEXT NOT NULL,
|
||||||
|
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
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
|
||||||
|
);
|
||||||
|
''',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscription requests
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $subscriptionsTable(
|
||||||
|
jid TEXT PRIMARY KEY
|
||||||
|
)''');
|
||||||
|
|
||||||
// OMEMO
|
// OMEMO
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
@@ -158,7 +245,7 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
PRIMARY KEY (jid, id)
|
PRIMARY KEY (jid, id)
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'''
|
'''
|
||||||
CREATE TABLE $omemoDeviceListTable (
|
CREATE TABLE $omemoDeviceListTable (
|
||||||
jid TEXT NOT NULL,
|
jid TEXT NOT NULL,
|
||||||
@@ -166,6 +253,15 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
PRIMARY KEY (jid, id)
|
PRIMARY KEY (jid, id)
|
||||||
)''',
|
)''',
|
||||||
);
|
);
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE $omemoFingerprintCache (
|
||||||
|
jid TEXT NOT NULL,
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
fingerprint TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (jid, id)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -240,14 +336,6 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
'true',
|
'true',
|
||||||
).toDatabaseJson(),
|
).toDatabaseJson(),
|
||||||
);
|
);
|
||||||
await db.insert(
|
|
||||||
preferenceTable,
|
|
||||||
Preference(
|
|
||||||
'autoAcceptSubscriptionRequests',
|
|
||||||
typeBool,
|
|
||||||
'false',
|
|
||||||
).toDatabaseJson(),
|
|
||||||
);
|
|
||||||
await db.insert(
|
await db.insert(
|
||||||
preferenceTable,
|
preferenceTable,
|
||||||
Preference(
|
Preference(
|
||||||
@@ -336,4 +424,28 @@ Future<void> createDatabase(Database db, int version) async {
|
|||||||
'default',
|
'default',
|
||||||
).toDatabaseJson(),
|
).toDatabaseJson(),
|
||||||
);
|
);
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'enableContactIntegration',
|
||||||
|
typeBool,
|
||||||
|
'false',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'isStickersNodePublic',
|
||||||
|
typeBool,
|
||||||
|
'true',
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
|
await db.insert(
|
||||||
|
preferenceTable,
|
||||||
|
Preference(
|
||||||
|
'showDebugMenu',
|
||||||
|
typeBool,
|
||||||
|
boolToString(false),
|
||||||
|
).toDatabaseJson(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,5 @@
|
|||||||
|
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||||
|
|
||||||
/// Conversion helpers for bool <-> int as sqlite has no "real" booleans
|
/// Conversion helpers for bool <-> int as sqlite has no "real" booleans
|
||||||
int boolToInt(bool b) => b ? 1 : 0;
|
int boolToInt(bool b) => b ? 1 : 0;
|
||||||
bool intToBool(int i) => i == 0 ? false : true;
|
bool intToBool(int i) => i == 0 ? false : true;
|
||||||
@@ -7,3 +9,43 @@ bool stringToBool(String s) => s == 'true' ? true : false;
|
|||||||
|
|
||||||
String intToString(int i) => '$i';
|
String intToString(int i) => '$i';
|
||||||
int stringToInt(String s) => int.parse(s);
|
int stringToInt(String s) => int.parse(s);
|
||||||
|
|
||||||
|
String conversationTypeToString(ConversationType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ConversationType.chat:
|
||||||
|
{
|
||||||
|
return 'chat';
|
||||||
|
}
|
||||||
|
case ConversationType.note:
|
||||||
|
{
|
||||||
|
return 'note';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ConversationType stringToConversationType(String type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'chat':
|
||||||
|
{
|
||||||
|
return ConversationType.chat;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
{
|
||||||
|
return ConversationType.note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given a map [map], extract all key-value pairs from [map] where the key starts with
|
||||||
|
/// [prefix]. Combine those key-value pairs into a new map, where the leading [prefix]
|
||||||
|
/// is removed from all key names.
|
||||||
|
Map<String, T> getPrefixedSubMap<T>(Map<String, T> map, String prefix) {
|
||||||
|
return Map<String, T>.fromEntries(
|
||||||
|
map.entries.where((entry) => entry.key.startsWith(prefix)).map(
|
||||||
|
(entry) => MapEntry<String, T>(
|
||||||
|
entry.key.substring(prefix.length),
|
||||||
|
entry.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
44
lib/service/database/migration.dart
Normal file
44
lib/service/database/migration.dart
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import 'package:logging/logging.dart';
|
||||||
|
|
||||||
|
/// A function to be called when a migration should be performed.
|
||||||
|
typedef DatabaseMigrationCallback<T> = Future<void> Function(T);
|
||||||
|
|
||||||
|
/// This class represents a single database migration.
|
||||||
|
class DatabaseMigration<T> {
|
||||||
|
const DatabaseMigration(this.version, this.migration);
|
||||||
|
|
||||||
|
/// The version this migration upgrades the database to.
|
||||||
|
final int version;
|
||||||
|
|
||||||
|
/// The migration callback. Called the the database version is less than [version].
|
||||||
|
final DatabaseMigrationCallback<T> migration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Given the database [db] with the current version [version], goes through the list of
|
||||||
|
/// migrations [migrations] and applies all migrations with a version greater than
|
||||||
|
/// [version]. [migrations] is sorted before usage.
|
||||||
|
///
|
||||||
|
/// NOTE: This entire setup is written as a generic to make testing easier. We cannot easily
|
||||||
|
/// mock, or better "instantiate", a Database object. Thus, to avoid having nullable
|
||||||
|
/// database argument, just pass in whatever (the tests use an integer).
|
||||||
|
Future<void> runMigrations<T>(
|
||||||
|
Logger log,
|
||||||
|
T db,
|
||||||
|
List<DatabaseMigration<T>> migrations,
|
||||||
|
int version,
|
||||||
|
) async {
|
||||||
|
final sortedMigrations = List<DatabaseMigration<T>>.from(migrations)
|
||||||
|
..sort(
|
||||||
|
(a, b) => a.version.compareTo(b.version),
|
||||||
|
);
|
||||||
|
var currentVersion = version;
|
||||||
|
for (final migration in sortedMigrations) {
|
||||||
|
if (version < migration.version) {
|
||||||
|
log.info(
|
||||||
|
'Running database migration $currentVersion -> ${migration.version}',
|
||||||
|
);
|
||||||
|
await migration.migration(db);
|
||||||
|
currentVersion = migration.version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
lib/service/database/migrations/0000_blocklist.dart
Normal file
12
lib/service/database/migrations/0000_blocklist.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.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,72 @@
|
|||||||
|
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)};',
|
||||||
|
);
|
||||||
|
}
|
||||||
11
lib/service/database/migrations/0000_conversations.dart
Normal file
11
lib/service/database/migrations/0000_conversations.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.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;',
|
||||||
|
);
|
||||||
|
}
|
||||||
47
lib/service/database/migrations/0000_conversations3.dart
Normal file
47
lib/service/database/migrations/0000_conversations3.dart
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
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;');
|
||||||
|
}
|
||||||
10
lib/service/database/migrations/0000_lmc.dart
Normal file
10
lib/service/database/migrations/0000_lmc.dart
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.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)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
}
|
||||||
11
lib/service/database/migrations/0000_pseudo_messages.dart
Normal file
11
lib/service/database/migrations/0000_pseudo_messages.dart
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.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)};',
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import 'package:moxxyv2/service/database/constants.dart';
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/preference.dart';
|
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
Future<void> upgradeFromV3ToV4(Database db) async {
|
Future<void> upgradeFromV3ToV4(Database db) async {
|
||||||
// Mark all messages as not retracted
|
// Mark all messages as not retracted
|
||||||
await db.execute(
|
await db.execute(
|
||||||
'ALTER TABLE $messsagesTable ADD COLUMN isRetracted INTEGER DEFAULT ${boolToInt(false)};',
|
'ALTER TABLE $messagesTable ADD COLUMN isRetracted INTEGER DEFAULT ${boolToInt(false)};',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:moxxyv2/service/database/constants.dart';
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/helpers.dart';
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/preference.dart';
|
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
Future<void> upgradeFromV4ToV5(Database db) async {
|
Future<void> upgradeFromV4ToV5(Database db) async {
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import 'package:moxxyv2/service/database/constants.dart';
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/shared/models/preference.dart';
|
|
||||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
Future<void> upgradeFromV5ToV6(Database db) async {
|
Future<void> upgradeFromV5ToV6(Database db) async {
|
||||||
// Allow shared media to reference a message
|
// Allow shared media to reference a message
|
||||||
await db.execute(
|
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
45
lib/service/database/migrations/0000_stickers_hash_key.dart
Normal file
45
lib/service/database/migrations/0000_stickers_hash_key.dart
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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,12 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV29ToV30(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $conversationsTable ADD COLUMN sharedMediaAmount INTEGER NOT NULL DEFAULT 0;',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all conversations
|
||||||
|
final conversations = await db.query(
|
||||||
|
conversationsTable,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final conversation in conversations) {
|
||||||
|
// Count the amount of shared media
|
||||||
|
final jid = conversation['jid']! as String;
|
||||||
|
final result = Sqflite.firstIntValue(
|
||||||
|
await db.rawQuery(
|
||||||
|
'SELECT COUNT(*) FROM $mediaTable WHERE conversation_jid = ?',
|
||||||
|
[jid],
|
||||||
|
),
|
||||||
|
) ??
|
||||||
|
0;
|
||||||
|
|
||||||
|
final c = Map<String, Object?>.from(conversation)..remove('id');
|
||||||
|
await db.update(
|
||||||
|
conversationsTable,
|
||||||
|
{
|
||||||
|
...c,
|
||||||
|
'sharedMediaAmount': result,
|
||||||
|
},
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV27ToV28(Database db) async {
|
||||||
|
// Collect conversations so that we have a mapping id -> jid
|
||||||
|
final idMap = <int, String>{};
|
||||||
|
final conversations = await db.query(conversationsTable);
|
||||||
|
for (final c in conversations) {
|
||||||
|
idMap[c['id']! as int] = c['jid']! as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate the conversations
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE ${conversationsTable}_new (
|
||||||
|
jid TEXT NOT NULL PRIMARY KEY,
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'INSERT INTO ${conversationsTable}_new SELECT jid, title, avatarUrl, lastChangeTimestamp, unreadCounter, open, muted, encrypted, lastMessageId, contactid, contactAvatarPath, contactDisplayName from $conversationsTable',
|
||||||
|
);
|
||||||
|
await db.execute('DROP TABLE $conversationsTable;');
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add the jid column to shared media
|
||||||
|
await db.execute(
|
||||||
|
"ALTER TABLE $mediaTable ADD COLUMN conversation_jid TEXT NOT NULL DEFAULT '';",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update all shared media items
|
||||||
|
for (final entry in idMap.entries) {
|
||||||
|
await db.update(
|
||||||
|
mediaTable,
|
||||||
|
{
|
||||||
|
'conversation_jid': entry.value,
|
||||||
|
},
|
||||||
|
where: 'conversation_id = ?',
|
||||||
|
whereArgs: [entry.key],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate shared media
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE ${mediaTable}_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
mime TEXT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
conversation_jid TEXT NOT NULL,
|
||||||
|
message_id INTEGER,
|
||||||
|
FOREIGN KEY (conversation_jid) REFERENCES $conversationsTable (jid),
|
||||||
|
FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
'INSERT INTO ${mediaTable}_new SELECT id, path, mime, timestamp, message_id, conversation_jid from $mediaTable',
|
||||||
|
);
|
||||||
|
await db.execute('DROP TABLE $mediaTable;');
|
||||||
|
await db.execute('ALTER TABLE ${mediaTable}_new RENAME TO $mediaTable;');
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV30ToV31(Database db) async {
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $conversationsTable ADD COLUMN type TEXT NOT NULL DEFAULT "chat";',
|
||||||
|
);
|
||||||
|
}
|
||||||
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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV28ToV29(Database db) async {
|
||||||
|
await db.delete(
|
||||||
|
preferenceTable,
|
||||||
|
where: 'key = "autoAcceptSubscriptionRequests"',
|
||||||
|
);
|
||||||
|
}
|
||||||
9
lib/service/database/migrations/0001_subscriptions.dart
Normal file
9
lib/service/database/migrations/0001_subscriptions.dart
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV26ToV27(Database db) async {
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $subscriptionsTable(
|
||||||
|
jid TEXT PRIMARY KEY
|
||||||
|
)''');
|
||||||
|
}
|
||||||
226
lib/service/database/migrations/0002_file_metadata_table.dart
Normal file
226
lib/service/database/migrations/0002_file_metadata_table.dart
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/files.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV31ToV32(Database db) async {
|
||||||
|
// Create the tracking table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $fileMetadataTable (
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
path TEXT,
|
||||||
|
sourceUrls TEXT,
|
||||||
|
mimeType TEXT,
|
||||||
|
thumbnailType TEXT,
|
||||||
|
thumbnailData TEXT,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
plaintextHashes TEXT,
|
||||||
|
encryptionKey TEXT,
|
||||||
|
encryptionIv TEXT,
|
||||||
|
encryptionScheme TEXT,
|
||||||
|
cipherTextHashes TEXT,
|
||||||
|
filename TEXT NOT NULL,
|
||||||
|
size INTEGER
|
||||||
|
)''');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $fileMetadataHashesTable (
|
||||||
|
algorithm TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
|
||||||
|
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''');
|
||||||
|
|
||||||
|
// Add the file_metadata_id column
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $messagesTable ADD COLUMN file_metadata_id TEXT DEFAULT NULL;',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Migrate the media messages' attributes to new table
|
||||||
|
final messages = await db.query(
|
||||||
|
messagesTable,
|
||||||
|
where: 'isMedia = ${boolToInt(true)}',
|
||||||
|
);
|
||||||
|
for (final message in messages) {
|
||||||
|
// Do we know of a hash?
|
||||||
|
String id;
|
||||||
|
if (message['plaintextHashes'] != null) {
|
||||||
|
// Plaintext hashes available (SFS)
|
||||||
|
final plaintextHashes = deserializeHashMap(
|
||||||
|
message['plaintextHashes']! as String,
|
||||||
|
);
|
||||||
|
final result = await db.query(
|
||||||
|
fileMetadataHashesTable,
|
||||||
|
where: 'algorithm = ? AND value = ?',
|
||||||
|
whereArgs: [
|
||||||
|
plaintextHashes.entries.first.key,
|
||||||
|
plaintextHashes.entries.first.value,
|
||||||
|
],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.isEmpty) {
|
||||||
|
final metadata = FileMetadata(
|
||||||
|
getStrongestHashFromMap(plaintextHashes) ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
message['mediaUrl'] as String?,
|
||||||
|
message['srcUrl'] != null ? [message['srcUrl']! as String] : null,
|
||||||
|
message['mediaType'] as String?,
|
||||||
|
message['mediaSize'] as int?,
|
||||||
|
message['thumbnailData'] != null ? 'blurhash' : null,
|
||||||
|
message['thumbnailData'] as String?,
|
||||||
|
message['mediaWidth'] as int?,
|
||||||
|
message['mediaHeight'] as int?,
|
||||||
|
plaintextHashes,
|
||||||
|
message['key'] as String?,
|
||||||
|
message['iv'] as String?,
|
||||||
|
message['encryptionScheme'] as String?,
|
||||||
|
message['plaintextHashes'] == null
|
||||||
|
? null
|
||||||
|
: deserializeHashMap(message['ciphertextHashes']! as String),
|
||||||
|
message['filename']! as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the metadata
|
||||||
|
await db.insert(
|
||||||
|
fileMetadataTable,
|
||||||
|
metadata.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
id = metadata.id;
|
||||||
|
} else {
|
||||||
|
id = result[0]['id']! as String;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No plaintext hashes are available (OOB data)
|
||||||
|
int? size;
|
||||||
|
int? height;
|
||||||
|
int? width;
|
||||||
|
Map<HashFunction, String>? hashes;
|
||||||
|
String? filePath;
|
||||||
|
String? urlSource;
|
||||||
|
String? mediaType;
|
||||||
|
String? filename;
|
||||||
|
if (message['filename'] == null) {
|
||||||
|
// We are dealing with a sticker
|
||||||
|
assert(
|
||||||
|
message['stickerPackId'] != null,
|
||||||
|
'The message must contain a sticker',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
message['stickerHashKey'] != null,
|
||||||
|
'The message must contain a sticker',
|
||||||
|
);
|
||||||
|
final sticker = (await db.query(
|
||||||
|
stickersTable,
|
||||||
|
where: 'stickerPackId = ? AND hashKey = ?',
|
||||||
|
whereArgs: [message['stickerPackId'], message['stickerHashKey']],
|
||||||
|
limit: 1,
|
||||||
|
))
|
||||||
|
.first;
|
||||||
|
size = sticker['size']! as int;
|
||||||
|
width = sticker['width'] as int?;
|
||||||
|
height = sticker['height'] as int?;
|
||||||
|
hashes = deserializeHashMap(sticker['hashes']! as String);
|
||||||
|
filePath = sticker['path']! as String;
|
||||||
|
urlSource =
|
||||||
|
((jsonDecode(sticker['urlSources']! as String) as List<dynamic>)
|
||||||
|
.cast<String>())
|
||||||
|
.first;
|
||||||
|
mediaType = sticker['mediaType']! as String;
|
||||||
|
filename = path.basename(sticker['path']! as String);
|
||||||
|
} else {
|
||||||
|
size = message['mediaSize'] as int?;
|
||||||
|
width = message['mediaWidth'] as int?;
|
||||||
|
height = message['mediaHeight'] as int?;
|
||||||
|
filePath = message['mediaUrl'] as String?;
|
||||||
|
urlSource = message['srcUrl'] as String?;
|
||||||
|
mediaType = message['mediaType'] as String?;
|
||||||
|
filename = message['filename'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
final metadata = FileMetadata(
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
filePath,
|
||||||
|
urlSource != null ? [urlSource] : null,
|
||||||
|
mediaType,
|
||||||
|
size,
|
||||||
|
message['thumbnailData'] != null ? 'blurhash' : null,
|
||||||
|
message['thumbnailData'] as String?,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
hashes,
|
||||||
|
message['key'] as String?,
|
||||||
|
message['iv'] as String?,
|
||||||
|
message['encryptionScheme'] as String?,
|
||||||
|
null,
|
||||||
|
filename!,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create the metadata
|
||||||
|
await db.insert(
|
||||||
|
fileMetadataTable,
|
||||||
|
metadata.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
id = metadata.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the message
|
||||||
|
await db.update(
|
||||||
|
messagesTable,
|
||||||
|
{
|
||||||
|
'file_metadata_id': id,
|
||||||
|
},
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [message['id']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove columns and add foreign key
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE ${messagesTable}_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
sid TEXT NOT NULL,
|
||||||
|
conversationJid TEXT NOT NULL,
|
||||||
|
isFileUploadNotification INTEGER NOT NULL,
|
||||||
|
encrypted INTEGER NOT NULL,
|
||||||
|
errorType INTEGER,
|
||||||
|
warningType INTEGER,
|
||||||
|
received INTEGER,
|
||||||
|
displayed INTEGER,
|
||||||
|
acked INTEGER,
|
||||||
|
originId TEXT,
|
||||||
|
quote_id INTEGER,
|
||||||
|
file_metadata_id TEXT,
|
||||||
|
isDownloading INTEGER NOT NULL,
|
||||||
|
isUploading INTEGER NOT NULL,
|
||||||
|
isRetracted INTEGER,
|
||||||
|
isEdited INTEGER NOT NULL,
|
||||||
|
reactions TEXT NOT NULL,
|
||||||
|
containsNoStore INTEGER NOT NULL,
|
||||||
|
stickerPackId TEXT,
|
||||||
|
stickerHashKey TEXT,
|
||||||
|
pseudoMessageType INTEGER,
|
||||||
|
pseudoMessageData TEXT,
|
||||||
|
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||||
|
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'INSERT INTO ${messagesTable}_new SELECT id, sender, body, timestamp, sid, conversationJid, isFileUploadNotification, encrypted, errorType, warningType, received, displayed, acked, originId, quote_id, file_metadata_id, isDownloading, isUploading, isRetracted, isEdited, reactions, containsNoStore, stickerPackId, stickerHashKey, pseudoMessageType, pseudoMessageData FROM $messagesTable',
|
||||||
|
);
|
||||||
|
await db.execute('DROP TABLE $messagesTable');
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE ${messagesTable}_new RENAME TO $messagesTable;',
|
||||||
|
);
|
||||||
|
}
|
||||||
24
lib/service/database/migrations/0002_indices.dart
Normal file
24
lib/service/database/migrations/0002_indices.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV36ToV37(Database db) async {
|
||||||
|
// Queries against messages by id (and sid/originId happen regularly)
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Conversations are often queried by their jid
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reactions must be quickly queried
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
||||||
|
);
|
||||||
|
|
||||||
|
// File metadata should also be quickly queriable by its id
|
||||||
|
await db.execute(
|
||||||
|
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
||||||
|
);
|
||||||
|
}
|
||||||
60
lib/service/database/migrations/0002_reactions.dart
Normal file
60
lib/service/database/migrations/0002_reactions.dart
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV34ToV35(Database db) async {
|
||||||
|
// Create the table
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $reactionsTable (
|
||||||
|
senderJid TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||||
|
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''');
|
||||||
|
|
||||||
|
// Figure out our JID
|
||||||
|
final rawJid = await db.query(
|
||||||
|
xmppStateTable,
|
||||||
|
where: "key = 'jid'",
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
String? jid;
|
||||||
|
if (rawJid.isNotEmpty) {
|
||||||
|
jid = rawJid.first['value']! as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate messages
|
||||||
|
final messages = await db.query(
|
||||||
|
messagesTable,
|
||||||
|
where: "reactions IS NOT '[]'",
|
||||||
|
);
|
||||||
|
for (final message in messages) {
|
||||||
|
final reactions =
|
||||||
|
(jsonDecode(message['reactions']! as String) as List<dynamic>)
|
||||||
|
.cast<Map<String, Object?>>();
|
||||||
|
|
||||||
|
for (final reaction in reactions) {
|
||||||
|
final senders = [
|
||||||
|
...reaction['senders']! as List<String>,
|
||||||
|
if (intToBool(reaction['reactedBySelf']! as int) && jid != null) jid,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final sender in senders) {
|
||||||
|
await db.insert(
|
||||||
|
reactionsTable,
|
||||||
|
{
|
||||||
|
'senderJid': sender,
|
||||||
|
'emoji': reaction['emoji']! as String,
|
||||||
|
'message_id': message['id']! as int,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the column
|
||||||
|
await db.execute('ALTER TABLE $messagesTable DROP COLUMN reactions');
|
||||||
|
}
|
||||||
15
lib/service/database/migrations/0002_reactions_2.dart
Normal file
15
lib/service/database/migrations/0002_reactions_2.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV35ToV36(Database db) async {
|
||||||
|
await db.execute('DROP TABLE $reactionsTable');
|
||||||
|
await db.execute('''
|
||||||
|
CREATE TABLE $reactionsTable (
|
||||||
|
senderJid TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
message_id INTEGER NOT NULL,
|
||||||
|
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||||
|
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
)''');
|
||||||
|
}
|
||||||
14
lib/service/database/migrations/0002_shared_media.dart
Normal file
14
lib/service/database/migrations/0002_shared_media.dart
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV33ToV34(Database db) async {
|
||||||
|
// Remove the shared media counter...
|
||||||
|
await db.execute(
|
||||||
|
'ALTER TABLE $conversationsTable DROP COLUMN sharedMediaAmount',
|
||||||
|
);
|
||||||
|
|
||||||
|
// ... and the entire table.
|
||||||
|
await db.execute(
|
||||||
|
'DROP TABLE $mediaTable',
|
||||||
|
);
|
||||||
|
}
|
||||||
113
lib/service/database/migrations/0002_sticker_metadata.dart
Normal file
113
lib/service/database/migrations/0002_sticker_metadata.dart
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
Future<void> upgradeFromV32ToV33(Database db) async {
|
||||||
|
final stickers = await db.query(stickersTable);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
'''
|
||||||
|
CREATE TABLE ${stickersTable}_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
desc TEXT NOT NULL,
|
||||||
|
suggests TEXT NOT NULL,
|
||||||
|
file_metadata_id TEXT NOT NULL,
|
||||||
|
stickerPackId TEXT NOT NULL,
|
||||||
|
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||||
|
)''',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mapping stickerHashKey -> fileMetadataId
|
||||||
|
final stickerHashMap = <String, String>{};
|
||||||
|
for (final sticker in stickers) {
|
||||||
|
final hashes =
|
||||||
|
(jsonDecode(sticker['hashes']! as String) as Map<String, dynamic>)
|
||||||
|
.cast<String, String>();
|
||||||
|
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (var i = 0; i < hashes.length; i++) {
|
||||||
|
buffer.write('(algorithm = ? AND value = ?) AND');
|
||||||
|
}
|
||||||
|
final query = buffer.toString();
|
||||||
|
|
||||||
|
final rawFm = await db.query(
|
||||||
|
fileMetadataHashesTable,
|
||||||
|
where: query.substring(0, query.length - 1 - 3),
|
||||||
|
whereArgs: hashes.entries
|
||||||
|
.map<List<String>>((entry) => [entry.key, entry.value])
|
||||||
|
.flattened
|
||||||
|
.toList(),
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
String fileMetadataId;
|
||||||
|
if (rawFm.isEmpty) {
|
||||||
|
// Create the metadata
|
||||||
|
fileMetadataId = DateTime.now().toString();
|
||||||
|
await db.insert(
|
||||||
|
fileMetadataTable,
|
||||||
|
{
|
||||||
|
'id': fileMetadataId,
|
||||||
|
'path': sticker['path']! as String,
|
||||||
|
'size': sticker['size']! as int,
|
||||||
|
'width': sticker['width'] as int?,
|
||||||
|
'height': sticker['height'] as int?,
|
||||||
|
'plaintextHashes': sticker['hashes']! as String,
|
||||||
|
'mimeType': sticker['mediaType']! as String,
|
||||||
|
'sourceUrls': sticker['urlSources'],
|
||||||
|
'filename': path.basename(sticker['path']! as String),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create hash pointers
|
||||||
|
for (final hashEntry in hashes.entries) {
|
||||||
|
await db.insert(
|
||||||
|
fileMetadataHashesTable,
|
||||||
|
{
|
||||||
|
'algorithm': hashEntry.key,
|
||||||
|
'value': hashEntry.value,
|
||||||
|
'id': fileMetadataId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fileMetadataId = rawFm.first['id']! as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
final hashKey = sticker['hashKey']! as String;
|
||||||
|
stickerHashMap[hashKey] = fileMetadataId;
|
||||||
|
await db.insert(
|
||||||
|
'${stickersTable}_new',
|
||||||
|
{
|
||||||
|
'id': hashKey,
|
||||||
|
'desc': sticker['desc']! as String,
|
||||||
|
'suggests': sticker['suggests']! as String,
|
||||||
|
'file_metadata_id': fileMetadataId,
|
||||||
|
'stickerPackId': sticker['stickerPackId']! as String,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename the table
|
||||||
|
await db.execute('DROP TABLE $stickersTable');
|
||||||
|
await db.execute('ALTER TABLE ${stickersTable}_new RENAME TO $stickersTable');
|
||||||
|
|
||||||
|
// Migrate messages
|
||||||
|
for (final stickerEntry in stickerHashMap.entries) {
|
||||||
|
await db.update(
|
||||||
|
messagesTable,
|
||||||
|
{
|
||||||
|
'file_metadata_id': stickerEntry.value,
|
||||||
|
},
|
||||||
|
where: 'stickerHashKey = ?',
|
||||||
|
whereArgs: [stickerEntry.key],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the hash key from messages
|
||||||
|
await db.execute('ALTER TABLE $messagesTable DROP COLUMN stickerHashKey');
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
340
lib/service/files.dart
Normal file
340
lib/service/files.dart
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||||
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||||
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// A class for returning whether a file metadata element was just created or retrieved.
|
||||||
|
class FileMetadataWrapper {
|
||||||
|
FileMetadataWrapper(
|
||||||
|
this.fileMetadata,
|
||||||
|
this.retrieved,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The file metadata.
|
||||||
|
FileMetadata fileMetadata;
|
||||||
|
|
||||||
|
/// Indicates whether the file metadata already exists (true) or
|
||||||
|
/// if it has been created (false).
|
||||||
|
bool retrieved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the strongest hash from [map], if [map] is not null. If no known hash is found
|
||||||
|
/// or [map] is null, returns null.
|
||||||
|
String? getStrongestHashFromMap(Map<HashFunction, String>? map) {
|
||||||
|
if (map == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[HashFunction.blake2b512] ??
|
||||||
|
map[HashFunction.blake2b256] ??
|
||||||
|
map[HashFunction.sha3_512] ??
|
||||||
|
map[HashFunction.sha3_256] ??
|
||||||
|
map[HashFunction.sha512] ??
|
||||||
|
map[HashFunction.sha256];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the path for a given file with filename [filename] and the optional
|
||||||
|
/// plaintext hashes [hashes]. If the base directory for the file does not exist, then it
|
||||||
|
/// will be created.
|
||||||
|
Future<String> computeCachedPathForFile(
|
||||||
|
String filename,
|
||||||
|
Map<HashFunction, String>? hashes,
|
||||||
|
) async {
|
||||||
|
final basePath = path.join(
|
||||||
|
(await getApplicationDocumentsDirectory()).path,
|
||||||
|
'media',
|
||||||
|
);
|
||||||
|
final baseDir = Directory(basePath);
|
||||||
|
|
||||||
|
if (!baseDir.existsSync()) {
|
||||||
|
await baseDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep the extension of the file. Otherwise Android will be really confused
|
||||||
|
// as to what it should open the file with.
|
||||||
|
final ext = path.extension(filename);
|
||||||
|
final hash = getStrongestHashFromMap(hashes)?.replaceAll('/', '_');
|
||||||
|
return path.join(
|
||||||
|
basePath,
|
||||||
|
hash != null
|
||||||
|
? '$hash.$ext'
|
||||||
|
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class FilesService {
|
||||||
|
// Logging.
|
||||||
|
final Logger _log = Logger('FilesService');
|
||||||
|
|
||||||
|
Future<void> createMetadataHashEntries(
|
||||||
|
Map<HashFunction, String> plaintextHashes,
|
||||||
|
String metadataId,
|
||||||
|
) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
for (final hash in plaintextHashes.entries) {
|
||||||
|
await db.insert(
|
||||||
|
fileMetadataHashesTable,
|
||||||
|
{
|
||||||
|
'algorithm': hash.key.toName(),
|
||||||
|
'value': hash.value,
|
||||||
|
'id': metadataId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FileMetadata?> getFileMetadataFromFile(FileMetadata metadata) async {
|
||||||
|
final hash = metadata.plaintextHashes?[HashFunction.sha256] ??
|
||||||
|
await GetIt.I
|
||||||
|
.get<CryptographyService>()
|
||||||
|
.hashFile(metadata.path!, HashFunction.sha256);
|
||||||
|
final fm = await getFileMetadataFromHash({
|
||||||
|
HashFunction.sha256: hash,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (fm != null) {
|
||||||
|
return fm;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await addFileMetadataFromData(
|
||||||
|
metadata.copyWith(
|
||||||
|
plaintextHashes: {
|
||||||
|
...metadata.plaintextHashes ?? {},
|
||||||
|
HashFunction.sha256: hash,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await createMetadataHashEntries(result.plaintextHashes!, result.id);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FileMetadata?> getFileMetadataFromHash(
|
||||||
|
Map<HashFunction, String>? plaintextHashes,
|
||||||
|
) async {
|
||||||
|
if (plaintextHashes?.isEmpty ?? true) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final values = List<String>.empty(growable: true);
|
||||||
|
final query = plaintextHashes!.entries.map((entry) {
|
||||||
|
values
|
||||||
|
..add(entry.key.toName())
|
||||||
|
..add(entry.value);
|
||||||
|
return '(algorithm = ? AND value = ?)';
|
||||||
|
}).join(' OR ');
|
||||||
|
final hashes = await db.query(
|
||||||
|
fileMetadataHashesTable,
|
||||||
|
where: query,
|
||||||
|
whereArgs: values,
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (hashes.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await db.query(
|
||||||
|
fileMetadataTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [hashes[0]['id']! as String],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (result.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileMetadata.fromDatabaseJson(result[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a FileMetadata entry if we do not know the plaintext hashes described in
|
||||||
|
/// [location].
|
||||||
|
/// If we know of at least one hash, return that FileMetadata element.
|
||||||
|
///
|
||||||
|
/// If [createHashPointers] is true and we have to create a new FileMetadata element,
|
||||||
|
/// then also create the hash pointers, if plaintext hashes are specified. If no
|
||||||
|
/// plaintext hashes are specified or [createHashPointers] is false, no pointers will be
|
||||||
|
/// created.
|
||||||
|
Future<FileMetadataWrapper> createFileMetadataIfRequired(
|
||||||
|
MediaFileLocation location,
|
||||||
|
String? mimeType,
|
||||||
|
int? size,
|
||||||
|
Size? dimensions,
|
||||||
|
String? thubnailType,
|
||||||
|
String? thumbnailData, {
|
||||||
|
bool createHashPointers = true,
|
||||||
|
String? path,
|
||||||
|
}) async {
|
||||||
|
if (location.plaintextHashes?.isNotEmpty ?? false) {
|
||||||
|
final result = await getFileMetadataFromHash(location.plaintextHashes);
|
||||||
|
if (result != null) {
|
||||||
|
_log.finest('Not creating new metadata as we found the hash');
|
||||||
|
return FileMetadataWrapper(
|
||||||
|
result,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final fm = FileMetadata(
|
||||||
|
getStrongestHashFromMap(location.plaintextHashes) ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
path,
|
||||||
|
location.urls,
|
||||||
|
mimeType,
|
||||||
|
size,
|
||||||
|
thubnailType,
|
||||||
|
thumbnailData,
|
||||||
|
dimensions?.width.toInt(),
|
||||||
|
dimensions?.height.toInt(),
|
||||||
|
location.plaintextHashes,
|
||||||
|
location.key != null ? base64Encode(location.key!) : null,
|
||||||
|
location.iv != null ? base64Encode(location.iv!) : null,
|
||||||
|
location.encryptionScheme,
|
||||||
|
location.ciphertextHashes,
|
||||||
|
location.filename,
|
||||||
|
);
|
||||||
|
await db.insert(fileMetadataTable, fm.toDatabaseJson());
|
||||||
|
|
||||||
|
if ((location.plaintextHashes?.isNotEmpty ?? false) && createHashPointers) {
|
||||||
|
await createMetadataHashEntries(
|
||||||
|
location.plaintextHashes!,
|
||||||
|
fm.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileMetadataWrapper(
|
||||||
|
fm,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeFileMetadata(String id) async {
|
||||||
|
await GetIt.I.get<DatabaseService>().database.delete(
|
||||||
|
fileMetadataTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FileMetadata> updateFileMetadata(
|
||||||
|
String id, {
|
||||||
|
Object? path = notSpecified,
|
||||||
|
int? size,
|
||||||
|
String? encryptionScheme,
|
||||||
|
String? encryptionKey,
|
||||||
|
String? encryptionIv,
|
||||||
|
List<String>? sourceUrls,
|
||||||
|
int? width,
|
||||||
|
int? height,
|
||||||
|
String? mimeType,
|
||||||
|
Map<String, String>? plaintextHashes,
|
||||||
|
Map<String, String>? ciphertextHashes,
|
||||||
|
}) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final m = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (path != notSpecified) {
|
||||||
|
m['path'] = path as String?;
|
||||||
|
}
|
||||||
|
if (encryptionScheme != null) {
|
||||||
|
m['encryptionScheme'] = encryptionScheme;
|
||||||
|
}
|
||||||
|
if (size != null) {
|
||||||
|
m['size'] = size;
|
||||||
|
}
|
||||||
|
if (encryptionKey != null) {
|
||||||
|
m['encryptionKey'] = encryptionKey;
|
||||||
|
}
|
||||||
|
if (encryptionIv != null) {
|
||||||
|
m['encryptionIv'] = encryptionIv;
|
||||||
|
}
|
||||||
|
if (sourceUrls != null) {
|
||||||
|
m['sourceUrl'] = jsonEncode(sourceUrls);
|
||||||
|
}
|
||||||
|
if (width != null) {
|
||||||
|
m['width'] = width;
|
||||||
|
}
|
||||||
|
if (height != null) {
|
||||||
|
m['height'] = height;
|
||||||
|
}
|
||||||
|
if (mimeType != null) {
|
||||||
|
m['mimeType'] = mimeType;
|
||||||
|
}
|
||||||
|
if (plaintextHashes != null) {
|
||||||
|
m['plaintextHashes'] = jsonEncode(plaintextHashes);
|
||||||
|
}
|
||||||
|
if (ciphertextHashes != null) {
|
||||||
|
m['cipherTextHashes'] = jsonEncode(ciphertextHashes);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await db.updateAndReturn(
|
||||||
|
fileMetadataTable,
|
||||||
|
m,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return FileMetadata.fromDatabaseJson(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the file metadata described by [metadata] if it is referenced by exactly 0
|
||||||
|
/// messages and no stickers use this file. If the file is referenced by > 1 messages
|
||||||
|
/// or a sticker, does nothing.
|
||||||
|
Future<void> removeFileIfNotReferenced(FileMetadata metadata) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final messagesCount = await db.count(
|
||||||
|
messagesTable,
|
||||||
|
'file_metadata_id = ?',
|
||||||
|
[metadata.id],
|
||||||
|
);
|
||||||
|
final stickersCount = await db.count(
|
||||||
|
stickersTable,
|
||||||
|
'file_metadata_id = ?',
|
||||||
|
[metadata.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messagesCount == 0 && stickersCount == 0) {
|
||||||
|
_log.finest(
|
||||||
|
'Removing file metadata as no stickers and no messages reference it',
|
||||||
|
);
|
||||||
|
await removeFileMetadata(metadata.id);
|
||||||
|
|
||||||
|
// Only remove the file if we have a path
|
||||||
|
if (metadata.path != null) {
|
||||||
|
try {
|
||||||
|
await File(metadata.path!).delete();
|
||||||
|
} catch (ex) {
|
||||||
|
_log.warning('Failed to remove file ${metadata.path!}: $ex');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.info('Not removing file as there is no path associated with it');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.info(
|
||||||
|
'Not removing file as $messagesCount messages and $stickersCount stickers reference this file',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FileMetadata> addFileMetadataFromData(
|
||||||
|
FileMetadata metadata,
|
||||||
|
) async {
|
||||||
|
final result =
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insertAndReturn(
|
||||||
|
fileMetadataTable,
|
||||||
|
metadata.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
return FileMetadata.fromDatabaseJson(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:native_imaging/native_imaging.dart' as native;
|
import 'package:native_imaging/native_imaging.dart' as native;
|
||||||
|
|
||||||
Future<String?> _generateBlurhashThumbnailImpl(String path) async {
|
Future<String?> _generateBlurhashThumbnailImpl(String path) async {
|
||||||
@@ -65,11 +67,93 @@ Future<String?> generateBlurhashThumbnail(String path) async {
|
|||||||
String xmppErrorToTranslatableString(XmppError error) {
|
String xmppErrorToTranslatableString(XmppError error) {
|
||||||
if (error is StartTLSFailedError) {
|
if (error is StartTLSFailedError) {
|
||||||
return t.errors.login.startTlsFailed;
|
return t.errors.login.startTlsFailed;
|
||||||
} else if (error is SaslFailedError) {
|
} else if (error is SaslError) {
|
||||||
return t.errors.login.saslFailed;
|
return t.errors.login.saslFailed;
|
||||||
} else if (error is NoConnectionError) {
|
} else if (error is NoConnectionPossibleError) {
|
||||||
return t.errors.login.noConnection;
|
return t.errors.login.noConnection;
|
||||||
}
|
}
|
||||||
|
|
||||||
return t.errors.login.unspecified;
|
return t.errors.login.unspecified;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
HashFunction getStickerHashKeyType(Map<HashFunction, String> hashes) {
|
||||||
|
if (hashes.containsKey(HashFunction.blake2b512)) {
|
||||||
|
return HashFunction.blake2b512;
|
||||||
|
} else if (hashes.containsKey(HashFunction.blake2b256)) {
|
||||||
|
return HashFunction.blake2b256;
|
||||||
|
} else if (hashes.containsKey(HashFunction.sha3_512)) {
|
||||||
|
return HashFunction.sha3_512;
|
||||||
|
} else if (hashes.containsKey(HashFunction.sha3_256)) {
|
||||||
|
return HashFunction.sha3_256;
|
||||||
|
} else if (hashes.containsKey(HashFunction.sha512)) {
|
||||||
|
return HashFunction.sha512;
|
||||||
|
} else if (hashes.containsKey(HashFunction.sha256)) {
|
||||||
|
return HashFunction.sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(false, 'No valid hash found');
|
||||||
|
return HashFunction.sha256;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(PapaTutuWawa): Replace with getStrongestHash
|
||||||
|
String getStickerHashKey(Map<HashFunction, String> hashes) {
|
||||||
|
final key = getStickerHashKeyType(hashes);
|
||||||
|
return '$key:${hashes[key]}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a human readable string describing an unrecoverable error event [event].
|
||||||
|
String getUnrecoverableErrorString(NonRecoverableErrorEvent event) {
|
||||||
|
final error = event.error;
|
||||||
|
if (error is SaslAccountDisabledError) {
|
||||||
|
return t.errors.connection.saslAccountDisabled;
|
||||||
|
} else if (error is SaslCredentialsExpiredError ||
|
||||||
|
error is SaslNotAuthorizedError) {
|
||||||
|
return t.errors.connection.saslInvalidCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.errors.connection.unrecoverable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates the fallback body for quoted messages.
|
||||||
|
/// If the quoted message contains text, it simply quotes the text.
|
||||||
|
/// If it contains a media file, the messageEmoji (usually an emoji
|
||||||
|
/// representing the mime type) is shown together with the file size
|
||||||
|
/// (from experience this information is sufficient, as most clients show
|
||||||
|
/// the file size, and including time information might be confusing and a
|
||||||
|
/// potential privacy issue).
|
||||||
|
/// This information is complemented either the srcUrl or – if unavailable –
|
||||||
|
/// by the body of the quoted message. For non-media messages, we always use
|
||||||
|
/// the body as fallback.
|
||||||
|
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
|
||||||
|
if (quotedMessage == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quotedMessage.isMedia) {
|
||||||
|
// Create formatted size string, if size is stored
|
||||||
|
String quoteMessageSize;
|
||||||
|
if (quotedMessage.fileMetadata!.size != null &&
|
||||||
|
quotedMessage.fileMetadata!.size! > 0) {
|
||||||
|
quoteMessageSize =
|
||||||
|
'(${fileSizeToString(quotedMessage.fileMetadata!.size!)}) ';
|
||||||
|
} else {
|
||||||
|
quoteMessageSize = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create media url string, or use body if no srcUrl is stored
|
||||||
|
String quotedMediaUrl;
|
||||||
|
if (quotedMessage.fileMetadata!.sourceUrls != null &&
|
||||||
|
quotedMessage.fileMetadata!.sourceUrls!.first.isNotEmpty) {
|
||||||
|
quotedMediaUrl = '• ${quotedMessage.fileMetadata!.sourceUrls!.first}';
|
||||||
|
} else if (quotedMessage.body.isNotEmpty) {
|
||||||
|
quotedMediaUrl = '• ${quotedMessage.body}';
|
||||||
|
} else {
|
||||||
|
quotedMediaUrl = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concatenate emoji, size string, and media url and return
|
||||||
|
return '${quotedMessage.messageEmoji} $quoteMessageSize$quotedMediaUrl';
|
||||||
|
} else {
|
||||||
|
return quotedMessage.body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
146
lib/service/httpfiletransfer/client.dart
Normal file
146
lib/service/httpfiletransfer/client.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
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,40 +1,4 @@
|
|||||||
import 'dart:io';
|
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:external_path/external_path.dart';
|
|
||||||
import 'package:moxxyv2/shared/helpers.dart';
|
|
||||||
import 'package:path/path.dart' as path;
|
|
||||||
|
|
||||||
/// Calculates the path for a given file to be saved to and, if neccessary, create it.
|
|
||||||
Future<String> getDownloadPath(String filename, String conversationJid, String? mime) async {
|
|
||||||
String type;
|
|
||||||
var prependMoxxy = true;
|
|
||||||
if (mime != null && ['image/', 'video/'].any((e) => mime.startsWith(e))) {
|
|
||||||
type = ExternalPath.DIRECTORY_PICTURES;
|
|
||||||
} else {
|
|
||||||
type = ExternalPath.DIRECTORY_DOWNLOADS;
|
|
||||||
prependMoxxy = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
final externalDir = await ExternalPath.getExternalStoragePublicDirectory(type);
|
|
||||||
final fileDirectory = prependMoxxy ? path.join(externalDir, 'Moxxy', conversationJid) : externalDir;
|
|
||||||
final dir = Directory(fileDirectory);
|
|
||||||
if (!dir.existsSync()) {
|
|
||||||
await dir.create(recursive: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
var i = 0;
|
|
||||||
while (true) {
|
|
||||||
final filenameSuffix = i == 0 ? '' : '($i)';
|
|
||||||
final suffixedFilename = filenameWithSuffix(filename, filenameSuffix);
|
|
||||||
|
|
||||||
final filePath = path.join(fileDirectory, suffixedFilename);
|
|
||||||
if (!File(filePath).existsSync()) {
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if the request was successful based on [statusCode].
|
/// Returns true if the request was successful based on [statusCode].
|
||||||
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
||||||
@@ -42,9 +6,8 @@ bool isRequestOkay(int? statusCode) {
|
|||||||
return statusCode != null && statusCode >= 200 && statusCode <= 399;
|
return statusCode != null && statusCode >= 200 && statusCode <= 399;
|
||||||
}
|
}
|
||||||
|
|
||||||
class FileMetadata {
|
class FileUploadMetadata {
|
||||||
|
const FileUploadMetadata({this.mime, this.size});
|
||||||
const FileMetadata({ this.mime, this.size });
|
|
||||||
final String? mime;
|
final String? mime;
|
||||||
final int? size;
|
final int? size;
|
||||||
}
|
}
|
||||||
@@ -52,16 +15,11 @@ class FileMetadata {
|
|||||||
/// Returns the size of the file at [url] in octets. If an error occurs or the server
|
/// Returns the size of the file at [url] in octets. If an error occurs or the server
|
||||||
/// does not specify the Content-Length header, null is returned.
|
/// does not specify the Content-Length header, null is returned.
|
||||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||||
Future<FileMetadata> peekFile(String url) async {
|
Future<FileUploadMetadata> peekFile(String url) async {
|
||||||
final response = await Dio().headUri<dynamic>(Uri.parse(url));
|
final result = await peekUrl(Uri.parse(url));
|
||||||
|
|
||||||
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
|
return FileUploadMetadata(
|
||||||
|
mime: result?.contentType,
|
||||||
final contentLengthHeaders = response.headers['Content-Length'];
|
size: result?.contentLength,
|
||||||
final contentTypeHeaders = response.headers['Content-Type'];
|
|
||||||
|
|
||||||
return FileMetadata(
|
|
||||||
mime: contentTypeHeaders?.first,
|
|
||||||
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||||
import 'package:dio/dio.dart' as dio;
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:image_size_getter/file_input.dart';
|
|
||||||
import 'package:image_size_getter/image_size_getter.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mime/mime.dart';
|
import 'package:mime/mime.dart';
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
@@ -16,14 +12,17 @@ import 'package:moxxyv2/service/connectivity.dart';
|
|||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/files.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/notifications.dart';
|
import 'package:moxxyv2/service/notifications.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/shared/error_types.dart';
|
import 'package:moxxyv2/shared/error_types.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/warning_types.dart';
|
import 'package:moxxyv2/shared/warning_types.dart';
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
@@ -33,37 +32,39 @@ import 'package:uuid/uuid.dart';
|
|||||||
|
|
||||||
/// This service is responsible for managing the up- and download of files using Http.
|
/// This service is responsible for managing the up- and download of files using Http.
|
||||||
class HttpFileTransferService {
|
class HttpFileTransferService {
|
||||||
HttpFileTransferService()
|
HttpFileTransferService() {
|
||||||
: _uploadQueue = Queue<FileUploadJob>(),
|
GetIt.I.get<ConnectivityService>().stream.listen(_onConnectivityChanged);
|
||||||
_downloadQueue = Queue<FileDownloadJob>(),
|
}
|
||||||
_uploadLock = Lock(),
|
|
||||||
_downloadLock = Lock(),
|
|
||||||
_log = Logger('HttpFileTransferService');
|
|
||||||
|
|
||||||
final Logger _log;
|
final Logger _log = Logger('HttpFileTransferService');
|
||||||
|
|
||||||
/// Queues for tracking up- and download tasks
|
/// Queues for tracking up- and download tasks
|
||||||
final Queue<FileDownloadJob> _downloadQueue;
|
final Queue<FileDownloadJob> _downloadQueue = Queue<FileDownloadJob>();
|
||||||
final Queue<FileUploadJob> _uploadQueue;
|
final Queue<FileUploadJob> _uploadQueue = Queue<FileUploadJob>();
|
||||||
|
|
||||||
/// The currently running job and their lock
|
/// The currently running job and their lock
|
||||||
FileUploadJob? _currentUploadJob;
|
FileUploadJob? _currentUploadJob;
|
||||||
FileDownloadJob? _currentDownloadJob;
|
FileDownloadJob? _currentDownloadJob;
|
||||||
|
|
||||||
/// Locks for upload and download state
|
/// Locks for upload and download state
|
||||||
final Lock _uploadLock;
|
final Lock _uploadLock = Lock();
|
||||||
final Lock _downloadLock;
|
final Lock _downloadLock = Lock();
|
||||||
|
|
||||||
/// Called by the ConnectivityService if the connection got lost but then was regained.
|
/// Called by the ConnectivityService if the connection got lost but then was regained.
|
||||||
Future<void> onConnectivityChanged(bool regained) async {
|
Future<void> _onConnectivityChanged(ConnectivityEvent event) async {
|
||||||
if (!regained) return;
|
if (!event.regained) return;
|
||||||
|
|
||||||
await _uploadLock.synchronized(() async {
|
await _uploadLock.synchronized(() async {
|
||||||
if (_currentUploadJob != null) {
|
if (_currentUploadJob != null) {
|
||||||
_log.finest('Connectivity regained and there is still an upload job. Restarting it.');
|
_log.finest(
|
||||||
|
'Connectivity regained and there is still an upload job. Restarting it.',
|
||||||
|
);
|
||||||
unawaited(_performFileUpload(_currentUploadJob!));
|
unawaited(_performFileUpload(_currentUploadJob!));
|
||||||
} else {
|
} else {
|
||||||
if (_uploadQueue.isNotEmpty) {
|
if (_uploadQueue.isNotEmpty) {
|
||||||
_log.finest('Connectivity regained and the upload queue is not empty. Starting a new upload job.');
|
_log.finest(
|
||||||
|
'Connectivity regained and the upload queue is not empty. Starting a new upload job.',
|
||||||
|
);
|
||||||
_currentUploadJob = _uploadQueue.removeFirst();
|
_currentUploadJob = _uploadQueue.removeFirst();
|
||||||
unawaited(_performFileUpload(_currentUploadJob!));
|
unawaited(_performFileUpload(_currentUploadJob!));
|
||||||
}
|
}
|
||||||
@@ -72,11 +73,15 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
await _downloadLock.synchronized(() async {
|
await _downloadLock.synchronized(() async {
|
||||||
if (_currentDownloadJob != null) {
|
if (_currentDownloadJob != null) {
|
||||||
_log.finest('Connectivity regained and there is still a download job. Restarting it.');
|
_log.finest(
|
||||||
|
'Connectivity regained and there is still a download job. Restarting it.',
|
||||||
|
);
|
||||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||||
} else {
|
} else {
|
||||||
if (_downloadQueue.isNotEmpty) {
|
if (_downloadQueue.isNotEmpty) {
|
||||||
_log.finest('Connectivity regained and the download queue is not empty. Starting a new download job.');
|
_log.finest(
|
||||||
|
'Connectivity regained and the download queue is not empty. Starting a new download job.',
|
||||||
|
);
|
||||||
_currentDownloadJob = _downloadQueue.removeFirst();
|
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||||
}
|
}
|
||||||
@@ -103,44 +108,38 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
/// Queue the download job [job] to be performed.
|
/// Queue the download job [job] to be performed.
|
||||||
Future<void> downloadFile(FileDownloadJob job) async {
|
Future<void> downloadFile(FileDownloadJob job) async {
|
||||||
var canDownload = false;
|
|
||||||
await _uploadLock.synchronized(() async {
|
await _uploadLock.synchronized(() async {
|
||||||
if (_currentDownloadJob != null) {
|
if (_currentDownloadJob != null) {
|
||||||
|
_log.finest('Queuing up download task.');
|
||||||
_downloadQueue.add(job);
|
_downloadQueue.add(job);
|
||||||
} else {
|
} else {
|
||||||
|
_log.finest('Executing download task.');
|
||||||
_currentDownloadJob = job;
|
_currentDownloadJob = job;
|
||||||
canDownload = true;
|
|
||||||
|
unawaited(_performFileDownload(job));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (canDownload) {
|
|
||||||
unawaited(_performFileDownload(job));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _copyFile(FileUploadJob job) async {
|
Future<void> _copyFile(
|
||||||
for (final recipient in job.recipients) {
|
FileUploadJob job,
|
||||||
final newPath = await getDownloadPath(
|
String to,
|
||||||
pathlib.basename(job.path),
|
) async {
|
||||||
recipient,
|
if (!File(to).existsSync()) {
|
||||||
job.mime,
|
await File(job.path).copy(to);
|
||||||
);
|
|
||||||
|
|
||||||
await File(job.path).copy(newPath);
|
|
||||||
|
|
||||||
// Let the media scanner index the file
|
// Let the media scanner index the file
|
||||||
MoxplatformPlugin.media.scanFile(newPath);
|
MoxplatformPlugin.media.scanFile(to);
|
||||||
|
} else {
|
||||||
// Update the message
|
_log.finest(
|
||||||
await GetIt.I.get<MessageService>().updateMessage(
|
'Skipping file copy on upload as file is already at media location',
|
||||||
job.messageMap[recipient]!.id,
|
|
||||||
mediaUrl: newPath,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
||||||
final ms = GetIt.I.get<MessageService>();
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
|
||||||
// Notify UI of upload failure
|
// Notify UI of upload failure
|
||||||
for (final recipient in job.recipients) {
|
for (final recipient in job.recipients) {
|
||||||
@@ -150,6 +149,19 @@ class HttpFileTransferService {
|
|||||||
isUploading: false,
|
isUploading: false,
|
||||||
);
|
);
|
||||||
sendEvent(MessageUpdatedEvent(message: msg));
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
|
// Update the conversation list
|
||||||
|
final conversation = await cs.getConversationByJid(recipient);
|
||||||
|
if (conversation?.lastMessage?.id == msg.id) {
|
||||||
|
final newConversation = conversation!.copyWith(
|
||||||
|
lastMessage: msg,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the cache
|
||||||
|
cs.setConversation(newConversation);
|
||||||
|
|
||||||
|
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _pickNextUploadTask();
|
await _pickNextUploadTask();
|
||||||
@@ -172,10 +184,10 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
encryption = await GetIt.I.get<CryptographyService>().encryptFile(
|
encryption = await GetIt.I.get<CryptographyService>().encryptFile(
|
||||||
job.path,
|
job.path,
|
||||||
path,
|
path,
|
||||||
SFSEncryptionType.aes256GcmNoPadding,
|
SFSEncryptionType.aes256GcmNoPadding,
|
||||||
);
|
);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
_log.warning('Encrypting ${job.path} failed: $ex');
|
_log.warning('Encrypting ${job.path} failed: $ex');
|
||||||
await _fileUploadFailed(job, messageFailedToEncryptFile);
|
await _fileUploadFailed(job, messageFailedToEncryptFile);
|
||||||
@@ -184,12 +196,12 @@ class HttpFileTransferService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
final data = await file.readAsBytes();
|
|
||||||
final stat = file.statSync();
|
final stat = file.statSync();
|
||||||
|
|
||||||
// Request the upload slot
|
// Request the upload slot
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<XmppConnection>();
|
||||||
final httpManager = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
final httpManager =
|
||||||
|
conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
||||||
final slotResult = await httpManager.requestUploadSlot(
|
final slotResult = await httpManager.requestUploadSlot(
|
||||||
pathlib.basename(path),
|
pathlib.basename(path),
|
||||||
stat.size,
|
stat.size,
|
||||||
@@ -201,120 +213,164 @@ class HttpFileTransferService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
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>();
|
final uploadStatusCode = await client.uploadFile(
|
||||||
if (response.statusCode != 201) {
|
Uri.parse(slot.putUrl),
|
||||||
// TODO(PapaTutuWawa): Trigger event
|
slot.headers,
|
||||||
_log.severe('Upload failed');
|
path,
|
||||||
await _fileUploadFailed(job, fileUploadFailedError);
|
(total, current) {
|
||||||
return;
|
// TODO(PapaTutuWawa): Make this smarter by also checking if one of those chats
|
||||||
} else {
|
// is open.
|
||||||
_log.fine('Upload was successful');
|
if (job.recipients.length == 1) {
|
||||||
|
final progress = current.toDouble() / total.toDouble();
|
||||||
const uuid = Uuid();
|
sendEvent(
|
||||||
for (final recipient in job.recipients) {
|
ProgressEvent(
|
||||||
// Notify UI of upload completion
|
id: job.messageMap.values.first.id,
|
||||||
var msg = await ms.updateMessage(
|
progress: progress == 1 ? 0.99 : progress,
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
} on dio.DioError {
|
);
|
||||||
_log.finest('Upload failed due to connection error');
|
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
if (!isRequestOkay(uploadStatusCode)) {
|
||||||
|
_log.severe('Upload failed due to status code $uploadStatusCode');
|
||||||
await _fileUploadFailed(job, fileUploadFailedError);
|
await _fileUploadFailed(job, fileUploadFailedError);
|
||||||
return;
|
return;
|
||||||
|
} else {
|
||||||
|
_log.fine('Upload was successful');
|
||||||
|
|
||||||
|
// Get hashes
|
||||||
|
StatelessFileSharingSource source;
|
||||||
|
final plaintextHashes = <HashFunction, String>{};
|
||||||
|
Map<HashFunction, String>? ciphertextHashes;
|
||||||
|
if (encryption != null) {
|
||||||
|
source = StatelessFileSharingEncryptedSource(
|
||||||
|
SFSEncryptionType.aes256GcmNoPadding,
|
||||||
|
encryption.key,
|
||||||
|
encryption.iv,
|
||||||
|
encryption.ciphertextHashes,
|
||||||
|
StatelessFileSharingUrlSource(slot.getUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||||
|
ciphertextHashes = encryption.ciphertextHashes;
|
||||||
|
} else {
|
||||||
|
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||||
|
try {
|
||||||
|
plaintextHashes[HashFunction.sha256] = await GetIt.I
|
||||||
|
.get<CryptographyService>()
|
||||||
|
.hashFile(job.path, HashFunction.sha256);
|
||||||
|
} catch (ex) {
|
||||||
|
_log.warning('Failed to hash file ${job.path} using SHA-256: $ex');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the metadata
|
||||||
|
final filename = pathlib.basename(job.path);
|
||||||
|
final filePath = await computeCachedPathForFile(
|
||||||
|
filename,
|
||||||
|
plaintextHashes,
|
||||||
|
);
|
||||||
|
final metadataWrapper =
|
||||||
|
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||||
|
MediaFileLocation(
|
||||||
|
[slot.getUrl],
|
||||||
|
filename,
|
||||||
|
encryption != null
|
||||||
|
? SFSEncryptionType.aes256GcmNoPadding.toNamespace()
|
||||||
|
: null,
|
||||||
|
encryption?.key,
|
||||||
|
encryption?.iv,
|
||||||
|
plaintextHashes,
|
||||||
|
ciphertextHashes,
|
||||||
|
stat.size,
|
||||||
|
),
|
||||||
|
job.mime,
|
||||||
|
stat.size,
|
||||||
|
null,
|
||||||
|
// TODO(Unknown): job.thumbnails.first
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
path: filePath,
|
||||||
|
);
|
||||||
|
var metadata = metadataWrapper.fileMetadata;
|
||||||
|
|
||||||
|
// Remove the tempoary metadata if we already know the file
|
||||||
|
if (metadataWrapper.retrieved) {
|
||||||
|
// Only skip the copy if the existing file metadata has a path associated with it
|
||||||
|
if (metadataWrapper.fileMetadata.path != null) {
|
||||||
|
_log.fine(
|
||||||
|
'Uploaded file $filename is already tracked. Skipping copy.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.fine(
|
||||||
|
'Uploaded file $filename is already tracked but has no path. Copying...',
|
||||||
|
);
|
||||||
|
await _copyFile(job, filePath);
|
||||||
|
metadata = await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||||
|
metadata.id,
|
||||||
|
path: filePath,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_log.fine('Uploaded file $filename not tracked. Copying...');
|
||||||
|
await _copyFile(job, metadataWrapper.fileMetadata.path!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uuid = Uuid();
|
||||||
|
for (final recipient in job.recipients) {
|
||||||
|
// Notify UI of upload completion
|
||||||
|
var msg = await ms.updateMessage(
|
||||||
|
job.messageMap[recipient]!.id,
|
||||||
|
errorType: noError,
|
||||||
|
isUploading: false,
|
||||||
|
fileMetadata: metadata,
|
||||||
|
);
|
||||||
|
// 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));
|
||||||
|
|
||||||
|
// 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: filename,
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the old metadata only here because we would otherwise violate a foreign key
|
||||||
|
// constraint.
|
||||||
|
if (metadataWrapper.retrieved) {
|
||||||
|
await GetIt.I.get<FilesService>().removeFileMetadata(
|
||||||
|
job.metadataId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _pickNextUploadTask();
|
await _pickNextUploadTask();
|
||||||
@@ -322,7 +378,8 @@ class HttpFileTransferService {
|
|||||||
|
|
||||||
Future<void> _pickNextUploadTask() async {
|
Future<void> _pickNextUploadTask() async {
|
||||||
// Free the upload resources for the next one
|
// Free the upload resources for the next one
|
||||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
if (GetIt.I.get<ConnectivityService>().currentState ==
|
||||||
|
ConnectivityResult.none) return;
|
||||||
await _uploadLock.synchronized(() async {
|
await _uploadLock.synchronized(() async {
|
||||||
if (_uploadQueue.isNotEmpty) {
|
if (_uploadQueue.isNotEmpty) {
|
||||||
_currentUploadJob = _uploadQueue.removeFirst();
|
_currentUploadJob = _uploadQueue.removeFirst();
|
||||||
@@ -350,8 +407,10 @@ class HttpFileTransferService {
|
|||||||
/// Actually attempt to download the file described by the job [job].
|
/// Actually attempt to download the file described by the job [job].
|
||||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||||
final filename = job.location.filename;
|
final filename = job.location.filename;
|
||||||
_log.finest('Downloading ${job.location.url} as $filename');
|
final downloadedPath = await computeCachedPathForFile(
|
||||||
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
job.location.filename,
|
||||||
|
job.location.plaintextHashes,
|
||||||
|
);
|
||||||
|
|
||||||
var downloadPath = downloadedPath;
|
var downloadPath = downloadedPath;
|
||||||
if (job.location.key != null && job.location.iv != null) {
|
if (job.location.key != null && job.location.iv != null) {
|
||||||
@@ -360,13 +419,21 @@ class HttpFileTransferService {
|
|||||||
downloadPath = pathlib.join(tempDir.path, filename);
|
downloadPath = pathlib.join(tempDir.path, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
dio.Response<dynamic>? response;
|
// TODO(Unknown): Maybe try other URLs?
|
||||||
|
final downloadUrl = job.location.urls.first;
|
||||||
|
_log.finest(
|
||||||
|
'Downloading $downloadUrl as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)',
|
||||||
|
);
|
||||||
|
|
||||||
|
int? downloadStatusCode;
|
||||||
|
var integrityCheckPassed = true;
|
||||||
try {
|
try {
|
||||||
response = await dio.Dio().downloadUri(
|
_log.finest('Beginning download...');
|
||||||
Uri.parse(job.location.url),
|
downloadStatusCode = await client.downloadFile(
|
||||||
|
Uri.parse(downloadUrl),
|
||||||
downloadPath,
|
downloadPath,
|
||||||
onReceiveProgress: (count, total) {
|
(total, current) {
|
||||||
final progress = count.toDouble() / total.toDouble();
|
final progress = current.toDouble() / total.toDouble();
|
||||||
sendEvent(
|
sendEvent(
|
||||||
ProgressEvent(
|
ProgressEvent(
|
||||||
id: job.mId,
|
id: job.mId,
|
||||||
@@ -375,141 +442,192 @@ class HttpFileTransferService {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
} on dio.DioError catch(err) {
|
_log.finest('Download done...');
|
||||||
// TODO(PapaTutuWawa): React if we received an error that is not related to the
|
} catch (err) {
|
||||||
// connection.
|
|
||||||
_log.finest('Failed to download: $err');
|
_log.finest('Failed to download: $err');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRequestOkay(downloadStatusCode)) {
|
||||||
|
_log.warning(
|
||||||
|
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
||||||
|
);
|
||||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRequestOkay(response.statusCode)) {
|
final decryptionKeysAvailable =
|
||||||
_log.warning('HTTP GET of ${job.location.url} returned ${response.statusCode}');
|
job.location.key != null && job.location.iv != null;
|
||||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
final crypto = GetIt.I.get<CryptographyService>();
|
||||||
return;
|
if (decryptionKeysAvailable) {
|
||||||
} else {
|
// The file was downloaded and is now being decrypted
|
||||||
var integrityCheckPassed = true;
|
sendEvent(
|
||||||
final conv = (await GetIt.I.get<ConversationService>()
|
ProgressEvent(
|
||||||
.getConversationByJid(job.conversationJid))!;
|
id: job.mId,
|
||||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
),
|
||||||
if (decryptionKeysAvailable) {
|
);
|
||||||
// The file was downloaded and is now being decrypted
|
|
||||||
sendEvent(
|
try {
|
||||||
ProgressEvent(
|
final result = await crypto.decryptFile(
|
||||||
id: job.mId,
|
downloadPath,
|
||||||
),
|
downloadedPath,
|
||||||
|
SFSEncryptionType.fromNamespace(job.location.encryptionScheme!),
|
||||||
|
job.location.key!,
|
||||||
|
job.location.iv!,
|
||||||
|
job.location.plaintextHashes ?? {},
|
||||||
|
job.location.ciphertextHashes ?? {},
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
if (!result.decryptionOkay) {
|
||||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
_log.warning('Failed to decrypt $downloadPath');
|
||||||
downloadPath,
|
|
||||||
downloadedPath,
|
|
||||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
|
||||||
job.location.key!,
|
|
||||||
job.location.iv!,
|
|
||||||
job.location.plaintextHashes ?? {},
|
|
||||||
job.location.ciphertextHashes ?? {},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!result.decryptionOkay) {
|
|
||||||
_log.warning('Failed to decrypt $downloadPath');
|
|
||||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
|
||||||
} catch (ex) {
|
|
||||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
|
||||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||||
return;
|
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
|
unawaited(
|
||||||
final notification = GetIt.I.get<NotificationsService>();
|
Directory(pathlib.dirname(downloadPath)).delete(recursive: true),
|
||||||
final mime = job.mimeGuess ?? lookupMimeType(downloadedPath);
|
);
|
||||||
|
} else if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
||||||
int? mediaWidth;
|
// Verify only the plaintext hash
|
||||||
int? mediaHeight;
|
// TODO(Unknown): Allow verification of other hash functions
|
||||||
if (mime != null) {
|
if (job.location.plaintextHashes![HashFunction.sha256] != null) {
|
||||||
if (mime.startsWith('image/')) {
|
final hash = await crypto.hashFile(
|
||||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
downloadPath,
|
||||||
|
HashFunction.sha256,
|
||||||
// Find out the dimensions
|
);
|
||||||
// TODO(Unknown): Restrict to the library's supported file types
|
integrityCheckPassed =
|
||||||
Size? size;
|
hash == job.location.plaintextHashes![HashFunction.sha256];
|
||||||
try {
|
} else if (job.location.plaintextHashes![HashFunction.sha512] != null) {
|
||||||
size = ImageSizeGetter.getSize(FileInput(File(downloadedPath)));
|
final hash = await crypto.hashFile(
|
||||||
} catch (ex) {
|
downloadPath,
|
||||||
_log.warning('Failed to get image size for $downloadedPath: $ex');
|
HashFunction.sha512,
|
||||||
}
|
);
|
||||||
|
integrityCheckPassed =
|
||||||
mediaWidth = size?.width;
|
hash == job.location.plaintextHashes![HashFunction.sha512];
|
||||||
mediaHeight = size?.height;
|
} else {
|
||||||
} else if (mime.startsWith('video/')) {
|
_log.warning(
|
||||||
// TODO(Unknown): Also figure out the thumbnail size here
|
'Could not verify file integrity as no accelerated hash function is available (${job.location.plaintextHashes!.keys})',
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 fs = GetIt.I.get<FilesService>();
|
||||||
|
final metadata = await fs.updateFileMetadata(
|
||||||
|
job.metadataId,
|
||||||
|
path: downloadedPath,
|
||||||
|
size: File(downloadedPath).lengthSync(),
|
||||||
|
width: mediaWidth,
|
||||||
|
height: mediaHeight,
|
||||||
|
mimeType: mime,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only add the hash pointers if the file hashes match what was sent
|
||||||
|
if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
||||||
|
if (integrityCheckPassed) {
|
||||||
|
await fs.createMetadataHashEntries(
|
||||||
|
job.location.plaintextHashes!,
|
||||||
|
job.metadataId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_log.warning('Integrity check failed for file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
|
final conversation = (await cs.getConversationByJid(job.conversationJid))!;
|
||||||
|
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||||
|
job.mId,
|
||||||
|
fileMetadata: metadata,
|
||||||
|
isFileUploadNotification: false,
|
||||||
|
warningType:
|
||||||
|
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
||||||
|
errorType: conversation.encrypted && !decryptionKeysAvailable
|
||||||
|
? messageChatEncryptedButFileNot
|
||||||
|
: null,
|
||||||
|
isDownloading: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
sendEvent(MessageUpdatedEvent(message: msg));
|
||||||
|
|
||||||
|
final updatedConversation = conversation.copyWith(
|
||||||
|
lastMessage: conversation.lastMessage?.id == job.mId
|
||||||
|
? msg
|
||||||
|
: conversation.lastMessage,
|
||||||
|
);
|
||||||
|
cs.setConversation(updatedConversation);
|
||||||
|
|
||||||
|
// Show a notification
|
||||||
|
if (notification.shouldShowNotification(msg.conversationJid) &&
|
||||||
|
job.shouldShowNotification) {
|
||||||
|
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||||
|
await notification.showNotification(updatedConversation, msg, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
|
||||||
|
|
||||||
// Free the download resources for the next one
|
// Free the download resources for the next one
|
||||||
await _pickNextDownloadTask();
|
await _pickNextDownloadTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickNextDownloadTask() async {
|
Future<void> _pickNextDownloadTask() async {
|
||||||
if (GetIt.I.get<ConnectivityService>().currentState == ConnectivityResult.none) return;
|
|
||||||
|
|
||||||
await _downloadLock.synchronized(() async {
|
await _downloadLock.synchronized(() async {
|
||||||
if (_downloadQueue.isNotEmpty) {
|
if (_downloadQueue.isNotEmpty) {
|
||||||
_currentDownloadJob = _downloadQueue.removeFirst();
|
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
|
||||||
|
// Only download if we have a connection
|
||||||
|
if (GetIt.I.get<ConnectivityService>().currentState !=
|
||||||
|
ConnectivityResult.none) {
|
||||||
|
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
_currentDownloadJob = null;
|
_currentDownloadJob = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,15 @@ import 'package:moxxyv2/shared/models/message.dart';
|
|||||||
/// A job describing the download of a file.
|
/// A job describing the download of a file.
|
||||||
@immutable
|
@immutable
|
||||||
class FileUploadJob {
|
class FileUploadJob {
|
||||||
const FileUploadJob(this.recipients, this.path, this.mime, this.encryptMap, this.messageMap, this.thumbnails);
|
const FileUploadJob(
|
||||||
|
this.recipients,
|
||||||
|
this.path,
|
||||||
|
this.mime,
|
||||||
|
this.encryptMap,
|
||||||
|
this.messageMap,
|
||||||
|
this.metadataId,
|
||||||
|
this.thumbnails,
|
||||||
|
);
|
||||||
final List<String> recipients;
|
final List<String> recipients;
|
||||||
final String path;
|
final String path;
|
||||||
final String? mime;
|
final String? mime;
|
||||||
@@ -14,21 +22,30 @@ class FileUploadJob {
|
|||||||
final Map<String, bool> encryptMap;
|
final Map<String, bool> encryptMap;
|
||||||
// Recipient -> Message
|
// Recipient -> Message
|
||||||
final Map<String, Message> messageMap;
|
final Map<String, Message> messageMap;
|
||||||
|
final String metadataId;
|
||||||
final List<Thumbnail> thumbnails;
|
final List<Thumbnail> thumbnails;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return other is FileUploadJob &&
|
return other is FileUploadJob &&
|
||||||
recipients == other.recipients &&
|
recipients == other.recipients &&
|
||||||
path == other.path &&
|
path == other.path &&
|
||||||
messageMap == other.messageMap &&
|
messageMap == other.messageMap &&
|
||||||
mime == other.mime &&
|
mime == other.mime &&
|
||||||
thumbnails == other.thumbnails &&
|
thumbnails == other.thumbnails &&
|
||||||
encryptMap == other.encryptMap;
|
encryptMap == other.encryptMap &&
|
||||||
|
metadataId == other.metadataId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => path.hashCode ^ recipients.hashCode ^ messageMap.hashCode ^ mime.hashCode ^ thumbnails.hashCode ^ encryptMap.hashCode;
|
int get hashCode =>
|
||||||
|
path.hashCode ^
|
||||||
|
recipients.hashCode ^
|
||||||
|
messageMap.hashCode ^
|
||||||
|
mime.hashCode ^
|
||||||
|
thumbnails.hashCode ^
|
||||||
|
encryptMap.hashCode ^
|
||||||
|
metadataId.hashCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A job describing the upload of a file.
|
/// A job describing the upload of a file.
|
||||||
@@ -37,12 +54,14 @@ class FileDownloadJob {
|
|||||||
const FileDownloadJob(
|
const FileDownloadJob(
|
||||||
this.location,
|
this.location,
|
||||||
this.mId,
|
this.mId,
|
||||||
|
this.metadataId,
|
||||||
this.conversationJid,
|
this.conversationJid,
|
||||||
this.mimeGuess, {
|
this.mimeGuess, {
|
||||||
this.shouldShowNotification = true,
|
this.shouldShowNotification = true,
|
||||||
});
|
});
|
||||||
final MediaFileLocation location;
|
final MediaFileLocation location;
|
||||||
final int mId;
|
final int mId;
|
||||||
|
final String metadataId;
|
||||||
final String conversationJid;
|
final String conversationJid;
|
||||||
final String? mimeGuess;
|
final String? mimeGuess;
|
||||||
final bool shouldShowNotification;
|
final bool shouldShowNotification;
|
||||||
@@ -50,13 +69,20 @@ class FileDownloadJob {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) {
|
bool operator ==(Object other) {
|
||||||
return other is FileDownloadJob &&
|
return other is FileDownloadJob &&
|
||||||
location == other.location &&
|
location == other.location &&
|
||||||
mId == other.mId &&
|
mId == other.mId &&
|
||||||
conversationJid == other.conversationJid &&
|
metadataId == other.metadataId &&
|
||||||
mimeGuess == other.mimeGuess &&
|
conversationJid == other.conversationJid &&
|
||||||
shouldShowNotification == other.shouldShowNotification;
|
mimeGuess == other.mimeGuess &&
|
||||||
|
shouldShowNotification == other.shouldShowNotification;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => location.hashCode ^ mId.hashCode ^ conversationJid.hashCode ^ mimeGuess.hashCode ^ shouldShowNotification.hashCode;
|
int get hashCode =>
|
||||||
|
location.hashCode ^
|
||||||
|
mId.hashCode ^
|
||||||
|
metadataId.hashCode ^
|
||||||
|
conversationJid.hashCode ^
|
||||||
|
mimeGuess.hashCode ^
|
||||||
|
shouldShowNotification.hashCode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:meta/meta.dart';
|
import 'package:meta/meta.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class MediaFileLocation {
|
class MediaFileLocation {
|
||||||
|
|
||||||
const MediaFileLocation(
|
const MediaFileLocation(
|
||||||
this.url,
|
this.urls,
|
||||||
this.filename,
|
this.filename,
|
||||||
this.encryptionScheme,
|
this.encryptionScheme,
|
||||||
this.key,
|
this.key,
|
||||||
this.iv,
|
this.iv,
|
||||||
this.plaintextHashes,
|
this.plaintextHashes,
|
||||||
this.ciphertextHashes,
|
this.ciphertextHashes,
|
||||||
|
this.size,
|
||||||
);
|
);
|
||||||
final String url;
|
final List<String> urls;
|
||||||
final String filename;
|
final String filename;
|
||||||
final String? encryptionScheme;
|
final String? encryptionScheme;
|
||||||
final List<int>? key;
|
final List<int>? key;
|
||||||
final List<int>? iv;
|
final List<int>? iv;
|
||||||
final Map<String, String>? plaintextHashes;
|
final Map<HashFunction, String>? plaintextHashes;
|
||||||
final Map<String, String>? ciphertextHashes;
|
final Map<HashFunction, String>? ciphertextHashes;
|
||||||
|
final int? size;
|
||||||
|
|
||||||
String? get keyBase64 {
|
String? get keyBase64 {
|
||||||
if (key != null) return base64Encode(key!);
|
if (key != null) return base64Encode(key!);
|
||||||
@@ -34,16 +36,24 @@ class MediaFileLocation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get hashCode => url.hashCode ^ filename.hashCode ^ encryptionScheme.hashCode ^ key.hashCode ^ iv.hashCode ^ plaintextHashes.hashCode ^ ciphertextHashes.hashCode;
|
int get hashCode =>
|
||||||
|
urls.hashCode ^
|
||||||
|
filename.hashCode ^
|
||||||
|
encryptionScheme.hashCode ^
|
||||||
|
key.hashCode ^
|
||||||
|
iv.hashCode ^
|
||||||
|
plaintextHashes.hashCode ^
|
||||||
|
ciphertextHashes.hashCode ^
|
||||||
|
size.hashCode;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator==(Object other) {
|
bool operator ==(Object other) {
|
||||||
// TODO(PapaTutuWawa): Compare the Maps
|
// TODO(PapaTutuWawa): Compare the Maps
|
||||||
return other is MediaFileLocation &&
|
return other is MediaFileLocation &&
|
||||||
url == other.url &&
|
filename == other.filename &&
|
||||||
filename == other.filename &&
|
encryptionScheme == other.encryptionScheme &&
|
||||||
encryptionScheme == other.encryptionScheme &&
|
key == other.key &&
|
||||||
key == other.key &&
|
iv == other.iv &&
|
||||||
iv == other.iv;
|
size == other.size;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,314 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
|
||||||
import 'dart:io';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/files.dart';
|
||||||
import 'package:moxxyv2/service/not_specified.dart';
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/shared/cache.dart';
|
||||||
|
import 'package:moxxyv2/shared/constants.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/media.dart';
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class MessageService {
|
class MessageService {
|
||||||
MessageService() : _messageCache = HashMap(), _log = Logger('MessageService');
|
/// Logger
|
||||||
final HashMap<String, List<Message>> _messageCache;
|
final Logger _log = Logger('MessageService');
|
||||||
final Logger _log;
|
|
||||||
|
|
||||||
/// Returns the messages for [jid], either from cache or from the database.
|
final LRUCache<String, List<Message>> _messageCache =
|
||||||
Future<List<Message>> getMessagesForJid(String jid) async {
|
LRUCache(conversationMessagePageCacheSize);
|
||||||
if (!_messageCache.containsKey(jid)) {
|
final Lock _cacheLock = Lock();
|
||||||
_messageCache[jid] = await GetIt.I.get<DatabaseService>().loadMessagesForJid(jid);
|
|
||||||
|
Future<Message?> getMessageById(
|
||||||
|
int id,
|
||||||
|
String conversationJid, {
|
||||||
|
bool queryReactionPreview = true,
|
||||||
|
}) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final messagesRaw = await db.query(
|
||||||
|
messagesTable,
|
||||||
|
where: 'id = ? AND conversationJid = ?',
|
||||||
|
whereArgs: [id, conversationJid],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messagesRaw.isEmpty) return null;
|
||||||
|
|
||||||
|
// TODO(PapaTutuWawa): Load the quoted message
|
||||||
|
final msg = messagesRaw.first;
|
||||||
|
|
||||||
|
// Load the file metadata, if available
|
||||||
|
FileMetadata? fm;
|
||||||
|
if (msg['file_metadata_id'] != null) {
|
||||||
|
final rawFm = (await db.query(
|
||||||
|
fileMetadataTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [msg['file_metadata_id']],
|
||||||
|
limit: 1,
|
||||||
|
))
|
||||||
|
.first;
|
||||||
|
fm = FileMetadata.fromDatabaseJson(rawFm);
|
||||||
}
|
}
|
||||||
|
|
||||||
final messages = _messageCache[jid];
|
return Message.fromDatabaseJson(
|
||||||
if (messages == null) {
|
msg,
|
||||||
_log.warning('No messages found for $jid. Returning [].');
|
null,
|
||||||
return [];
|
fm,
|
||||||
|
queryReactionPreview
|
||||||
|
? await GetIt.I
|
||||||
|
.get<ReactionsService>()
|
||||||
|
.getPreviewReactionsForMessage(msg['id']! as int)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Message?> getMessageByXmppId(
|
||||||
|
String id,
|
||||||
|
String conversationJid, {
|
||||||
|
bool includeOriginId = true,
|
||||||
|
bool queryReactionPreview = true,
|
||||||
|
}) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final idQuery = includeOriginId ? '(sid = ? OR originId = ?)' : 'sid = ?';
|
||||||
|
final messagesRaw = await db.query(
|
||||||
|
messagesTable,
|
||||||
|
where: 'conversationJid = ? AND $idQuery',
|
||||||
|
whereArgs: [
|
||||||
|
conversationJid,
|
||||||
|
if (includeOriginId) id,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messagesRaw.isEmpty) return null;
|
||||||
|
|
||||||
|
// TODO(PapaTutuWawa): Load the quoted message
|
||||||
|
final msg = messagesRaw.first;
|
||||||
|
|
||||||
|
FileMetadata? fm;
|
||||||
|
if (msg['file_metadata_id'] != null) {
|
||||||
|
final rawFm = (await db.query(
|
||||||
|
fileMetadataTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [msg['file_metadata_id']],
|
||||||
|
limit: 1,
|
||||||
|
))
|
||||||
|
.first;
|
||||||
|
fm = FileMetadata.fromDatabaseJson(rawFm);
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return Message.fromDatabaseJson(
|
||||||
|
msg,
|
||||||
|
null,
|
||||||
|
fm,
|
||||||
|
queryReactionPreview
|
||||||
|
? await GetIt.I
|
||||||
|
.get<ReactionsService>()
|
||||||
|
.getPreviewReactionsForMessage(msg['id']! as int)
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a list of messages for [jid]. If [olderThan] is true, then all messages are older than [oldestTimestamp], if
|
||||||
|
/// specified, or the oldest messages are returned if null. If [olderThan] is false, then message must be newer
|
||||||
|
/// than [oldestTimestamp], or the newest messages are returned if null.
|
||||||
|
Future<List<Message>> getPaginatedMessagesForJid(
|
||||||
|
String jid,
|
||||||
|
bool olderThan,
|
||||||
|
int? oldestTimestamp,
|
||||||
|
) async {
|
||||||
|
if (olderThan && oldestTimestamp == null) {
|
||||||
|
final result = await _cacheLock.synchronized<List<Message>?>(() {
|
||||||
|
return _messageCache.getValue(jid);
|
||||||
|
});
|
||||||
|
if (result != null) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final comparator = olderThan ? '<' : '>';
|
||||||
|
final query = oldestTimestamp != null
|
||||||
|
? 'conversationJid = ? AND timestamp $comparator ?'
|
||||||
|
: 'conversationJid = ?';
|
||||||
|
final rawMessages = await db.rawQuery(
|
||||||
|
// LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
msg.*,
|
||||||
|
quote.id AS quote_id,
|
||||||
|
quote.sender AS quote_sender,
|
||||||
|
quote.body AS quote_body,
|
||||||
|
quote.timestamp AS quote_timestamp,
|
||||||
|
quote.sid AS quote_sid,
|
||||||
|
quote.conversationJid AS quote_conversationJid,
|
||||||
|
quote.isFileUploadNotification AS quote_isFileUploadNotification,
|
||||||
|
quote.encrypted AS quote_encrypted,
|
||||||
|
quote.errorType AS quote_errorType,
|
||||||
|
quote.warningType AS quote_warningType,
|
||||||
|
quote.received AS quote_received,
|
||||||
|
quote.displayed AS quote_displayed,
|
||||||
|
quote.acked AS quote_acked,
|
||||||
|
quote.originId AS quote_originId,
|
||||||
|
quote.quote_id AS quote_quote_id,
|
||||||
|
quote.file_metadata_id AS quote_file_metadata_id,
|
||||||
|
quote.isDownloading AS quote_isDownloading,
|
||||||
|
quote.isUploading AS quote_isUploading,
|
||||||
|
quote.isRetracted AS quote_isRetracted,
|
||||||
|
quote.isEdited AS quote_isEdited,
|
||||||
|
quote.containsNoStore AS quote_containsNoStore,
|
||||||
|
quote.stickerPackId AS quote_stickerPackId,
|
||||||
|
quote.pseudoMessageType AS quote_pseudoMessageType,
|
||||||
|
quote.pseudoMessageData AS quote_pseudoMessageData,
|
||||||
|
fm.id as fm_id,
|
||||||
|
fm.path as fm_path,
|
||||||
|
fm.sourceUrls as fm_sourceUrls,
|
||||||
|
fm.mimeType as fm_mimeType,
|
||||||
|
fm.thumbnailType as fm_thumbnailType,
|
||||||
|
fm.thumbnailData as fm_thumbnailData,
|
||||||
|
fm.width as fm_width,
|
||||||
|
fm.height as fm_height,
|
||||||
|
fm.plaintextHashes as fm_plaintextHashes,
|
||||||
|
fm.encryptionKey as fm_encryptionKey,
|
||||||
|
fm.encryptionIv as fm_encryptionIv,
|
||||||
|
fm.encryptionScheme as fm_encryptionScheme,
|
||||||
|
fm.cipherTextHashes as fm_cipherTextHashes,
|
||||||
|
fm.filename as fm_filename,
|
||||||
|
fm.size as fm_size
|
||||||
|
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $messagePaginationSize) AS msg
|
||||||
|
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id
|
||||||
|
LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id;
|
||||||
|
''',
|
||||||
|
[
|
||||||
|
jid,
|
||||||
|
if (oldestTimestamp != null) oldestTimestamp,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = List<Message>.empty(growable: true);
|
||||||
|
for (final m in rawMessages) {
|
||||||
|
if (m.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Message? quotes;
|
||||||
|
if (m['quote_id'] != null) {
|
||||||
|
final rawQuote = getPrefixedSubMap(m, 'quote_');
|
||||||
|
|
||||||
|
FileMetadata? quoteFm;
|
||||||
|
if (rawQuote['file_metadata_id'] != null) {
|
||||||
|
final rawQuoteFm = (await db.query(
|
||||||
|
fileMetadataTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [rawQuote['file_metadata_id']],
|
||||||
|
limit: 1,
|
||||||
|
))
|
||||||
|
.first;
|
||||||
|
quoteFm = FileMetadata.fromDatabaseJson(rawQuoteFm);
|
||||||
|
}
|
||||||
|
|
||||||
|
quotes = Message.fromDatabaseJson(rawQuote, null, quoteFm, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileMetadata? fm;
|
||||||
|
if (m['file_metadata_id'] != null) {
|
||||||
|
fm = FileMetadata.fromDatabaseJson(
|
||||||
|
getPrefixedSubMap(m, 'fm_'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
page.add(
|
||||||
|
Message.fromDatabaseJson(
|
||||||
|
m,
|
||||||
|
quotes,
|
||||||
|
fm,
|
||||||
|
await GetIt.I
|
||||||
|
.get<ReactionsService>()
|
||||||
|
.getPreviewReactionsForMessage(m['id']! as int),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (olderThan && oldestTimestamp == null) {
|
||||||
|
await _cacheLock.synchronized(() {
|
||||||
|
_messageCache.cache(
|
||||||
|
jid,
|
||||||
|
page,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
|
||||||
|
/// metadata attached. This method bypasses the cache and does not load the message's
|
||||||
|
/// quoted message, if it exists.
|
||||||
|
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
|
||||||
|
String jid,
|
||||||
|
bool olderThan,
|
||||||
|
int? oldestTimestamp,
|
||||||
|
) async {
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final comparator = olderThan ? '<' : '>';
|
||||||
|
final query = oldestTimestamp != null
|
||||||
|
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||||
|
: 'conversationJid = ? AND file_metadata_id IS NOT NULL';
|
||||||
|
final rawMessages = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
msg.*,
|
||||||
|
fm.id as fm_id,
|
||||||
|
fm.path as fm_path,
|
||||||
|
fm.sourceUrls as fm_sourceUrls,
|
||||||
|
fm.mimeType as fm_mimeType,
|
||||||
|
fm.thumbnailType as fm_thumbnailType,
|
||||||
|
fm.thumbnailData as fm_thumbnailData,
|
||||||
|
fm.width as fm_width,
|
||||||
|
fm.height as fm_height,
|
||||||
|
fm.plaintextHashes as fm_plaintextHashes,
|
||||||
|
fm.encryptionKey as fm_encryptionKey,
|
||||||
|
fm.encryptionIv as fm_encryptionIv,
|
||||||
|
fm.encryptionScheme as fm_encryptionScheme,
|
||||||
|
fm.cipherTextHashes as fm_cipherTextHashes,
|
||||||
|
fm.filename as fm_filename,
|
||||||
|
fm.size as fm_size
|
||||||
|
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg
|
||||||
|
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id;
|
||||||
|
''',
|
||||||
|
[
|
||||||
|
jid,
|
||||||
|
if (oldestTimestamp != null) oldestTimestamp,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final page = List<Message>.empty(growable: true);
|
||||||
|
for (final m in rawMessages) {
|
||||||
|
if (m.isEmpty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.add(
|
||||||
|
Message.fromDatabaseJson(
|
||||||
|
m,
|
||||||
|
null,
|
||||||
|
FileMetadata.fromDatabaseJson(
|
||||||
|
getPrefixedSubMap(m, 'fm_'),
|
||||||
|
),
|
||||||
|
await GetIt.I
|
||||||
|
.get<ReactionsService>()
|
||||||
|
.getPreviewReactionsForMessage(m['id']! as int),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return page;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
|
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
|
||||||
@@ -39,161 +317,222 @@ class MessageService {
|
|||||||
int timestamp,
|
int timestamp,
|
||||||
String sender,
|
String sender,
|
||||||
String conversationJid,
|
String conversationJid,
|
||||||
bool isMedia,
|
|
||||||
String sid,
|
String sid,
|
||||||
bool isFileUploadNotification,
|
bool isFileUploadNotification,
|
||||||
bool encrypted,
|
bool encrypted,
|
||||||
{
|
bool containsNoStore, {
|
||||||
String? srcUrl,
|
String? originId,
|
||||||
String? key,
|
String? quoteId,
|
||||||
String? iv,
|
FileMetadata? fileMetadata,
|
||||||
String? encryptionScheme,
|
int? errorType,
|
||||||
String? mediaUrl,
|
int? warningType,
|
||||||
String? mediaType,
|
bool isDownloading = false,
|
||||||
String? thumbnailData,
|
bool isUploading = false,
|
||||||
int? mediaWidth,
|
String? stickerPackId,
|
||||||
int? mediaHeight,
|
int? pseudoMessageType,
|
||||||
String? originId,
|
Map<String, dynamic>? pseudoMessageData,
|
||||||
String? quoteId,
|
bool received = false,
|
||||||
String? filename,
|
bool displayed = false,
|
||||||
int? errorType,
|
}) async {
|
||||||
int? warningType,
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
Map<String, String>? plaintextHashes,
|
var m = Message(
|
||||||
Map<String, String>? ciphertextHashes,
|
sender,
|
||||||
bool isDownloading = false,
|
|
||||||
bool isUploading = false,
|
|
||||||
int? mediaSize,
|
|
||||||
}
|
|
||||||
) async {
|
|
||||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
|
||||||
body,
|
body,
|
||||||
timestamp,
|
timestamp,
|
||||||
sender,
|
|
||||||
conversationJid,
|
|
||||||
isMedia,
|
|
||||||
sid,
|
sid,
|
||||||
|
-1,
|
||||||
|
conversationJid,
|
||||||
isFileUploadNotification,
|
isFileUploadNotification,
|
||||||
encrypted,
|
encrypted,
|
||||||
srcUrl: srcUrl,
|
containsNoStore,
|
||||||
key: key,
|
|
||||||
iv: iv,
|
|
||||||
encryptionScheme: encryptionScheme,
|
|
||||||
mediaUrl: mediaUrl,
|
|
||||||
mediaType: mediaType,
|
|
||||||
thumbnailData: thumbnailData,
|
|
||||||
mediaWidth: mediaWidth,
|
|
||||||
mediaHeight: mediaHeight,
|
|
||||||
originId: originId,
|
|
||||||
quoteId: quoteId,
|
|
||||||
filename: filename,
|
|
||||||
errorType: errorType,
|
errorType: errorType,
|
||||||
warningType: warningType,
|
warningType: warningType,
|
||||||
plaintextHashes: plaintextHashes,
|
fileMetadata: fileMetadata,
|
||||||
ciphertextHashes: ciphertextHashes,
|
received: received,
|
||||||
|
displayed: displayed,
|
||||||
|
acked: false,
|
||||||
|
originId: originId,
|
||||||
isUploading: isUploading,
|
isUploading: isUploading,
|
||||||
isDownloading: isDownloading,
|
isDownloading: isDownloading,
|
||||||
mediaSize: mediaSize,
|
stickerPackId: stickerPackId,
|
||||||
|
pseudoMessageType: pseudoMessageType,
|
||||||
|
pseudoMessageData: pseudoMessageData,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Only update the cache if the conversation already has been loaded. This prevents
|
if (quoteId != null) {
|
||||||
// us from accidentally not loading the conversation afterwards.
|
final quotes = await getMessageByXmppId(quoteId, conversationJid);
|
||||||
if (_messageCache.containsKey(conversationJid)) {
|
if (quotes == null) {
|
||||||
_messageCache[conversationJid] = _messageCache[conversationJid]!..add(msg);
|
_log.warning('Failed to add quote for message with id $quoteId');
|
||||||
|
} else {
|
||||||
|
m = m.copyWith(quotes: quotes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return msg;
|
m = m.copyWith(
|
||||||
|
id: await db.insert(messagesTable, m.toDatabaseJson()),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _cacheLock.synchronized(() {
|
||||||
|
final cachedList = _messageCache.getValue(conversationJid);
|
||||||
|
if (cachedList != null) {
|
||||||
|
_messageCache.replaceValue(
|
||||||
|
conversationJid,
|
||||||
|
clampedListPrepend(
|
||||||
|
cachedList,
|
||||||
|
m,
|
||||||
|
messagePaginationSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return m;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Message?> getMessageByStanzaId(String conversationJid, String stanzaId) async {
|
Future<Message?> getMessageByStanzaId(
|
||||||
if (!_messageCache.containsKey(conversationJid)) {
|
String conversationJid,
|
||||||
await getMessagesForJid(conversationJid);
|
String stanzaId,
|
||||||
}
|
) async {
|
||||||
|
return getMessageByXmppId(
|
||||||
return firstWhereOrNull(
|
stanzaId,
|
||||||
_messageCache[conversationJid]!,
|
conversationJid,
|
||||||
(message) => message.sid == stanzaId,
|
includeOriginId: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Message?> getMessageById(String conversationJid, int id) async {
|
Future<Message?> getMessageByStanzaOrOriginId(
|
||||||
if (!_messageCache.containsKey(conversationJid)) {
|
String conversationJid,
|
||||||
await getMessagesForJid(conversationJid);
|
String id,
|
||||||
}
|
) async {
|
||||||
|
return getMessageByXmppId(
|
||||||
return firstWhereOrNull(
|
id,
|
||||||
_messageCache[conversationJid]!,
|
conversationJid,
|
||||||
(message) => message.id == id,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
||||||
Future<Message> updateMessage(int id, {
|
Future<Message> updateMessage(
|
||||||
|
int id, {
|
||||||
Object? body = notSpecified,
|
Object? body = notSpecified,
|
||||||
Object? mediaUrl = notSpecified,
|
|
||||||
Object? mediaType = notSpecified,
|
|
||||||
bool? isMedia,
|
|
||||||
bool? received,
|
bool? received,
|
||||||
bool? displayed,
|
bool? displayed,
|
||||||
bool? acked,
|
bool? acked,
|
||||||
|
Object? fileMetadata = notSpecified,
|
||||||
Object? errorType = notSpecified,
|
Object? errorType = notSpecified,
|
||||||
Object? warningType = notSpecified,
|
Object? warningType = notSpecified,
|
||||||
bool? isFileUploadNotification,
|
bool? isFileUploadNotification,
|
||||||
Object? srcUrl = notSpecified,
|
|
||||||
Object? key = notSpecified,
|
|
||||||
Object? iv = notSpecified,
|
|
||||||
Object? encryptionScheme = notSpecified,
|
|
||||||
Object? mediaWidth = notSpecified,
|
|
||||||
Object? mediaHeight = notSpecified,
|
|
||||||
Object? mediaSize = notSpecified,
|
|
||||||
bool? isUploading,
|
bool? isUploading,
|
||||||
bool? isDownloading,
|
bool? isDownloading,
|
||||||
Object? originId = notSpecified,
|
Object? originId = notSpecified,
|
||||||
Object? sid = notSpecified,
|
Object? sid = notSpecified,
|
||||||
Object? thumbnailData = notSpecified,
|
|
||||||
bool? isRetracted,
|
bool? isRetracted,
|
||||||
|
bool? isEdited,
|
||||||
}) async {
|
}) async {
|
||||||
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
id,
|
final m = <String, dynamic>{};
|
||||||
body: body,
|
|
||||||
mediaUrl: mediaUrl,
|
|
||||||
mediaType: mediaType,
|
|
||||||
received: received,
|
|
||||||
displayed: displayed,
|
|
||||||
acked: acked,
|
|
||||||
errorType: errorType,
|
|
||||||
warningType: warningType,
|
|
||||||
isFileUploadNotification: isFileUploadNotification,
|
|
||||||
srcUrl: srcUrl,
|
|
||||||
key: key,
|
|
||||||
iv: iv,
|
|
||||||
encryptionScheme: encryptionScheme,
|
|
||||||
mediaWidth: mediaWidth,
|
|
||||||
mediaHeight: mediaHeight,
|
|
||||||
mediaSize: mediaSize,
|
|
||||||
isUploading: isUploading,
|
|
||||||
isDownloading: isDownloading,
|
|
||||||
originId: originId,
|
|
||||||
sid: sid,
|
|
||||||
isRetracted: isRetracted,
|
|
||||||
isMedia: isMedia,
|
|
||||||
thumbnailData: thumbnailData,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
if (body != notSpecified) {
|
||||||
_messageCache[newMessage.conversationJid] = _messageCache[newMessage.conversationJid]!.map((m) {
|
m['body'] = body as String?;
|
||||||
if (m.id == newMessage.id) return newMessage;
|
}
|
||||||
|
if (received != null) {
|
||||||
return m;
|
m['received'] = boolToInt(received);
|
||||||
}).toList();
|
}
|
||||||
|
if (displayed != null) {
|
||||||
|
m['displayed'] = boolToInt(displayed);
|
||||||
|
}
|
||||||
|
if (acked != null) {
|
||||||
|
m['acked'] = boolToInt(acked);
|
||||||
|
}
|
||||||
|
if (errorType != notSpecified) {
|
||||||
|
m['errorType'] = errorType as int?;
|
||||||
|
}
|
||||||
|
if (warningType != notSpecified) {
|
||||||
|
m['warningType'] = warningType as int?;
|
||||||
|
}
|
||||||
|
if (isFileUploadNotification != null) {
|
||||||
|
m['isFileUploadNotification'] = boolToInt(isFileUploadNotification);
|
||||||
|
}
|
||||||
|
if (isDownloading != null) {
|
||||||
|
m['isDownloading'] = boolToInt(isDownloading);
|
||||||
|
}
|
||||||
|
if (isUploading != null) {
|
||||||
|
m['isUploading'] = boolToInt(isUploading);
|
||||||
|
}
|
||||||
|
if (sid != notSpecified) {
|
||||||
|
m['sid'] = sid as String?;
|
||||||
|
}
|
||||||
|
if (originId != notSpecified) {
|
||||||
|
m['originId'] = originId as String?;
|
||||||
|
}
|
||||||
|
if (isRetracted != null) {
|
||||||
|
m['isRetracted'] = boolToInt(isRetracted);
|
||||||
|
}
|
||||||
|
if (fileMetadata != notSpecified) {
|
||||||
|
m['file_metadata_id'] = (fileMetadata as FileMetadata?)?.id;
|
||||||
|
}
|
||||||
|
if (isEdited != null) {
|
||||||
|
m['isEdited'] = boolToInt(isEdited);
|
||||||
}
|
}
|
||||||
|
|
||||||
return newMessage;
|
final updatedMessage = await db.updateAndReturn(
|
||||||
|
messagesTable,
|
||||||
|
m,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
Message? quotes;
|
||||||
|
if (updatedMessage['quote_id'] != null) {
|
||||||
|
quotes = await getMessageById(
|
||||||
|
updatedMessage['quote_id']! as int,
|
||||||
|
updatedMessage['conversationJid']! as String,
|
||||||
|
queryReactionPreview: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileMetadata? metadata;
|
||||||
|
if (fileMetadata != notSpecified) {
|
||||||
|
metadata = fileMetadata as FileMetadata?;
|
||||||
|
} else if (updatedMessage['file_metadata_id'] != null) {
|
||||||
|
final metadataRaw = (await db.query(
|
||||||
|
fileMetadataTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [updatedMessage['file_metadata_id']],
|
||||||
|
limit: 1,
|
||||||
|
))
|
||||||
|
.first;
|
||||||
|
metadata = FileMetadata.fromDatabaseJson(metadataRaw);
|
||||||
|
}
|
||||||
|
|
||||||
|
final msg = Message.fromDatabaseJson(
|
||||||
|
updatedMessage,
|
||||||
|
quotes,
|
||||||
|
metadata,
|
||||||
|
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(id),
|
||||||
|
);
|
||||||
|
|
||||||
|
await _cacheLock.synchronized(() {
|
||||||
|
final page = _messageCache.getValue(msg.conversationJid);
|
||||||
|
if (page != null) {
|
||||||
|
_messageCache.replaceValue(
|
||||||
|
msg.conversationJid,
|
||||||
|
page.map((m) {
|
||||||
|
if (m.id == msg.id) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper function that manages everything related to retracting a message. It
|
/// Helper function that manages everything related to retracting a message. It
|
||||||
/// - Replaces all metadata of the message with null values and marks it as retracted
|
/// - Replaces all metadata of the message with null values and marks it as retracted
|
||||||
/// - Modified the conversation, if the retracted message was the newest message
|
/// - Modified the conversation, if the retracted message was the newest message
|
||||||
/// - Remove the SharedMedium from the database, if one referenced the retracted message
|
|
||||||
/// - Update the UI
|
/// - Update the UI
|
||||||
///
|
///
|
||||||
/// [conversationJid] is the bare JID of the conversation this message belongs to.
|
/// [conversationJid] is the bare JID of the conversation this message belongs to.
|
||||||
@@ -202,83 +541,89 @@ class MessageService {
|
|||||||
/// [selfRetract] indicates whether the message retraction came from the UI. If true,
|
/// [selfRetract] indicates whether the message retraction came from the UI. If true,
|
||||||
/// then the sender check (see security considerations of XEP-0424) is skipped as
|
/// then the sender check (see security considerations of XEP-0424) is skipped as
|
||||||
/// the UI already verifies it.
|
/// the UI already verifies it.
|
||||||
Future<void> retractMessage(String conversationJid, String originId, String bareSender, bool selfRetract) async {
|
Future<void> retractMessage(
|
||||||
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId(
|
String conversationJid,
|
||||||
|
String originId,
|
||||||
|
String bareSender,
|
||||||
|
bool selfRetract,
|
||||||
|
) async {
|
||||||
|
final msg = await getMessageByXmppId(
|
||||||
originId,
|
originId,
|
||||||
conversationJid,
|
conversationJid,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (msg == null) {
|
if (msg == null) {
|
||||||
_log.finest('Got message retraction for origin Id $originId, but did not find the message');
|
_log.finest(
|
||||||
|
'Got message retraction for origin Id $originId, but did not find the message',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the retraction was sent by the original sender
|
// Check if the retraction was sent by the original sender
|
||||||
if (!selfRetract) {
|
if (!selfRetract) {
|
||||||
if (JID.fromString(msg.sender).toBare().toString() != bareSender) {
|
if (JID.fromString(msg.sender).toBare().toString() != bareSender) {
|
||||||
_log.warning('Received invalid message retraction from $bareSender but its original sender is ${msg.sender}');
|
_log.warning(
|
||||||
|
'Received invalid message retraction from $bareSender but its original sender is ${msg.sender}',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final isMedia = msg.isMedia;
|
final isMedia = msg.isMedia;
|
||||||
final mediaUrl = msg.mediaUrl;
|
|
||||||
final retractedMessage = await updateMessage(
|
final retractedMessage = await updateMessage(
|
||||||
msg.id,
|
msg.id,
|
||||||
isMedia: false,
|
|
||||||
mediaUrl: null,
|
|
||||||
mediaType: null,
|
|
||||||
warningType: null,
|
warningType: null,
|
||||||
errorType: null,
|
errorType: null,
|
||||||
srcUrl: null,
|
|
||||||
key: null,
|
|
||||||
iv: null,
|
|
||||||
encryptionScheme: null,
|
|
||||||
mediaWidth: null,
|
|
||||||
mediaHeight: null,
|
|
||||||
mediaSize: null,
|
|
||||||
isRetracted: true,
|
isRetracted: true,
|
||||||
thumbnailData: null,
|
body: '',
|
||||||
|
fileMetadata: null,
|
||||||
);
|
);
|
||||||
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
||||||
|
|
||||||
final cs = GetIt.I.get<ConversationService>();
|
final cs = GetIt.I.get<ConversationService>();
|
||||||
final conversation = await cs.getConversationByJid(conversationJid);
|
final conversation = await cs.getConversationByJid(conversationJid);
|
||||||
if (conversation != null) {
|
if (conversation != null) {
|
||||||
if (conversation.lastMessageId == msg.id) {
|
if (conversation.lastMessage?.id == msg.id) {
|
||||||
var newConversation = await cs.updateConversation(
|
final newConversation = conversation.copyWith(
|
||||||
conversation.id,
|
lastMessage: retractedMessage,
|
||||||
lastMessageBody: '',
|
|
||||||
lastMessageRetracted: true,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isMedia) {
|
cs.setConversation(newConversation);
|
||||||
await GetIt.I.get<DatabaseService>().removeSharedMediumByMessageId(msg.id);
|
|
||||||
|
|
||||||
newConversation = newConversation.copyWith(
|
|
||||||
sharedMedia: newConversation.sharedMedia.where((SharedMedium medium) {
|
|
||||||
return medium.messageId != msg.id;
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
GetIt.I.get<ConversationService>().setConversation(newConversation);
|
|
||||||
|
|
||||||
// Delete the file if we downloaded it
|
|
||||||
if (mediaUrl != null) {
|
|
||||||
final file = File(mediaUrl);
|
|
||||||
if (file.existsSync()) {
|
|
||||||
unawaited(file.delete());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendEvent(
|
sendEvent(
|
||||||
ConversationUpdatedEvent(
|
ConversationUpdatedEvent(
|
||||||
conversation: newConversation,
|
conversation: newConversation,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isMedia) {
|
||||||
|
// Remove the file
|
||||||
|
await GetIt.I.get<FilesService>().removeFileIfNotReferenced(
|
||||||
|
msg.fileMetadata!,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_log.warning('Failed to find conversation with conversationJid $conversationJid');
|
_log.warning(
|
||||||
|
'Failed to find conversation with conversationJid $conversationJid',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> replaceMessageInCache(Message message) async {
|
||||||
|
await _cacheLock.synchronized(() {
|
||||||
|
final cachedList = _messageCache.getValue(message.conversationJid);
|
||||||
|
if (cachedList != null) {
|
||||||
|
_messageCache.replaceValue(
|
||||||
|
message.conversationJid,
|
||||||
|
cachedList.map((m) {
|
||||||
|
if (m.id == message.id) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m;
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
lib/service/moxxmpp/connectivity.dart
Normal file
61
lib/service/moxxmpp/connectivity.dart
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
class MoxxyConnectivityManager extends ConnectivityManager {
|
||||||
|
MoxxyConnectivityManager() : super() {
|
||||||
|
GetIt.I.get<ConnectivityService>().stream.listen(_onConnectivityChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Logger _log = Logger('MoxxyConnectivityManager');
|
||||||
|
|
||||||
|
Completer<void>? _completer;
|
||||||
|
|
||||||
|
final Lock _completerLock = Lock();
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
await _completerLock.synchronized(() async {
|
||||||
|
final result = await GetIt.I.get<ConnectivityService>().hasConnection();
|
||||||
|
if (!result) {
|
||||||
|
_log.finest(
|
||||||
|
'No network connection at initialization: Creating completer',
|
||||||
|
);
|
||||||
|
_completer = Completer<void>();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onConnectivityChanged(ConnectivityEvent event) async {
|
||||||
|
if (event.regained) {
|
||||||
|
await _completerLock.synchronized(() {
|
||||||
|
_log.finest(
|
||||||
|
'Network regained. _completer != null: ${_completer != null}',
|
||||||
|
);
|
||||||
|
_completer?.complete();
|
||||||
|
_completer = null;
|
||||||
|
});
|
||||||
|
} else if (event.lost) {
|
||||||
|
await _completerLock.synchronized(() {
|
||||||
|
_log.finest('Network connection lost. Creating completer');
|
||||||
|
_completer ??= Completer<void>();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> hasConnection() async {
|
||||||
|
return GetIt.I.get<ConnectivityService>().hasConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> waitForConnection() async {
|
||||||
|
final c = await _completerLock.synchronized(() => _completer);
|
||||||
|
if (c != null) {
|
||||||
|
_log.finest('waitForConnection: Completer non-null. Waiting.');
|
||||||
|
await c.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import 'package:moxxmpp/moxxmpp.dart';
|
|
||||||
|
|
||||||
class MoxxyDiscoManager extends DiscoManager {
|
|
||||||
@override
|
|
||||||
List<Identity> getIdentities() => const [ Identity(category: 'client', type: 'phone', name: 'Moxxy') ];
|
|
||||||
}
|
|
||||||
@@ -4,27 +4,30 @@ import 'package:moxxyv2/service/conversation.dart';
|
|||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
|
||||||
class MoxxyOmemoManager extends OmemoManager {
|
class MoxxyOmemoManager extends BaseOmemoManager {
|
||||||
|
|
||||||
MoxxyOmemoManager() : super();
|
MoxxyOmemoManager() : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<OmemoSessionManager> getSessionManager() async {
|
Future<OmemoManager> getOmemoManager() async {
|
||||||
final os = GetIt.I.get<OmemoService>();
|
final os = GetIt.I.get<OmemoService>();
|
||||||
await os.ensureInitialized();
|
await os.ensureInitialized();
|
||||||
return os.omemoState;
|
return os.omemoManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza) async {
|
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza) async {
|
||||||
// Never encrypt stanzas that contain PubSub elements
|
// Never encrypt stanzas that contain PubSub elements
|
||||||
if (stanza.firstTag('pubsub', xmlns: pubsubXmlns) != null ||
|
if (stanza.firstTag('pubsub', xmlns: pubsubXmlns) != null ||
|
||||||
stanza.firstTag('pubsub', xmlns: pubsubOwnerXmlns) != null) {
|
stanza.firstTag('pubsub', xmlns: pubsubOwnerXmlns) != null ||
|
||||||
|
stanza.firstTagByXmlns(carbonsXmlns) != null ||
|
||||||
|
stanza.firstTagByXmlns(rosterXmlns) != null) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt when the conversation is set to use OMEMO.
|
// Encrypt when the conversation is set to use OMEMO.
|
||||||
return GetIt.I.get<ConversationService>().shouldEncryptForConversation(toJid);
|
return GetIt.I
|
||||||
|
.get<ConversationService>()
|
||||||
|
.shouldEncryptForConversation(toJid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +36,11 @@ class MoxxyBTBVTrustManager extends BlindTrustBeforeVerificationTrustManager {
|
|||||||
Map<RatchetMapKey, BTBVTrustState> trustCache,
|
Map<RatchetMapKey, BTBVTrustState> trustCache,
|
||||||
Map<RatchetMapKey, bool> enablementCache,
|
Map<RatchetMapKey, bool> enablementCache,
|
||||||
Map<String, List<int>> devices,
|
Map<String, List<int>> devices,
|
||||||
) : super(trustCache: trustCache, enablementCache: enablementCache, devices: devices);
|
) : super(
|
||||||
|
trustCache: trustCache,
|
||||||
|
enablementCache: enablementCache,
|
||||||
|
devices: devices,
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> commitState() async {
|
Future<void> commitState() async {
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:math';
|
|
||||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
|
||||||
import 'package:logging/logging.dart';
|
|
||||||
import 'package:meta/meta.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
|
||||||
import 'package:synchronized/synchronized.dart';
|
|
||||||
|
|
||||||
/// This class implements a reconnection policy that is connectivity aware with a random
|
|
||||||
/// 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(),
|
|
||||||
_log = Logger('MoxxyReconnectionPolicy'),
|
|
||||||
super();
|
|
||||||
final Logger _log;
|
|
||||||
|
|
||||||
/// The backoff timer
|
|
||||||
@visibleForTesting
|
|
||||||
Timer? timer;
|
|
||||||
final Lock _timerLock;
|
|
||||||
|
|
||||||
/// Just for testing purposes
|
|
||||||
final bool _isTesting;
|
|
||||||
|
|
||||||
/// Maximum backoff time
|
|
||||||
final int? maxBackoffTime;
|
|
||||||
|
|
||||||
/// To be called when the conectivity changes
|
|
||||||
Future<void> onConnectivityChanged(bool regained, bool lost) async {
|
|
||||||
// Do nothing if we should not reconnect
|
|
||||||
if (!shouldReconnect && regained) {
|
|
||||||
_log.finest('Connectivity changed but not attempting reconnection as shouldReconnect is false');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lost) {
|
|
||||||
// We just lost network connectivity
|
|
||||||
_log.finest('Lost network connectivity. Queueing failure...');
|
|
||||||
|
|
||||||
// Cancel the timer if it was running
|
|
||||||
await _stopTimer();
|
|
||||||
await setIsReconnecting(false);
|
|
||||||
triggerConnectionLost!();
|
|
||||||
} else if (regained && shouldReconnect) {
|
|
||||||
// We should reconnect
|
|
||||||
_log.finest('Network regained. Attempting reconnection...');
|
|
||||||
await _attemptReconnection(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> reset() async {
|
|
||||||
await _stopTimer();
|
|
||||||
await setIsReconnecting(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _stopTimer() async {
|
|
||||||
await _timerLock.synchronized(() {
|
|
||||||
if (timer != null) {
|
|
||||||
timer!.cancel();
|
|
||||||
timer = null;
|
|
||||||
_log.finest('Destroying timer');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
Future<void> onTimerElapsed() async {
|
|
||||||
await _stopTimer();
|
|
||||||
|
|
||||||
_log.finest('Performing reconnect');
|
|
||||||
await performReconnect!();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _attemptReconnection(bool immediately) async {
|
|
||||||
if (await testAndSetIsReconnecting()) {
|
|
||||||
// Attempt reconnecting
|
|
||||||
int seconds;
|
|
||||||
if (_isTesting) {
|
|
||||||
seconds = 9999;
|
|
||||||
} else {
|
|
||||||
final r = Random().nextInt(15);
|
|
||||||
if (maxBackoffTime != null) {
|
|
||||||
seconds = min(maxBackoffTime!, r);
|
|
||||||
} else {
|
|
||||||
seconds = r;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _stopTimer();
|
|
||||||
if (immediately) {
|
|
||||||
_log.finest('Immediately attempting reconnection...');
|
|
||||||
await onTimerElapsed();
|
|
||||||
} else {
|
|
||||||
_log.finest('Started backoff timer with ${seconds}s backoff');
|
|
||||||
await _timerLock.synchronized(() {
|
|
||||||
timer = Timer(Duration(seconds: seconds), onTimerElapsed);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_log.severe('_attemptReconnection called while reconnect is running!');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> onFailure() async {
|
|
||||||
final state = GetIt.I.get<ConnectivityService>().currentState;
|
|
||||||
|
|
||||||
if (state != ConnectivityResult.none) {
|
|
||||||
await _attemptReconnection(false);
|
|
||||||
} else {
|
|
||||||
_log.fine('Failure occurred while no network connection is available. Waiting for connection...');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> onSuccess() async {
|
|
||||||
await reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,104 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
|
||||||
class MoxxyRosterManager extends RosterManager {
|
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||||
@override
|
@override
|
||||||
Future<void> commitLastRosterVersion(String version) async {
|
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||||
await GetIt.I.get<XmppService>().modifyXmppState((state) => state.copyWith(
|
final rs = GetIt.I.get<RosterService>();
|
||||||
lastRosterVersion: version,
|
return RosterCacheLoadResult(
|
||||||
),);
|
(await GetIt.I.get<XmppStateService>().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
|
@override
|
||||||
Future<void> loadLastRosterVersion() async {
|
Future<void> commitRoster(
|
||||||
final ver = (await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion;
|
String? version,
|
||||||
if (ver != null) {
|
List<String> removed,
|
||||||
setRosterVersion(ver);
|
List<XmppRosterItem> modified,
|
||||||
|
List<XmppRosterItem> added,
|
||||||
|
) async {
|
||||||
|
final rs = GetIt.I.get<RosterService>();
|
||||||
|
final xss = GetIt.I.get<XmppStateService>();
|
||||||
|
await xss.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) {
|
||||||
|
final exists = await rs.getRosterItemByJid(item.jid) != null;
|
||||||
|
// Skip adding items twice
|
||||||
|
if (exists) continue;
|
||||||
|
|
||||||
|
rosterAdded.add(
|
||||||
|
await rs.addRosterItemFromData(
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
item.jid,
|
||||||
|
item.name ?? item.jid.split('@').first,
|
||||||
|
item.subscription,
|
||||||
|
item.ask ?? '',
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
groups: item.groups,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,18 +2,20 @@ import 'package:moxdns/moxdns.dart';
|
|||||||
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
|
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
|
||||||
|
|
||||||
class MoxxyTCPSocketWrapper extends TCPSocketWrapper {
|
class MoxxyTCPSocketWrapper extends TCPSocketWrapper {
|
||||||
MoxxyTCPSocketWrapper() : super(false);
|
MoxxyTCPSocketWrapper() : super();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MoxSrvRecord>> srvQuery(String domain, bool dnssec) async {
|
Future<List<MoxSrvRecord>> srvQuery(String domain, bool dnssec) async {
|
||||||
final records = await MoxdnsPlugin.srvQuery(domain, dnssec);
|
final records = await MoxdnsPlugin.srvQuery(domain, dnssec);
|
||||||
return records
|
return records
|
||||||
.map((record) => MoxSrvRecord(
|
.map(
|
||||||
record.priority,
|
(record) => MoxSrvRecord(
|
||||||
record.weight,
|
record.priority,
|
||||||
record.target,
|
record.weight,
|
||||||
record.port,
|
record.target,
|
||||||
),)
|
record.port,
|
||||||
.toList();
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,36 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
|
|
||||||
class MoxxyStreamManagementManager extends StreamManagementManager {
|
class MoxxyStreamManagementManager extends StreamManagementManager {
|
||||||
@override
|
@override
|
||||||
bool shouldTriggerAckedEvent(Stanza stanza) {
|
bool shouldTriggerAckedEvent(Stanza stanza) {
|
||||||
return stanza.tag == 'message' &&
|
return stanza.tag == 'message' &&
|
||||||
stanza.id != null && (
|
stanza.id != null &&
|
||||||
stanza.firstTag('body') != null ||
|
(stanza.firstTag('body') != null ||
|
||||||
stanza.firstTag('x', xmlns: oobDataXmlns) != null ||
|
stanza.firstTag('x', xmlns: oobDataXmlns) != null ||
|
||||||
stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null ||
|
stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null ||
|
||||||
stanza.firstTag('file-upload', xmlns: fileUploadNotificationXmlns) != null ||
|
stanza.firstTag(
|
||||||
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null
|
'file-upload',
|
||||||
);
|
xmlns: fileUploadNotificationXmlns,
|
||||||
|
) !=
|
||||||
|
null ||
|
||||||
|
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> commitState() async {
|
Future<void> commitState() async {
|
||||||
await GetIt.I.get<XmppService>().modifyXmppState((s) => s.copyWith(
|
await GetIt.I.get<XmppStateService>().modifyXmppState(
|
||||||
smState: state,
|
(s) => s.copyWith(
|
||||||
),);
|
smState: state,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> loadState() async {
|
Future<void> loadState() async {
|
||||||
final state = await GetIt.I.get<XmppService>().getXmppState();
|
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||||
if (state.smState != null) {
|
if (state.smState != null) {
|
||||||
await setState(state.smState!);
|
await setState(state.smState!);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
class _NotSpecifiedValue { const _NotSpecifiedValue(); }
|
class _NotSpecifiedValue {
|
||||||
|
const _NotSpecifiedValue();
|
||||||
|
}
|
||||||
|
|
||||||
/// A value used for indicating that a value is not specified.
|
/// A value used for indicating that a value is not specified.
|
||||||
const notSpecified = _NotSpecifiedValue();
|
const notSpecified = _NotSpecifiedValue();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||||
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/events.dart';
|
import 'package:moxxyv2/service/events.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
@@ -49,11 +50,13 @@ class NotificationsService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.warning('Received unknown notification action key ${action.buttonKeyPressed}');
|
logger.warning(
|
||||||
|
'Received unknown notification action key ${action.buttonKeyPressed}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> initialize() async {
|
||||||
final an = AwesomeNotifications();
|
final an = AwesomeNotifications();
|
||||||
await an.initialize(
|
await an.initialize(
|
||||||
'resource://drawable/ic_service_icon',
|
'resource://drawable/ic_service_icon',
|
||||||
@@ -61,12 +64,14 @@ class NotificationsService {
|
|||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
channelKey: _messageChannelKey,
|
channelKey: _messageChannelKey,
|
||||||
channelName: t.notifications.channels.messagesChannelName,
|
channelName: t.notifications.channels.messagesChannelName,
|
||||||
channelDescription: t.notifications.channels.messagesChannelDescription,
|
channelDescription:
|
||||||
|
t.notifications.channels.messagesChannelDescription,
|
||||||
),
|
),
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
channelKey: _warningChannelKey,
|
channelKey: _warningChannelKey,
|
||||||
channelName: t.notifications.channels.warningChannelName,
|
channelName: t.notifications.channels.warningChannelName,
|
||||||
channelDescription: t.notifications.channels.warningChannelDescription,
|
channelDescription:
|
||||||
|
t.notifications.channels.warningChannelDescription,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
debug: kDebugMode,
|
debug: kDebugMode,
|
||||||
@@ -84,32 +89,49 @@ class NotificationsService {
|
|||||||
/// Show a notification for a message [m] grouped by its conversationJid
|
/// Show a notification for a message [m] grouped by its conversationJid
|
||||||
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
||||||
/// then Android's BigPicture will be used.
|
/// then Android's BigPicture will be used.
|
||||||
Future<void> showNotification(modelc.Conversation c, modelm.Message m, String title, { String? body }) async {
|
Future<void> showNotification(
|
||||||
// TODO(Unknown): Keep track of notifications to create a summary notification
|
modelc.Conversation c,
|
||||||
|
modelm.Message m,
|
||||||
|
String title, {
|
||||||
|
String? body,
|
||||||
|
}) async {
|
||||||
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
||||||
final body = m.isMedia ?
|
String body;
|
||||||
mimeTypeToEmoji(m.mediaType) :
|
if (m.stickerPackId != null) {
|
||||||
m.body;
|
body = t.messages.sticker;
|
||||||
|
} else if (m.isMedia) {
|
||||||
|
body = mimeTypeToEmoji(m.fileMetadata!.mimeType);
|
||||||
|
} else {
|
||||||
|
body = m.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
|
final contactIntegrationEnabled = await css.isContactIntegrationEnabled();
|
||||||
|
final title =
|
||||||
|
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
|
||||||
|
final avatarPath = contactIntegrationEnabled
|
||||||
|
? c.contactAvatarPath ?? c.avatarUrl
|
||||||
|
: c.avatarUrl;
|
||||||
|
|
||||||
await AwesomeNotifications().createNotification(
|
await AwesomeNotifications().createNotification(
|
||||||
content: NotificationContent(
|
content: NotificationContent(
|
||||||
id: m.id,
|
id: m.id,
|
||||||
groupKey: c.jid,
|
groupKey: c.jid,
|
||||||
channelKey: _messageChannelKey,
|
channelKey: _messageChannelKey,
|
||||||
summary: c.title,
|
summary: title,
|
||||||
title: c.title,
|
title: title,
|
||||||
body: body,
|
body: body,
|
||||||
largeIcon: c.avatarUrl.isNotEmpty ? 'file://${c.avatarUrl}' : null,
|
largeIcon: avatarPath.isNotEmpty ? 'file://$avatarPath' : null,
|
||||||
notificationLayout: m.thumbnailable ?
|
notificationLayout: m.isThumbnailable
|
||||||
NotificationLayout.BigPicture :
|
? NotificationLayout.BigPicture
|
||||||
NotificationLayout.Messaging,
|
: NotificationLayout.Messaging,
|
||||||
category: NotificationCategory.Message,
|
category: NotificationCategory.Message,
|
||||||
bigPicture: m.thumbnailable ? 'file://${m.mediaUrl}' : null,
|
bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
|
||||||
payload: <String, String>{
|
payload: <String, String>{
|
||||||
'conversationJid': c.jid,
|
'conversationJid': c.jid,
|
||||||
'sid': m.sid,
|
'sid': m.sid,
|
||||||
'title': c.title,
|
'title': title,
|
||||||
'avatarUrl': c.avatarUrl,
|
'avatarUrl': avatarPath,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
actionButtons: [
|
actionButtons: [
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
|
||||||
Future<OmemoSessionManager> generateNewIdentityImpl(String jid) async {
|
Future<OmemoDevice> generateNewIdentityImpl(String jid) async {
|
||||||
return OmemoSessionManager.generateNewIdentity(
|
return OmemoDevice.generateNewDevice(jid);
|
||||||
jid,
|
|
||||||
MoxxyBTBVTrustManager(
|
|
||||||
<RatchetMapKey, BTBVTrustState>{},
|
|
||||||
<RatchetMapKey, bool>{},
|
|
||||||
<String, List<int>>{},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
|
import 'dart:convert';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:hex/hex.dart';
|
import 'package:hex/hex.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||||
import 'package:moxxyv2/shared/models/omemo_device.dart';
|
import 'package:moxxyv2/service/omemo/types.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||||
import 'package:omemo_dart/omemo_dart.dart';
|
import 'package:omemo_dart/omemo_dart.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
class OmemoDoubleRatchetWrapper {
|
class OmemoDoubleRatchetWrapper {
|
||||||
|
|
||||||
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
||||||
final OmemoDoubleRatchet ratchet;
|
final OmemoDoubleRatchet ratchet;
|
||||||
final int id;
|
final int id;
|
||||||
@@ -21,60 +30,95 @@ class OmemoDoubleRatchetWrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class OmemoService {
|
class OmemoService {
|
||||||
|
|
||||||
final Logger _log = Logger('OmemoService');
|
final Logger _log = Logger('OmemoService');
|
||||||
|
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
final Lock _lock = Lock();
|
final Lock _lock = Lock();
|
||||||
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
|
final Queue<Completer<void>> _waitingForInitialization =
|
||||||
|
Queue<Completer<void>>();
|
||||||
|
final Map<String, Map<int, String>> _fingerprintCache = {};
|
||||||
|
|
||||||
late OmemoSessionManager omemoState;
|
late OmemoManager omemoManager;
|
||||||
|
|
||||||
Future<void> initializeIfNeeded(String jid) async {
|
Future<void> initializeIfNeeded(String jid) async {
|
||||||
final done = await _lock.synchronized(() => _initialized);
|
final done = await _lock.synchronized(() => _initialized);
|
||||||
if (done) return;
|
if (done) return;
|
||||||
|
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
final device = await _loadOmemoDevice(jid);
|
||||||
final device = await db.loadOmemoDevice(jid);
|
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||||
|
final deviceList = <String, List<int>>{};
|
||||||
if (device == null) {
|
if (device == null) {
|
||||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||||
// Generate the identity in the background
|
|
||||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
|
||||||
|
|
||||||
await commitDevice(await omemoState.getDevice());
|
|
||||||
await commitDeviceMap(<String, List<int>>{});
|
|
||||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
|
||||||
} else {
|
} else {
|
||||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
for (final ratchet in await _loadRatchets()) {
|
||||||
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
|
||||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||||
ratchetMap[key] = ratchet.ratchet;
|
ratchetMap[key] = ratchet.ratchet;
|
||||||
}
|
}
|
||||||
|
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
deviceList.addAll(await _loadOmemoDeviceList());
|
||||||
omemoState = OmemoSessionManager(
|
|
||||||
device,
|
|
||||||
await db.loadOmemoDeviceList(),
|
|
||||||
ratchetMap,
|
|
||||||
await loadTrustManager(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
omemoState.eventStream.listen((event) async {
|
final om = GetIt.I
|
||||||
|
.get<moxxmpp.XmppConnection>()
|
||||||
|
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||||
|
omemoManager = OmemoManager(
|
||||||
|
device ?? await compute(generateNewIdentityImpl, jid),
|
||||||
|
await loadTrustManager(),
|
||||||
|
om.sendEmptyMessageImpl,
|
||||||
|
om.fetchDeviceList,
|
||||||
|
om.fetchDeviceBundle,
|
||||||
|
om.subscribeToDeviceListImpl,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (device == null) {
|
||||||
|
await commitDevice(await omemoManager.getDevice());
|
||||||
|
await commitDeviceMap(<String, List<int>>{});
|
||||||
|
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
omemoManager.initialize(
|
||||||
|
ratchetMap,
|
||||||
|
deviceList,
|
||||||
|
);
|
||||||
|
|
||||||
|
omemoManager.eventStream.listen((event) async {
|
||||||
if (event is RatchetModifiedEvent) {
|
if (event is RatchetModifiedEvent) {
|
||||||
await GetIt.I.get<DatabaseService>().saveRatchet(
|
await _saveRatchet(
|
||||||
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
|
OmemoDoubleRatchetWrapper(
|
||||||
|
event.ratchet,
|
||||||
|
event.deviceId,
|
||||||
|
event.jid,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else if (event is DeviceMapModifiedEvent) {
|
|
||||||
await commitDeviceMap(event.map);
|
if (event.added) {
|
||||||
|
// Cache the fingerprint
|
||||||
|
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
||||||
|
await _addFingerprintsToCache([
|
||||||
|
OmemoCacheTriple(
|
||||||
|
event.jid,
|
||||||
|
event.deviceId,
|
||||||
|
fingerprint,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (_fingerprintCache.containsKey(event.jid)) {
|
||||||
|
_fingerprintCache[event.jid]![event.deviceId] = fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
await addNewDeviceMessage(event.jid, event.deviceId);
|
||||||
|
}
|
||||||
|
} else if (event is DeviceListModifiedEvent) {
|
||||||
|
await commitDeviceMap(event.list);
|
||||||
} else if (event is DeviceModifiedEvent) {
|
} else if (event is DeviceModifiedEvent) {
|
||||||
await commitDevice(event.device);
|
await commitDevice(event.device);
|
||||||
|
|
||||||
// Publish it
|
// Publish it
|
||||||
await GetIt.I.get<XmppConnection>()
|
await GetIt.I
|
||||||
.getManagerById<OmemoManager>(omemoManager)!
|
.get<moxxmpp.XmppConnection>()
|
||||||
.publishBundle(await event.device.toBundle());
|
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||||
|
.publishBundle(await event.device.toBundle());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,32 +132,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<XmppStateService>().getXmppState();
|
||||||
|
if (jid == xmppState.jid) return;
|
||||||
|
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final message = await ms.addMessageFromData(
|
||||||
|
'',
|
||||||
|
DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'',
|
||||||
|
jid,
|
||||||
|
'',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
pseudoMessageType: pseudoMessageTypeNewDevice,
|
||||||
|
pseudoMessageData: <String, dynamic>{
|
||||||
|
'deviceId': deviceId,
|
||||||
|
'jid': jid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sendEvent(
|
||||||
|
MessageAddedEvent(
|
||||||
|
message: message,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<model.OmemoDevice> regenerateDevice(String jid) async {
|
||||||
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
|
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
|
||||||
await _lock.synchronized(() {
|
await _lock.synchronized(() {
|
||||||
_initialized = false;
|
_initialized = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||||
final oldId = await omemoState.getDeviceId();
|
final oldId = await omemoManager.getDeviceId();
|
||||||
|
|
||||||
// Clear the database
|
// Clear the database
|
||||||
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
await _emptyOmemoSessionTables();
|
||||||
|
|
||||||
// Regenerate the identity in the background
|
// Regenerate the identity in the background
|
||||||
omemoState = await compute(generateNewIdentityImpl, jid);
|
final device = await compute(generateNewIdentityImpl, jid);
|
||||||
|
await omemoManager.replaceDevice(device);
|
||||||
await commitDevice(await omemoState.getDevice());
|
await commitDevice(device);
|
||||||
await commitDeviceMap(<String, List<int>>{});
|
await commitDeviceMap(<String, List<int>>{});
|
||||||
await commitTrustManager(await omemoState.trustManager.toJson());
|
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||||
|
|
||||||
// Remove the old device
|
// Remove the old device
|
||||||
final omemo = GetIt.I.get<XmppConnection>()
|
final omemo = GetIt.I
|
||||||
.getManagerById<OmemoManager>(omemoManager)!;
|
.get<moxxmpp.XmppConnection>()
|
||||||
|
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||||
await omemo.deleteDevice(oldId);
|
await omemo.deleteDevice(oldId);
|
||||||
|
|
||||||
// Publish the new one
|
// Publish the new one
|
||||||
await omemo.publishBundle(await omemoState.getDeviceBundle());
|
await omemo.publishBundle(await omemoManager.getDeviceBundle());
|
||||||
|
|
||||||
// Allow access again
|
// Allow access again
|
||||||
await _lock.synchronized(() {
|
await _lock.synchronized(() {
|
||||||
@@ -126,7 +201,7 @@ class OmemoService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Return the OmemoDevice
|
// Return the OmemoDevice
|
||||||
return OmemoDevice(
|
return model.OmemoDevice(
|
||||||
await getDeviceFingerprint(),
|
await getDeviceFingerprint(),
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
@@ -154,11 +229,11 @@ class OmemoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
|
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
|
||||||
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
|
await _saveOmemoDeviceList(deviceMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitDevice(Device device) async {
|
Future<void> commitDevice(OmemoDevice device) async {
|
||||||
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
|
await _saveOmemoDevice(device);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Requests our device list and checks if the current device is in it. If not, then
|
/// Requests our device list and checks if the current device is in it. If not, then
|
||||||
@@ -168,55 +243,115 @@ class OmemoService {
|
|||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
_log.finest('publishDeviceIfNeeded: Done');
|
_log.finest('publishDeviceIfNeeded: Done');
|
||||||
|
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||||
final omemo = conn.getManagerById<OmemoManager>(omemoManager)!;
|
final omemo =
|
||||||
final dm = conn.getManagerById<DiscoManager>(discoManager)!;
|
conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||||
final bareJid = conn.getConnectionSettings().jid.toBare();
|
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||||
final device = await omemoState.getDevice();
|
final bareJid = conn.connectionSettings.jid.toBare();
|
||||||
|
final device = await omemoManager.getDevice();
|
||||||
|
|
||||||
final bundlesRaw = await dm.discoItemsQuery(
|
final bundlesRaw = await dm.discoItemsQuery(
|
||||||
bareJid.toString(),
|
bareJid,
|
||||||
node: omemoBundlesXmlns,
|
node: moxxmpp.omemoBundlesXmlns,
|
||||||
);
|
);
|
||||||
if (bundlesRaw.isType<DiscoError>()) {
|
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||||
await omemo.publishBundle(await device.toBundle());
|
await omemo.publishBundle(await device.toBundle());
|
||||||
return bundlesRaw.get<DiscoError>();
|
return bundlesRaw.get<moxxmpp.DiscoError>();
|
||||||
}
|
}
|
||||||
|
|
||||||
final bundleIds = bundlesRaw
|
final bundleIds = bundlesRaw
|
||||||
.get<List<DiscoItem>>()
|
.get<List<moxxmpp.DiscoItem>>()
|
||||||
.where((item) => item.name != null)
|
.where((item) => item.name != null)
|
||||||
.map((item) => int.parse(item.name!));
|
.map((item) => int.parse(item.name!));
|
||||||
if (!bundleIds.contains(device.id)) {
|
if (!bundleIds.contains(device.id)) {
|
||||||
final result = await omemo.publishBundle(await device.toBundle());
|
final result = await omemo.publishBundle(await device.toBundle());
|
||||||
if (result.isType<OmemoError>()) return result.get<OmemoError>();
|
if (result.isType<moxxmpp.OmemoError>()) {
|
||||||
|
return result.get<moxxmpp.OmemoError>();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final idsRaw = await omemo.getDeviceList(bareJid);
|
final idsRaw = await omemo.getDeviceList(bareJid);
|
||||||
final ids = idsRaw.isType<OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
final ids =
|
||||||
|
idsRaw.isType<moxxmpp.OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
||||||
if (!ids.contains(device.id)) {
|
if (!ids.contains(device.id)) {
|
||||||
final result = await omemo.publishBundle(await device.toBundle());
|
final result = await omemo.publishBundle(await device.toBundle());
|
||||||
if (result.isType<OmemoError>()) return result.get<OmemoError>();
|
if (result.isType<moxxmpp.OmemoError>()) {
|
||||||
|
return result.get<moxxmpp.OmemoError>();
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
|
||||||
|
final bareJid = jid.toBare().toString();
|
||||||
|
final allDevicesRaw = await GetIt.I
|
||||||
|
.get<moxxmpp.XmppConnection>()
|
||||||
|
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||||
|
.retrieveDeviceBundles(jid);
|
||||||
|
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
||||||
|
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
||||||
|
final map = <int, String>{};
|
||||||
|
final items = List<OmemoCacheTriple>.empty(growable: true);
|
||||||
|
for (final device in allDevices) {
|
||||||
|
final curveIk = await device.ik.toCurve25519();
|
||||||
|
final fingerprint = HEX.encode(await curveIk.getBytes());
|
||||||
|
map[device.id] = fingerprint;
|
||||||
|
items.add(OmemoCacheTriple(bareJid, device.id, fingerprint));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache them in memory
|
||||||
|
_fingerprintCache[bareJid] = map;
|
||||||
|
|
||||||
|
// Cache them in the database
|
||||||
|
await _addFingerprintsToCache(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadOrFetchFingerprints(moxxmpp.JID jid) async {
|
||||||
|
final bareJid = jid.toBare().toString();
|
||||||
|
if (!_fingerprintCache.containsKey(bareJid)) {
|
||||||
|
// First try to load it from the database
|
||||||
|
final triples = await _getFingerprintsFromCache(bareJid);
|
||||||
|
if (triples.isEmpty) {
|
||||||
|
// We found no fingerprints in the database, so try to fetch them
|
||||||
|
await _fetchFingerprintsAndCache(jid);
|
||||||
|
} else {
|
||||||
|
// We have fetched fingerprints from the database
|
||||||
|
_fingerprintCache[bareJid] = Map<int, String>.fromEntries(
|
||||||
|
triples.map((triple) {
|
||||||
|
return MapEntry<int, String>(
|
||||||
|
triple.deviceId,
|
||||||
|
triple.fingerprint,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<model.OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
final fingerprints = await omemoState.getHexFingerprintsForJid(jid);
|
|
||||||
final keys = List<OmemoDevice>.empty(growable: true);
|
// Get finger prints if we have to
|
||||||
for (final fp in fingerprints) {
|
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
|
||||||
|
|
||||||
|
final keys = List<model.OmemoDevice>.empty(growable: true);
|
||||||
|
final tm =
|
||||||
|
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||||
|
final trustMap = await tm.getDevicesTrust(jid);
|
||||||
|
|
||||||
|
if (!_fingerprintCache.containsKey(jid)) return [];
|
||||||
|
for (final deviceId in _fingerprintCache[jid]!.keys) {
|
||||||
keys.add(
|
keys.add(
|
||||||
OmemoDevice(
|
model.OmemoDevice(
|
||||||
fp.fingerprint,
|
_fingerprintCache[jid]![deviceId]!,
|
||||||
await omemoState.trustManager.isTrusted(jid, fp.deviceId),
|
await tm.isTrusted(jid, deviceId),
|
||||||
// TODO(Unknown): Allow verifying OMEMO keys
|
trustMap[deviceId] == BTBVTrustState.verified,
|
||||||
false,
|
await tm.isEnabled(jid, deviceId),
|
||||||
await omemoState.trustManager.isEnabled(jid, fp.deviceId),
|
deviceId,
|
||||||
fp.deviceId,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,81 +360,394 @@ class OmemoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||||
|
await _saveTrustCache(
|
||||||
await GetIt.I.get<DatabaseService>().saveTrustCache(
|
|
||||||
json['trust']! as Map<String, int>,
|
json['trust']! as Map<String, int>,
|
||||||
);
|
);
|
||||||
await GetIt.I.get<DatabaseService>().saveTrustEnablementList(
|
await _saveTrustEnablementList(
|
||||||
json['enable']! as Map<String, bool>,
|
json['enable']! as Map<String, bool>,
|
||||||
);
|
);
|
||||||
await GetIt.I.get<DatabaseService>().saveTrustDeviceList(
|
await _saveTrustDeviceList(
|
||||||
json['devices']! as Map<String, List<int>>,
|
json['devices']! as Map<String, List<int>>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
||||||
final db = GetIt.I.get<DatabaseService>();
|
|
||||||
return MoxxyBTBVTrustManager(
|
return MoxxyBTBVTrustManager(
|
||||||
await db.loadTrustCache(),
|
await _loadTrustCache(),
|
||||||
await db.loadTrustEnablementList(),
|
await _loadTrustEnablementList(),
|
||||||
await db.loadTrustDeviceList(),
|
await _loadTrustDeviceList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
|
Future<void> setOmemoKeyEnabled(
|
||||||
|
String jid,
|
||||||
|
int deviceId,
|
||||||
|
bool enabled,
|
||||||
|
) async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
await omemoState.trustManager.setEnabled(jid, deviceId, enabled);
|
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> removeAllSessions(String jid) async {
|
Future<void> removeAllSessions(String jid) async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
await omemoState.removeAllRatchets(jid);
|
await omemoManager.removeAllRatchets(jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<int> getDeviceId() async {
|
Future<int> getDeviceId() async {
|
||||||
await ensureInitialized();
|
await ensureInitialized();
|
||||||
return omemoState.getDeviceId();
|
return omemoManager.getDeviceId();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> getDeviceFingerprint() async {
|
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
|
||||||
return (await omemoState.getHexFingerprintForDevice()).fingerprint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
||||||
/// published on [ownJid]'s devices PubSub node.
|
/// published on [ownJid]'s devices PubSub node.
|
||||||
/// Note that the list is made so that the current device is excluded.
|
/// Note that the list is made so that the current device is excluded.
|
||||||
Future<List<OmemoDevice>> getOwnFingerprints(JID ownJid) async {
|
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
|
||||||
final conn = GetIt.I.get<XmppConnection>();
|
|
||||||
final ownId = await getDeviceId();
|
final ownId = await getDeviceId();
|
||||||
final keys = List<OmemoDevice>.from(
|
final keys = List<model.OmemoDevice>.from(
|
||||||
await getOmemoKeysForJid(ownJid.toString()),
|
await getOmemoKeysForJid(ownJid.toString()),
|
||||||
);
|
);
|
||||||
|
final bareJid = ownJid.toBare().toString();
|
||||||
|
|
||||||
// TODO(PapaTutuWawa): This should be cached in the database and only requested if
|
// Get fingerprints if we have to
|
||||||
// it's not cached.
|
await _loadOrFetchFingerprints(ownJid);
|
||||||
final allDevicesRaw = await conn.getManagerById<OmemoManager>(omemoManager)!
|
|
||||||
.retrieveDeviceBundles(ownJid);
|
|
||||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
|
||||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
|
||||||
|
|
||||||
for (final device in allDevices) {
|
final tm =
|
||||||
// All devices that are publishes that is not the current device
|
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||||
if (device.id == ownId) continue;
|
final trustMap = await tm.getDevicesTrust(bareJid);
|
||||||
final curveIk = await device.ik.toCurve25519();
|
|
||||||
|
|
||||||
keys.add(
|
for (final deviceId in _fingerprintCache[bareJid]!.keys) {
|
||||||
OmemoDevice(
|
if (deviceId == ownId) continue;
|
||||||
HEX.encode(await curveIk.getBytes()),
|
if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
|
||||||
false,
|
|
||||||
false,
|
final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
|
||||||
false,
|
keys.add(
|
||||||
device.id,
|
model.OmemoDevice(
|
||||||
hasSessionWith: false,
|
fingerprint,
|
||||||
),
|
await tm.isTrusted(bareJid, deviceId),
|
||||||
);
|
trustMap[deviceId] == BTBVTrustState.verified,
|
||||||
}
|
await tm.isEnabled(bareJid, deviceId),
|
||||||
|
deviceId,
|
||||||
|
hasSessionWith: false,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> verifyDevice(int deviceId, String jid) async {
|
||||||
|
final tm =
|
||||||
|
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||||
|
await tm.setDeviceTrust(
|
||||||
|
jid,
|
||||||
|
deviceId,
|
||||||
|
BTBVTrustState.verified,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
|
||||||
|
void onNewConnection() {
|
||||||
|
if (_initialized) {
|
||||||
|
omemoManager.onNewConnection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database methods
|
||||||
|
|
||||||
|
Future<List<OmemoDoubleRatchetWrapper>> _loadRatchets() async {
|
||||||
|
final results =
|
||||||
|
await GetIt.I.get<DatabaseService>().database.query(omemoRatchetsTable);
|
||||||
|
|
||||||
|
return results.map((ratchet) {
|
||||||
|
final json = jsonDecode(ratchet['mkskipped']! as String) as List<dynamic>;
|
||||||
|
final mkskipped = List<Map<String, dynamic>>.empty(growable: true);
|
||||||
|
for (final i in json) {
|
||||||
|
final element = i as Map<String, dynamic>;
|
||||||
|
mkskipped.add({
|
||||||
|
'key': element['key']! as String,
|
||||||
|
'public': element['public']! as String,
|
||||||
|
'n': element['n']! as int,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return OmemoDoubleRatchetWrapper(
|
||||||
|
OmemoDoubleRatchet.fromJson(
|
||||||
|
{
|
||||||
|
...ratchet,
|
||||||
|
'acknowledged': intToBool(ratchet['acknowledged']! as int),
|
||||||
|
'mkskipped': mkskipped,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ratchet['id']! as int,
|
||||||
|
ratchet['jid']! as String,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveRatchet(OmemoDoubleRatchetWrapper ratchet) async {
|
||||||
|
final json = await ratchet.ratchet.toJson();
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
omemoRatchetsTable,
|
||||||
|
{
|
||||||
|
...json,
|
||||||
|
'mkskipped': jsonEncode(json['mkskipped']),
|
||||||
|
'acknowledged': boolToInt(json['acknowledged']! as bool),
|
||||||
|
'jid': ratchet.jid,
|
||||||
|
'id': ratchet.id,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<RatchetMapKey, BTBVTrustState>> _loadTrustCache() async {
|
||||||
|
final entries = await GetIt.I
|
||||||
|
.get<DatabaseService>()
|
||||||
|
.database
|
||||||
|
.query(omemoTrustCacheTable);
|
||||||
|
|
||||||
|
final mapEntries =
|
||||||
|
entries.map<MapEntry<RatchetMapKey, BTBVTrustState>>((entry) {
|
||||||
|
// TODO(PapaTutuWawa): Expose this from omemo_dart
|
||||||
|
BTBVTrustState state;
|
||||||
|
final value = entry['trust']! as int;
|
||||||
|
if (value == 1) {
|
||||||
|
state = BTBVTrustState.notTrusted;
|
||||||
|
} else if (value == 2) {
|
||||||
|
state = BTBVTrustState.blindTrust;
|
||||||
|
} else if (value == 3) {
|
||||||
|
state = BTBVTrustState.verified;
|
||||||
|
} else {
|
||||||
|
state = BTBVTrustState.notTrusted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return MapEntry(
|
||||||
|
RatchetMapKey.fromJsonKey(entry['key']! as String),
|
||||||
|
state,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Map.fromEntries(mapEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveTrustCache(Map<String, int> cache) async {
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
batch.delete(omemoTrustCacheTable);
|
||||||
|
for (final entry in cache.entries) {
|
||||||
|
batch.insert(
|
||||||
|
omemoTrustCacheTable,
|
||||||
|
{
|
||||||
|
'key': entry.key,
|
||||||
|
'trust': entry.value,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<RatchetMapKey, bool>> _loadTrustEnablementList() async {
|
||||||
|
final entries = await GetIt.I
|
||||||
|
.get<DatabaseService>()
|
||||||
|
.database
|
||||||
|
.query(omemoTrustEnableListTable);
|
||||||
|
|
||||||
|
final mapEntries = entries.map<MapEntry<RatchetMapKey, bool>>((entry) {
|
||||||
|
return MapEntry(
|
||||||
|
RatchetMapKey.fromJsonKey(entry['key']! as String),
|
||||||
|
intToBool(entry['enabled']! as int),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Map.fromEntries(mapEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveTrustEnablementList(Map<String, bool> list) async {
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
batch.delete(omemoTrustEnableListTable);
|
||||||
|
for (final entry in list.entries) {
|
||||||
|
batch.insert(
|
||||||
|
omemoTrustEnableListTable,
|
||||||
|
{
|
||||||
|
'key': entry.key,
|
||||||
|
'enabled': boolToInt(entry.value),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<int>>> _loadTrustDeviceList() async {
|
||||||
|
final entries = await GetIt.I
|
||||||
|
.get<DatabaseService>()
|
||||||
|
.database
|
||||||
|
.query(omemoTrustDeviceListTable);
|
||||||
|
|
||||||
|
final map = <String, List<int>>{};
|
||||||
|
for (final entry in entries) {
|
||||||
|
final key = entry['jid']! as String;
|
||||||
|
final device = entry['device']! as int;
|
||||||
|
|
||||||
|
if (map.containsKey(key)) {
|
||||||
|
map[key]!.add(device);
|
||||||
|
} else {
|
||||||
|
map[key] = [device];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveTrustDeviceList(Map<String, List<int>> list) async {
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
batch.delete(omemoTrustDeviceListTable);
|
||||||
|
for (final entry in list.entries) {
|
||||||
|
for (final device in entry.value) {
|
||||||
|
batch.insert(
|
||||||
|
omemoTrustDeviceListTable,
|
||||||
|
{
|
||||||
|
'jid': entry.key,
|
||||||
|
'device': device,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveOmemoDevice(OmemoDevice device) async {
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
omemoDeviceTable,
|
||||||
|
{
|
||||||
|
'jid': device.jid,
|
||||||
|
'id': device.id,
|
||||||
|
'data': jsonEncode(await device.toJson()),
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<OmemoDevice?> _loadOmemoDevice(String jid) async {
|
||||||
|
final data = await GetIt.I.get<DatabaseService>().database.query(
|
||||||
|
omemoDeviceTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
if (data.isEmpty) return null;
|
||||||
|
|
||||||
|
final deviceJson =
|
||||||
|
jsonDecode(data.first['data']! as String) as Map<String, dynamic>;
|
||||||
|
// NOTE: We need to do this because Dart otherwise complains about not being able
|
||||||
|
// to cast dynamic to List<int>.
|
||||||
|
final opks = List<Map<String, dynamic>>.empty(growable: true);
|
||||||
|
final opksIter = deviceJson['opks']! as List<dynamic>;
|
||||||
|
for (final tmpOpk in opksIter) {
|
||||||
|
final opk = tmpOpk as Map<String, dynamic>;
|
||||||
|
opks.add(<String, dynamic>{
|
||||||
|
'id': opk['id']! as int,
|
||||||
|
'public': opk['public']! as String,
|
||||||
|
'private': opk['private']! as String,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
deviceJson['opks'] = opks;
|
||||||
|
return OmemoDevice.fromJson(deviceJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, List<int>>> _loadOmemoDeviceList() async {
|
||||||
|
final list = await GetIt.I
|
||||||
|
.get<DatabaseService>()
|
||||||
|
.database
|
||||||
|
.query(omemoDeviceListTable);
|
||||||
|
final map = <String, List<int>>{};
|
||||||
|
for (final entry in list) {
|
||||||
|
final key = entry['jid']! as String;
|
||||||
|
final id = entry['id']! as int;
|
||||||
|
|
||||||
|
if (map.containsKey(key)) {
|
||||||
|
map[key]!.add(id);
|
||||||
|
} else {
|
||||||
|
map[key] = [id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveOmemoDeviceList(Map<String, List<int>> list) async {
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
batch.delete(omemoDeviceListTable);
|
||||||
|
for (final entry in list.entries) {
|
||||||
|
for (final id in entry.value) {
|
||||||
|
batch.insert(
|
||||||
|
omemoDeviceListTable,
|
||||||
|
{
|
||||||
|
'jid': entry.key,
|
||||||
|
'id': id,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _emptyOmemoSessionTables() async {
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
|
||||||
|
// ignore: cascade_invocations
|
||||||
|
batch
|
||||||
|
..delete(omemoRatchetsTable)
|
||||||
|
..delete(omemoTrustCacheTable)
|
||||||
|
..delete(omemoTrustEnableListTable);
|
||||||
|
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _addFingerprintsToCache(List<OmemoCacheTriple> items) async {
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
for (final item in items) {
|
||||||
|
batch.insert(
|
||||||
|
omemoFingerprintCache,
|
||||||
|
<String, dynamic>{
|
||||||
|
'jid': item.jid,
|
||||||
|
'id': item.deviceId,
|
||||||
|
'fingerprint': item.fingerprint,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<OmemoCacheTriple>> _getFingerprintsFromCache(String jid) async {
|
||||||
|
final rawItems = await GetIt.I.get<DatabaseService>().database.query(
|
||||||
|
omemoFingerprintCache,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return rawItems.map((item) {
|
||||||
|
return OmemoCacheTriple(
|
||||||
|
jid,
|
||||||
|
item['id']! as int,
|
||||||
|
item['fingerprint']! as String,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,12 +1,37 @@
|
|||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
|
||||||
class PreferencesService {
|
class PreferencesService {
|
||||||
PreferencesState? _preferences;
|
PreferencesState? _preferences;
|
||||||
|
|
||||||
Future<void> _loadPreferences() async {
|
Future<void> _loadPreferences() async {
|
||||||
_preferences = await GetIt.I.get<DatabaseService>().getPreferences();
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final preferencesRaw = (await db.query(preferenceTable)).map((preference) {
|
||||||
|
switch (preference['type']! as int) {
|
||||||
|
case typeInt:
|
||||||
|
return {
|
||||||
|
...preference,
|
||||||
|
'value': stringToInt(preference['value']! as String),
|
||||||
|
};
|
||||||
|
case typeBool:
|
||||||
|
return {
|
||||||
|
...preference,
|
||||||
|
'value': stringToBool(preference['value']! as String),
|
||||||
|
};
|
||||||
|
case typeString:
|
||||||
|
default:
|
||||||
|
return preference;
|
||||||
|
}
|
||||||
|
}).toList();
|
||||||
|
final json = <String, dynamic>{};
|
||||||
|
for (final preference in preferencesRaw) {
|
||||||
|
json[preference['key']! as String] = preference['value'];
|
||||||
|
}
|
||||||
|
|
||||||
|
_preferences = PreferencesState.fromJson(json);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PreferencesState> getPreferences() async {
|
Future<PreferencesState> getPreferences() async {
|
||||||
@@ -15,10 +40,44 @@ class PreferencesService {
|
|||||||
return _preferences!;
|
return _preferences!;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> modifyPreferences(PreferencesState Function(PreferencesState) func) async {
|
Future<void> modifyPreferences(
|
||||||
|
PreferencesState Function(PreferencesState) func,
|
||||||
|
) async {
|
||||||
if (_preferences == null) await _loadPreferences();
|
if (_preferences == null) await _loadPreferences();
|
||||||
|
|
||||||
_preferences = func(_preferences!);
|
_preferences = func(_preferences!);
|
||||||
await GetIt.I.get<DatabaseService>().savePreferences(_preferences!);
|
|
||||||
|
final stateJson = _preferences!.toJson();
|
||||||
|
final preferences = stateJson.keys.map((key) {
|
||||||
|
int type;
|
||||||
|
String value;
|
||||||
|
if (stateJson[key] is int) {
|
||||||
|
type = typeInt;
|
||||||
|
value = intToString(stateJson[key]! as int);
|
||||||
|
} else if (stateJson[key] is bool) {
|
||||||
|
type = typeBool;
|
||||||
|
value = boolToString(stateJson[key]! as bool);
|
||||||
|
} else {
|
||||||
|
type = typeString;
|
||||||
|
value = stateJson[key]! as String;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
'key': key,
|
||||||
|
'type': type,
|
||||||
|
'value': value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
for (final preference in preferences) {
|
||||||
|
batch.update(
|
||||||
|
preferenceTable,
|
||||||
|
preference,
|
||||||
|
where: 'key = ?',
|
||||||
|
whereArgs: [preference['key']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
203
lib/service/reactions.dart
Normal file
203
lib/service/reactions.dart
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/message.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/reaction.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
class ReactionWrapper {
|
||||||
|
const ReactionWrapper(this.emojis, this.modified);
|
||||||
|
|
||||||
|
final List<String> emojis;
|
||||||
|
|
||||||
|
final bool modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReactionsService {
|
||||||
|
final Logger _log = Logger('ReactionsService');
|
||||||
|
|
||||||
|
/// Query the database for 6 distinct emoji reactions associated with the message id
|
||||||
|
/// [id].
|
||||||
|
Future<List<String>> getPreviewReactionsForMessage(int id) async {
|
||||||
|
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||||
|
reactionsTable,
|
||||||
|
where: 'message_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
columns: ['emoji'],
|
||||||
|
distinct: true,
|
||||||
|
limit: 6,
|
||||||
|
);
|
||||||
|
|
||||||
|
return reactions.map((r) => r['emoji']! as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Reaction>> getReactionsForMessage(int id) async {
|
||||||
|
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||||
|
reactionsTable,
|
||||||
|
where: 'message_id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return reactions.map(Reaction.fromJson).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> getReactionsForMessageByJid(int id, String jid) async {
|
||||||
|
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||||
|
reactionsTable,
|
||||||
|
where: 'message_id = ? AND senderJid = ?',
|
||||||
|
whereArgs: [id, jid],
|
||||||
|
);
|
||||||
|
|
||||||
|
return reactions.map((r) => r['emoji']! as String).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int> _countReactions(int messageId, String emoji) async {
|
||||||
|
return GetIt.I.get<DatabaseService>().database.count(
|
||||||
|
reactionsTable,
|
||||||
|
'message_id = ? AND emoji = ?',
|
||||||
|
[messageId, emoji],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a new reaction [emoji], if possible, to [messageId] and returns the
|
||||||
|
/// new message reaction preview.
|
||||||
|
Future<Message?> addNewReaction(
|
||||||
|
int messageId,
|
||||||
|
String conversationJid,
|
||||||
|
String emoji,
|
||||||
|
) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final msg = await ms.getMessageById(messageId, conversationJid);
|
||||||
|
if (msg == null) {
|
||||||
|
_log.warning('Failed to get message $messageId');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!msg.reactionsPreview.contains(emoji) &&
|
||||||
|
msg.reactionsPreview.length < 6) {
|
||||||
|
final newPreview = [
|
||||||
|
...msg.reactionsPreview,
|
||||||
|
emoji,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
reactionsTable,
|
||||||
|
Reaction(
|
||||||
|
messageId,
|
||||||
|
jid,
|
||||||
|
emoji,
|
||||||
|
).toJson(),
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.fail,
|
||||||
|
);
|
||||||
|
|
||||||
|
final newMsg = msg.copyWith(
|
||||||
|
reactionsPreview: newPreview,
|
||||||
|
);
|
||||||
|
await ms.replaceMessageInCache(newMsg);
|
||||||
|
|
||||||
|
sendEvent(
|
||||||
|
MessageUpdatedEvent(
|
||||||
|
message: newMsg,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return newMsg;
|
||||||
|
} catch (ex) {
|
||||||
|
// The reaction already exists
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Message?> removeReaction(
|
||||||
|
int messageId,
|
||||||
|
String conversationJid,
|
||||||
|
String emoji,
|
||||||
|
) async {
|
||||||
|
final ms = GetIt.I.get<MessageService>();
|
||||||
|
final msg = await ms.getMessageById(messageId, conversationJid);
|
||||||
|
if (msg == null) {
|
||||||
|
_log.warning('Failed to get message $messageId');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await GetIt.I.get<DatabaseService>().database.delete(
|
||||||
|
reactionsTable,
|
||||||
|
where: 'message_id = ? AND emoji = ? AND senderJid = ?',
|
||||||
|
whereArgs: [
|
||||||
|
messageId,
|
||||||
|
emoji,
|
||||||
|
(await GetIt.I.get<XmppStateService>().getXmppState()).jid,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final count = await _countReactions(messageId, emoji);
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
final newPreview = List<String>.from(msg.reactionsPreview)..remove(emoji);
|
||||||
|
final newMsg = msg.copyWith(
|
||||||
|
reactionsPreview: newPreview,
|
||||||
|
);
|
||||||
|
await ms.replaceMessageInCache(newMsg);
|
||||||
|
sendEvent(
|
||||||
|
MessageUpdatedEvent(
|
||||||
|
message: newMsg,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return newMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> processNewReactions(
|
||||||
|
Message msg,
|
||||||
|
String senderJid,
|
||||||
|
List<String> emojis,
|
||||||
|
) async {
|
||||||
|
// Get all reactions know for this message
|
||||||
|
final allReactions = await getReactionsForMessage(msg.id);
|
||||||
|
final userEmojis =
|
||||||
|
allReactions.where((r) => r.senderJid == senderJid).map((r) => r.emoji);
|
||||||
|
final removedReactions = userEmojis.where((e) => !emojis.contains(e));
|
||||||
|
final addedReactions = emojis.where((e) => !userEmojis.contains(e));
|
||||||
|
|
||||||
|
// Remove and add the new reactions
|
||||||
|
final db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
for (final emoji in removedReactions) {
|
||||||
|
final rows = await db.delete(
|
||||||
|
reactionsTable,
|
||||||
|
where: 'message_id = ? AND senderJid = ? AND emoji = ?',
|
||||||
|
whereArgs: [msg.id, senderJid, emoji],
|
||||||
|
);
|
||||||
|
assert(rows == 1, 'Only one row should be removed');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final emoji in addedReactions) {
|
||||||
|
await db.insert(
|
||||||
|
reactionsTable,
|
||||||
|
Reaction(
|
||||||
|
msg.id,
|
||||||
|
senderJid,
|
||||||
|
emoji,
|
||||||
|
).toJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final newMessage = msg.copyWith(
|
||||||
|
reactionsPreview: await getPreviewReactionsForMessage(msg.id),
|
||||||
|
);
|
||||||
|
await GetIt.I.get<MessageService>().replaceMessageInCache(
|
||||||
|
newMessage,
|
||||||
|
);
|
||||||
|
sendEvent(MessageUpdatedEvent(message: newMessage));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,189 +1,33 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:collection';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:moxlib/moxlib.dart';
|
|
||||||
import 'package:moxplatform/moxplatform.dart';
|
|
||||||
import 'package:moxxmpp/moxxmpp.dart';
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/not_specified.dart';
|
||||||
import 'package:moxxyv2/service/service.dart';
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/subscription.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
|
||||||
import 'package:moxxyv2/shared/models/roster.dart';
|
import 'package:moxxyv2/shared/models/roster.dart';
|
||||||
|
|
||||||
/// Closure which returns true if the jid of a [RosterItem] is equal to [jid].
|
|
||||||
bool Function(RosterItem) _jidEqualsWrapper(String jid) {
|
|
||||||
return (i) => i.jid == jid;
|
|
||||||
}
|
|
||||||
|
|
||||||
typedef AddRosterItemFunction = Future<RosterItem> Function(
|
|
||||||
String avatarUrl,
|
|
||||||
String avatarHash,
|
|
||||||
String jid,
|
|
||||||
String title,
|
|
||||||
String subscription,
|
|
||||||
String ask,
|
|
||||||
{
|
|
||||||
List<String> groups,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
typedef UpdateRosterItemFunction = Future<RosterItem> Function(
|
|
||||||
int id, {
|
|
||||||
String? avatarUrl,
|
|
||||||
String? avatarHash,
|
|
||||||
String? title,
|
|
||||||
String? subscription,
|
|
||||||
String? ask,
|
|
||||||
List<String>? groups,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
typedef RemoveRosterItemFunction = Future<void> Function(String jid);
|
|
||||||
typedef GetConversationFunction = Future<Conversation?> Function(String jid);
|
|
||||||
typedef SendEventFunction = void Function(BackgroundEvent event, { String? id });
|
|
||||||
|
|
||||||
/// Compare the local roster with the roster we received either by request or by push.
|
|
||||||
/// Returns a diff between the roster before and after the request or the push.
|
|
||||||
/// NOTE: This abuses the [RosterDiffEvent] type a bit.
|
|
||||||
Future<RosterDiffEvent> processRosterDiff(
|
|
||||||
List<RosterItem> currentRoster,
|
|
||||||
List<XmppRosterItem> remoteRoster,
|
|
||||||
bool isRosterPush,
|
|
||||||
AddRosterItemFunction addRosterItemFromData,
|
|
||||||
UpdateRosterItemFunction updateRosterItem,
|
|
||||||
RemoveRosterItemFunction removeRosterItemByJid,
|
|
||||||
GetConversationFunction getConversationByJid,
|
|
||||||
SendEventFunction _sendEvent,
|
|
||||||
) async {
|
|
||||||
final removed = List<String>.empty(growable: true);
|
|
||||||
final modified = List<RosterItem>.empty(growable: true);
|
|
||||||
final added = List<RosterItem>.empty(growable: true);
|
|
||||||
|
|
||||||
for (final item in remoteRoster) {
|
|
||||||
if (isRosterPush) {
|
|
||||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
|
||||||
if (litem != null) {
|
|
||||||
if (item.subscription == 'remove') {
|
|
||||||
// We have the item locally but it has been removed
|
|
||||||
await removeRosterItemByJid(item.jid);
|
|
||||||
removed.add(item.jid);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item has been modified
|
|
||||||
final newItem = await updateRosterItem(
|
|
||||||
litem.id,
|
|
||||||
subscription: item.subscription,
|
|
||||||
title: item.name,
|
|
||||||
ask: item.ask,
|
|
||||||
groups: item.groups,
|
|
||||||
);
|
|
||||||
|
|
||||||
modified.add(newItem);
|
|
||||||
|
|
||||||
// Check if we have a conversation that we need to modify
|
|
||||||
final conv = await getConversationByJid(item.jid);
|
|
||||||
if (conv != null) {
|
|
||||||
_sendEvent(
|
|
||||||
ConversationUpdatedEvent(
|
|
||||||
conversation: conv.copyWith(subscription: item.subscription),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Item does not exist locally
|
|
||||||
if (item.subscription == 'remove') {
|
|
||||||
// Item has been removed but we don't have it locally
|
|
||||||
removed.add(item.jid);
|
|
||||||
} else {
|
|
||||||
// Item has been added and we don't have it locally
|
|
||||||
final newItem = await addRosterItemFromData(
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
item.jid,
|
|
||||||
item.name ?? item.jid.split('@')[0],
|
|
||||||
item.subscription,
|
|
||||||
item.ask ?? '',
|
|
||||||
groups: item.groups,
|
|
||||||
);
|
|
||||||
|
|
||||||
added.add(newItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
final litem = firstWhereOrNull(currentRoster, _jidEqualsWrapper(item.jid));
|
|
||||||
if (litem != null) {
|
|
||||||
// Item is modified
|
|
||||||
if (litem.title != item.name || litem.subscription != item.subscription || !listEquals(litem.groups, item.groups)) {
|
|
||||||
final modifiedItem = await updateRosterItem(
|
|
||||||
litem.id,
|
|
||||||
title: item.name,
|
|
||||||
subscription: item.subscription,
|
|
||||||
groups: item.groups,
|
|
||||||
);
|
|
||||||
modified.add(modifiedItem);
|
|
||||||
|
|
||||||
// Check if we have a conversation that we need to modify
|
|
||||||
final conv = await getConversationByJid(litem.jid);
|
|
||||||
if (conv != null) {
|
|
||||||
_sendEvent(
|
|
||||||
ConversationUpdatedEvent(
|
|
||||||
conversation: conv.copyWith(subscription: item.subscription),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Item is new
|
|
||||||
added.add(await addRosterItemFromData(
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
item.jid,
|
|
||||||
item.jid.split('@')[0],
|
|
||||||
item.subscription,
|
|
||||||
item.ask ?? '',
|
|
||||||
groups: item.groups,
|
|
||||||
),);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isRosterPush) {
|
|
||||||
for (final item in currentRoster) {
|
|
||||||
final ritem = firstWhereOrNull(remoteRoster, (XmppRosterItem i) => i.jid == item.jid);
|
|
||||||
if (ritem == null) {
|
|
||||||
await removeRosterItemByJid(item.jid);
|
|
||||||
removed.add(item.jid);
|
|
||||||
}
|
|
||||||
// We don't handle the modification case here as that is covered by the huge
|
|
||||||
// loop above
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return RosterDiffEvent(
|
|
||||||
added: added,
|
|
||||||
modified: modified,
|
|
||||||
removed: removed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class RosterService {
|
class RosterService {
|
||||||
|
/// The cached list of JID -> RosterItem. Null if not yet loaded
|
||||||
|
Map<String, RosterItem>? _rosterCache;
|
||||||
|
|
||||||
RosterService()
|
/// Logger.
|
||||||
: _rosterCache = HashMap(),
|
final Logger _log = Logger('RosterService');
|
||||||
_rosterLoaded = false,
|
|
||||||
_log = Logger('RosterService');
|
|
||||||
final HashMap<String, RosterItem> _rosterCache;
|
|
||||||
bool _rosterLoaded;
|
|
||||||
final Logger _log;
|
|
||||||
|
|
||||||
Future<bool> isInRoster(String jid) async {
|
Future<void> _loadRosterIfNeeded() async {
|
||||||
if (!_rosterLoaded) {
|
if (_rosterCache == null) {
|
||||||
await loadRosterFromDatabase();
|
await loadRosterFromDatabase();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return _rosterCache.containsKey(jid);
|
Future<bool> isInRoster(String jid) async {
|
||||||
|
await _loadRosterIfNeeded();
|
||||||
|
return _rosterCache!.containsKey(jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||||
@@ -194,22 +38,37 @@ class RosterService {
|
|||||||
String title,
|
String title,
|
||||||
String subscription,
|
String subscription,
|
||||||
String ask,
|
String ask,
|
||||||
{
|
bool pseudoRosterItem,
|
||||||
List<String> groups = const [],
|
String? contactId,
|
||||||
}
|
String? contactAvatarPath,
|
||||||
) async {
|
String? contactDisplayName, {
|
||||||
final item = await GetIt.I.get<DatabaseService>().addRosterItemFromData(
|
List<String> groups = const [],
|
||||||
|
}) async {
|
||||||
|
// TODO(PapaTutuWawa): Handle groups
|
||||||
|
final i = RosterItem(
|
||||||
|
-1,
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
avatarHash,
|
avatarHash,
|
||||||
jid,
|
jid,
|
||||||
title,
|
title,
|
||||||
subscription,
|
subscription,
|
||||||
ask,
|
ask,
|
||||||
groups: groups,
|
pseudoRosterItem,
|
||||||
|
<String>[],
|
||||||
|
contactId: contactId,
|
||||||
|
contactAvatarPath: contactAvatarPath,
|
||||||
|
contactDisplayName: contactDisplayName,
|
||||||
|
);
|
||||||
|
|
||||||
|
final item = i.copyWith(
|
||||||
|
id: await GetIt.I
|
||||||
|
.get<DatabaseService>()
|
||||||
|
.database
|
||||||
|
.insert(rosterTable, i.toDatabaseJson()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update the cache
|
// Update the cache
|
||||||
_rosterCache[item.jid] = item;
|
_rosterCache![item.jid] = item;
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
@@ -217,45 +76,86 @@ class RosterService {
|
|||||||
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
|
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
|
||||||
Future<RosterItem> updateRosterItem(
|
Future<RosterItem> updateRosterItem(
|
||||||
int id, {
|
int id, {
|
||||||
String? avatarUrl,
|
String? avatarUrl,
|
||||||
String? avatarHash,
|
String? avatarHash,
|
||||||
String? title,
|
String? title,
|
||||||
String? subscription,
|
String? subscription,
|
||||||
String? ask,
|
String? ask,
|
||||||
List<String>? groups,
|
Object pseudoRosterItem = notSpecified,
|
||||||
|
List<String>? groups,
|
||||||
|
Object? contactId = notSpecified,
|
||||||
|
Object? contactAvatarPath = notSpecified,
|
||||||
|
Object? contactDisplayName = notSpecified,
|
||||||
|
}) async {
|
||||||
|
final i = <String, dynamic>{};
|
||||||
|
|
||||||
|
if (avatarUrl != null) {
|
||||||
|
i['avatarUrl'] = avatarUrl;
|
||||||
}
|
}
|
||||||
) async {
|
if (avatarHash != null) {
|
||||||
final newItem = await GetIt.I.get<DatabaseService>().updateRosterItem(
|
i['avatarHash'] = avatarHash;
|
||||||
id,
|
}
|
||||||
avatarUrl: avatarUrl,
|
if (title != null) {
|
||||||
avatarHash: avatarHash,
|
i['title'] = title;
|
||||||
title: title,
|
}
|
||||||
subscription: subscription,
|
/*
|
||||||
ask: ask,
|
if (groups != null) {
|
||||||
groups: groups,
|
i.groups = groups;
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
if (subscription != null) {
|
||||||
|
i['subscription'] = subscription;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
|
||||||
|
rosterTable,
|
||||||
|
i,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
);
|
);
|
||||||
|
final newItem = RosterItem.fromDatabaseJson(result);
|
||||||
|
|
||||||
// Update cache
|
// Update cache
|
||||||
_rosterCache[newItem.jid] = newItem;
|
_rosterCache![newItem.jid] = newItem;
|
||||||
|
|
||||||
return newItem;
|
return newItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wrapper around [DatabaseService]'s removeRosterItem.
|
/// Removes a roster item from the database and cache
|
||||||
Future<void> removeRosterItem(int id) async {
|
Future<void> removeRosterItem(int id) async {
|
||||||
await GetIt.I.get<DatabaseService>().removeRosterItem(id);
|
// NOTE: This call ensures that _rosterCache != null
|
||||||
|
await GetIt.I.get<DatabaseService>().database.delete(
|
||||||
|
rosterTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||||
|
|
||||||
/// Update cache
|
/// Update cache
|
||||||
_rosterCache.removeWhere((_, value) => value.id == id);
|
_rosterCache!.removeWhere((_, value) => value.id == id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes a roster item from the database based on its JID.
|
/// Removes a roster item from the database based on its JID.
|
||||||
Future<void> removeRosterItemByJid(String jid) async {
|
Future<void> removeRosterItemByJid(String jid) async {
|
||||||
if (!_rosterLoaded) {
|
await _loadRosterIfNeeded();
|
||||||
await loadRosterFromDatabase();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final item in _rosterCache.values) {
|
for (final item in _rosterCache!.values) {
|
||||||
if (item.jid == jid) {
|
if (item.jid == jid) {
|
||||||
await removeRosterItem(item.id);
|
await removeRosterItem(item.id);
|
||||||
return;
|
return;
|
||||||
@@ -265,17 +165,14 @@ class RosterService {
|
|||||||
|
|
||||||
/// Returns the entire roster
|
/// Returns the entire roster
|
||||||
Future<List<RosterItem>> getRoster() async {
|
Future<List<RosterItem>> getRoster() async {
|
||||||
if (!_rosterLoaded) {
|
await _loadRosterIfNeeded();
|
||||||
await loadRosterFromDatabase();
|
return _rosterCache!.values.toList();
|
||||||
}
|
|
||||||
|
|
||||||
return _rosterCache.values.toList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
||||||
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
||||||
if (await isInRoster(jid)) {
|
if (await isInRoster(jid)) {
|
||||||
return _rosterCache[jid];
|
return _rosterCache![jid];
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -284,20 +181,29 @@ class RosterService {
|
|||||||
/// Load the roster from the database. This function is guarded against loading the
|
/// Load the roster from the database. This function is guarded against loading the
|
||||||
/// roster multiple times and thus creating too many "RosterDiff" actions.
|
/// roster multiple times and thus creating too many "RosterDiff" actions.
|
||||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||||
final items = await GetIt.I.get<DatabaseService>().loadRosterItems();
|
final itemsRaw =
|
||||||
|
await GetIt.I.get<DatabaseService>().database.query(rosterTable);
|
||||||
|
final items = itemsRaw.map(RosterItem.fromDatabaseJson);
|
||||||
|
|
||||||
_rosterLoaded = true;
|
_rosterCache = <String, RosterItem>{};
|
||||||
for (final item in items) {
|
for (final item in items) {
|
||||||
_rosterCache[item.jid] = item;
|
_rosterCache![item.jid] = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
return items;
|
return items.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempts to add an item to the roster by first performing the roster set
|
/// Attempts to add an item to the roster by first performing the roster set
|
||||||
/// and, if it was successful, create the database entry. Returns the
|
/// and, if it was successful, create the database entry. Returns the
|
||||||
/// [RosterItem] model object.
|
/// [RosterItem] model object.
|
||||||
Future<RosterItem> addToRosterWrapper(String avatarUrl, String avatarHash, String jid, String title) async {
|
Future<RosterItem> addToRosterWrapper(
|
||||||
|
String avatarUrl,
|
||||||
|
String avatarHash,
|
||||||
|
String jid,
|
||||||
|
String title,
|
||||||
|
) async {
|
||||||
|
final css = GetIt.I.get<ContactsService>();
|
||||||
|
final contactId = await css.getContactIdForJid(jid);
|
||||||
final item = await addRosterItemFromData(
|
final item = await addRosterItemFromData(
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
avatarHash,
|
avatarHash,
|
||||||
@@ -305,28 +211,39 @@ class RosterService {
|
|||||||
title,
|
title,
|
||||||
'none',
|
'none',
|
||||||
'',
|
'',
|
||||||
|
false,
|
||||||
|
contactId,
|
||||||
|
await css.getProfilePicturePathForJid(jid),
|
||||||
|
await css.getContactDisplayName(contactId),
|
||||||
);
|
);
|
||||||
final result = await GetIt.I.get<XmppConnection>().getRosterManager().addToRoster(jid, title);
|
|
||||||
|
final result = await GetIt.I
|
||||||
|
.get<XmppConnection>()
|
||||||
|
.getRosterManager()!
|
||||||
|
.addToRoster(jid, title);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// TODO(Unknown): Signal error?
|
// TODO(Unknown): Signal error?
|
||||||
}
|
}
|
||||||
|
|
||||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequest(jid);
|
sendEvent(RosterDiffEvent(added: [item]));
|
||||||
|
|
||||||
sendEvent(RosterDiffEvent(added: [ item ]));
|
|
||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the [RosterItem] with jid [jid] from the server-side roster and, if
|
/// Removes the [RosterItem] with jid [jid] from the server-side roster and, if
|
||||||
/// successful, from the database. If [unsubscribe] is true, then [jid] won't receive
|
/// successful, from the database. If [unsubscribe] is true, then [jid] won't receive
|
||||||
/// our presence anymore.
|
/// our presence anymore.
|
||||||
Future<bool> removeFromRosterWrapper(String jid, { bool unsubscribe = true }) async {
|
Future<bool> removeFromRosterWrapper(
|
||||||
final roster = GetIt.I.get<XmppConnection>().getRosterManager();
|
String jid, {
|
||||||
final presence = GetIt.I.get<XmppConnection>().getPresenceManager();
|
bool unsubscribe = true,
|
||||||
|
}) async {
|
||||||
|
final roster = GetIt.I.get<XmppConnection>().getRosterManager()!;
|
||||||
final result = await roster.removeFromRoster(jid);
|
final result = await roster.removeFromRoster(jid);
|
||||||
if (result == RosterRemovalResult.okay || result == RosterRemovalResult.itemNotFound) {
|
if (result == RosterRemovalResult.okay ||
|
||||||
|
result == RosterRemovalResult.itemNotFound) {
|
||||||
if (unsubscribe) {
|
if (unsubscribe) {
|
||||||
presence.sendUnsubscriptionRequest(jid);
|
GetIt.I
|
||||||
|
.get<SubscriptionRequestService>()
|
||||||
|
.sendUnsubscriptionRequest(jid);
|
||||||
}
|
}
|
||||||
|
|
||||||
_log.finest('Removing from roster maybe worked. Removing from database');
|
_log.finest('Removing from roster maybe worked. Removing from database');
|
||||||
@@ -336,73 +253,4 @@ class RosterService {
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> requestRoster() async {
|
|
||||||
final roster = GetIt.I.get<XmppConnection>().getManagerById<RosterManager>(rosterManager)!;
|
|
||||||
Result<RosterRequestResult?, RosterError> result;
|
|
||||||
if (roster.rosterVersioningAvailable()) {
|
|
||||||
_log.fine('Stream supports roster versioning');
|
|
||||||
result = await roster.requestRosterPushes();
|
|
||||||
_log.fine('Requesting roster pushes done');
|
|
||||||
} else {
|
|
||||||
_log.fine('Stream does not support roster versioning');
|
|
||||||
result = await roster.requestRoster();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.isType<RosterError>()) {
|
|
||||||
_log.warning('Failed to request roster');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final value = result.get<RosterRequestResult?>();
|
|
||||||
if (value != null) {
|
|
||||||
final currentRoster = await getRoster();
|
|
||||||
sendEvent(
|
|
||||||
await processRosterDiff(
|
|
||||||
currentRoster,
|
|
||||||
value.items,
|
|
||||||
false,
|
|
||||||
addRosterItemFromData,
|
|
||||||
updateRosterItem,
|
|
||||||
removeRosterItemByJid,
|
|
||||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
|
||||||
sendEvent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handles a roster push.
|
|
||||||
Future<void> handleRosterPushEvent(RosterPushEvent event) async {
|
|
||||||
final item = event.item;
|
|
||||||
final currentRoster = await getRoster();
|
|
||||||
sendEvent(
|
|
||||||
await processRosterDiff(
|
|
||||||
currentRoster,
|
|
||||||
[ item ],
|
|
||||||
true,
|
|
||||||
addRosterItemFromData,
|
|
||||||
updateRosterItem,
|
|
||||||
removeRosterItemByJid,
|
|
||||||
GetIt.I.get<ConversationService>().getConversationByJid,
|
|
||||||
sendEvent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
|
||||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestApproval(jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> rejectSubscriptionRequest(String jid) async {
|
|
||||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequestRejection(jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
void sendSubscriptionRequest(String jid) {
|
|
||||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendSubscriptionRequest(jid);
|
|
||||||
}
|
|
||||||
|
|
||||||
void sendUnsubscriptionRequest(String jid) {
|
|
||||||
GetIt.I.get<XmppConnection>().getPresenceManager().sendUnsubscriptionRequest(jid);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,28 +13,34 @@ import 'package:moxxyv2/service/avatars.dart';
|
|||||||
import 'package:moxxyv2/service/blocking.dart';
|
import 'package:moxxyv2/service/blocking.dart';
|
||||||
import 'package:moxxyv2/service/connectivity.dart';
|
import 'package:moxxyv2/service/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
import 'package:moxxyv2/service/connectivity_watcher.dart';
|
||||||
|
import 'package:moxxyv2/service/contacts.dart';
|
||||||
import 'package:moxxyv2/service/conversation.dart';
|
import 'package:moxxyv2/service/conversation.dart';
|
||||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||||
import 'package:moxxyv2/service/database/database.dart';
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
import 'package:moxxyv2/service/events.dart';
|
import 'package:moxxyv2/service/events.dart';
|
||||||
|
import 'package:moxxyv2/service/files.dart';
|
||||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||||
import 'package:moxxyv2/service/language.dart';
|
import 'package:moxxyv2/service/language.dart';
|
||||||
import 'package:moxxyv2/service/message.dart';
|
import 'package:moxxyv2/service/message.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/disco.dart';
|
import 'package:moxxyv2/service/moxxmpp/connectivity.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
|
|
||||||
import 'package:moxxyv2/service/moxxmpp/roster.dart';
|
import 'package:moxxyv2/service/moxxmpp/roster.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/socket.dart';
|
import 'package:moxxyv2/service/moxxmpp/socket.dart';
|
||||||
import 'package:moxxyv2/service/moxxmpp/stream.dart';
|
import 'package:moxxyv2/service/moxxmpp/stream.dart';
|
||||||
import 'package:moxxyv2/service/notifications.dart';
|
import 'package:moxxyv2/service/notifications.dart';
|
||||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||||
import 'package:moxxyv2/service/preferences.dart';
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/service/reactions.dart';
|
||||||
import 'package:moxxyv2/service/roster.dart';
|
import 'package:moxxyv2/service/roster.dart';
|
||||||
|
import 'package:moxxyv2/service/stickers.dart';
|
||||||
|
import 'package:moxxyv2/service/subscription.dart';
|
||||||
import 'package:moxxyv2/service/xmpp.dart';
|
import 'package:moxxyv2/service/xmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
import 'package:moxxyv2/shared/commands.dart';
|
import 'package:moxxyv2/shared/commands.dart';
|
||||||
import 'package:moxxyv2/shared/eventhandler.dart';
|
import 'package:moxxyv2/shared/eventhandler.dart';
|
||||||
import 'package:moxxyv2/shared/events.dart';
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
import 'package:moxxyv2/shared/logging.dart';
|
import 'package:moxxyv2/shared/logging.dart';
|
||||||
|
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||||
import 'package:moxxyv2/ui/events.dart' as ui_events;
|
import 'package:moxxyv2/ui/events.dart' as ui_events;
|
||||||
|
|
||||||
Future<void> initializeServiceIfNeeded() async {
|
Future<void> initializeServiceIfNeeded() async {
|
||||||
@@ -42,27 +48,30 @@ Future<void> initializeServiceIfNeeded() async {
|
|||||||
final handler = MoxplatformPlugin.handler;
|
final handler = MoxplatformPlugin.handler;
|
||||||
if (await handler.isRunning()) {
|
if (await handler.isRunning()) {
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
logger.fine('Since kDebugMode is true, waiting 600ms before sending PreStartCommand');
|
logger.fine(
|
||||||
|
'Since kDebugMode is true, waiting 600ms before sending PreStartCommand',
|
||||||
|
);
|
||||||
sleep(const Duration(milliseconds: 600));
|
sleep(const Duration(milliseconds: 600));
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info('Attaching to service...');
|
logger.info('Attaching to service...');
|
||||||
await handler.attach(ui_events.handleIsolateEvent);
|
await handler.attach(ui_events.receiveIsolateEvent);
|
||||||
logger.info('Done');
|
logger.info('Done');
|
||||||
|
|
||||||
// ignore: cascade_invocations
|
// ignore: cascade_invocations
|
||||||
logger.info('Service is running. Sending pre start command');
|
logger.info('Service is running. Sending pre start command');
|
||||||
await handler.getDataSender().sendData(
|
await handler.getDataSender().sendData(
|
||||||
PerformPreStartCommand(
|
PerformPreStartCommand(
|
||||||
systemLocaleCode: WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
|
systemLocaleCode: WidgetsBinding.instance.platformDispatcher.locale
|
||||||
),
|
.toLanguageTag(),
|
||||||
awaitable: false,
|
),
|
||||||
);
|
awaitable: false,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logger.info('Service is not running. Initializing service... ');
|
logger.info('Service is not running. Initializing service... ');
|
||||||
await handler.start(
|
await handler.start(
|
||||||
entrypoint,
|
entrypoint,
|
||||||
handleUiEvent,
|
receiveUIEvent,
|
||||||
ui_events.handleIsolateEvent,
|
ui_events.handleIsolateEvent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,42 +79,48 @@ Future<void> initializeServiceIfNeeded() async {
|
|||||||
|
|
||||||
/// A middleware for packing an event into a [DataWrapper] and also
|
/// A middleware for packing an event into a [DataWrapper] and also
|
||||||
/// logging what we send.
|
/// logging what we send.
|
||||||
void sendEvent(BackgroundEvent event, { String? id }) {
|
void sendEvent(BackgroundEvent event, {String? id}) {
|
||||||
// NOTE: *S*erver to *F*oreground
|
// 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);
|
GetIt.I.get<BackgroundService>().sendEvent(event, id: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupLogging() {
|
void setupLogging() {
|
||||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||||
Logger.root.onRecord.listen((record) {
|
Logger.root.onRecord.listen((record) {
|
||||||
final logMessageHeader = '[${record.level.name}] (${record.loggerName}) ${record.time}: ';
|
final logMessageHeader =
|
||||||
var msg = record.message;
|
'[${record.level.name}] (${record.loggerName}) ${record.time}: ';
|
||||||
do {
|
var msg = record.message;
|
||||||
final tooLong = logMessageHeader.length + msg.length >= 967;
|
do {
|
||||||
final line = tooLong ? msg.substring(0, 967 - logMessageHeader.length) : msg;
|
final tooLong = logMessageHeader.length + msg.length >= 967;
|
||||||
|
final line =
|
||||||
|
tooLong ? msg.substring(0, 967 - logMessageHeader.length) : msg;
|
||||||
|
|
||||||
if (tooLong) {
|
if (tooLong) {
|
||||||
msg = msg.substring(967 - logMessageHeader.length - 2);
|
msg = msg.substring(967 - logMessageHeader.length - 2);
|
||||||
} else {
|
} else {
|
||||||
msg = '';
|
msg = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
final logMessage = logMessageHeader + line;
|
||||||
|
|
||||||
|
if (GetIt.I.isRegistered<UDPLogger>()) {
|
||||||
|
final udp = GetIt.I.get<UDPLogger>();
|
||||||
|
if (udp.isEnabled()) {
|
||||||
|
udp.sendLog(
|
||||||
|
logMessage,
|
||||||
|
record.time.millisecondsSinceEpoch,
|
||||||
|
record.level.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final logMessage = logMessageHeader + line;
|
// ignore: literal_only_boolean_expressions
|
||||||
|
if (/*kDebugMode*/ true) {
|
||||||
if (GetIt.I.isRegistered<UDPLogger>()) {
|
// ignore: avoid_print
|
||||||
final udp = GetIt.I.get<UDPLogger>();
|
print(logMessage);
|
||||||
if (udp.isEnabled()) {
|
}
|
||||||
udp.sendLog(logMessage, record.time.millisecondsSinceEpoch, record.level.name);
|
} while (msg.isNotEmpty);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore: literal_only_boolean_expressions
|
|
||||||
if (/*kDebugMode*/ true) {
|
|
||||||
// ignore: avoid_print
|
|
||||||
print(logMessage);
|
|
||||||
}
|
|
||||||
} while (msg.isNotEmpty);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,21 +145,23 @@ Future<void> initUDPLogger() async {
|
|||||||
/// The entrypoint for all platforms after the platform specific initilization is done.
|
/// The entrypoint for all platforms after the platform specific initilization is done.
|
||||||
@pragma('vm:entry-point')
|
@pragma('vm:entry-point')
|
||||||
Future<void> entrypoint() async {
|
Future<void> entrypoint() async {
|
||||||
// Register the lock
|
setupLogging();
|
||||||
GetIt.I.registerSingleton<Completer<void>>(Completer());
|
setupBackgroundEventHandler();
|
||||||
|
|
||||||
// Register singletons
|
// Register singletons
|
||||||
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
|
GetIt.I.registerSingleton<Logger>(Logger('MoxxyService'));
|
||||||
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
|
GetIt.I.registerSingleton<UDPLogger>(UDPLogger());
|
||||||
GetIt.I.registerSingleton<LanguageService>(LanguageService());
|
GetIt.I.registerSingleton<LanguageService>(LanguageService());
|
||||||
|
|
||||||
setupLogging();
|
|
||||||
setupBackgroundEventHandler();
|
|
||||||
|
|
||||||
// Initialize the database
|
// Initialize the database
|
||||||
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
|
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
|
||||||
await GetIt.I.get<DatabaseService>().initialize();
|
await GetIt.I.get<DatabaseService>().initialize();
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
GetIt.I.registerSingleton<ConnectivityWatcherService>(
|
||||||
|
ConnectivityWatcherService(),
|
||||||
|
);
|
||||||
|
GetIt.I.registerSingleton<ConnectivityService>(ConnectivityService());
|
||||||
GetIt.I.registerSingleton<PreferencesService>(PreferencesService());
|
GetIt.I.registerSingleton<PreferencesService>(PreferencesService());
|
||||||
GetIt.I.registerSingleton<BlocklistService>(BlocklistService());
|
GetIt.I.registerSingleton<BlocklistService>(BlocklistService());
|
||||||
GetIt.I.registerSingleton<NotificationsService>(NotificationsService());
|
GetIt.I.registerSingleton<NotificationsService>(NotificationsService());
|
||||||
@@ -155,67 +172,86 @@ Future<void> entrypoint() async {
|
|||||||
GetIt.I.registerSingleton<MessageService>(MessageService());
|
GetIt.I.registerSingleton<MessageService>(MessageService());
|
||||||
GetIt.I.registerSingleton<OmemoService>(OmemoService());
|
GetIt.I.registerSingleton<OmemoService>(OmemoService());
|
||||||
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
||||||
|
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||||
|
GetIt.I.registerSingleton<StickersService>(StickersService());
|
||||||
|
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
|
||||||
|
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
||||||
|
SubscriptionRequestService(),
|
||||||
|
);
|
||||||
|
GetIt.I.registerSingleton<FilesService>(FilesService());
|
||||||
|
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
||||||
final xmpp = XmppService();
|
final xmpp = XmppService();
|
||||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||||
|
|
||||||
await GetIt.I.get<NotificationsService>().init();
|
await GetIt.I.get<NotificationsService>().initialize();
|
||||||
|
await GetIt.I.get<ContactsService>().initialize();
|
||||||
|
await GetIt.I.get<ConnectivityService>().initialize();
|
||||||
|
await GetIt.I.get<ConnectivityWatcherService>().initialize();
|
||||||
|
|
||||||
if (!kDebugMode) {
|
if (!kDebugMode) {
|
||||||
final enableDebug = (await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
|
final enableDebug =
|
||||||
|
(await GetIt.I.get<PreferencesService>().getPreferences()).debugEnabled;
|
||||||
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
|
Logger.root.level = enableDebug ? Level.ALL : Level.INFO;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init the UDPLogger
|
// Init the UDPLogger
|
||||||
await initUDPLogger();
|
await initUDPLogger();
|
||||||
|
|
||||||
GetIt.I.registerSingleton<MoxxyReconnectionPolicy>(MoxxyReconnectionPolicy());
|
final connectivityManager = MoxxyConnectivityManager();
|
||||||
|
await connectivityManager.initialize();
|
||||||
final connection = XmppConnection(
|
final connection = XmppConnection(
|
||||||
GetIt.I.get<MoxxyReconnectionPolicy>(),
|
RandomBackoffReconnectionPolicy(1, 6),
|
||||||
|
connectivityManager,
|
||||||
|
ClientToServerNegotiator(),
|
||||||
MoxxyTCPSocketWrapper(),
|
MoxxyTCPSocketWrapper(),
|
||||||
)..registerManagers([
|
);
|
||||||
MoxxyStreamManagementManager(),
|
await connection.registerFeatureNegotiators([
|
||||||
MoxxyDiscoManager(),
|
ResourceBindingNegotiator(),
|
||||||
MoxxyRosterManager(),
|
StartTlsNegotiator(),
|
||||||
MoxxyOmemoManager(),
|
StreamManagementNegotiator(),
|
||||||
PingManager(),
|
CSINegotiator(),
|
||||||
MessageManager(),
|
RosterFeatureNegotiator(),
|
||||||
PresenceManager('http://moxxy.im'),
|
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
||||||
CSIManager(),
|
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
||||||
CarbonsManager(),
|
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
||||||
PubSubManager(),
|
SaslPlainNegotiator(),
|
||||||
VCardManager(),
|
]);
|
||||||
UserAvatarManager(),
|
await connection.registerManagers([
|
||||||
StableIdManager(),
|
MoxxyStreamManagementManager(),
|
||||||
MessageDeliveryReceiptManager(),
|
DiscoManager([
|
||||||
ChatMarkerManager(),
|
const Identity(category: 'client', type: 'phone', name: 'Moxxy'),
|
||||||
OOBManager(),
|
]),
|
||||||
SFSManager(),
|
RosterManager(MoxxyRosterStateManager()),
|
||||||
MessageRepliesManager(),
|
MoxxyOmemoManager(),
|
||||||
BlockingManager(),
|
PingManager(const Duration(minutes: 3)),
|
||||||
ChatStateManager(),
|
MessageManager(),
|
||||||
HttpFileUploadManager(),
|
PresenceManager(),
|
||||||
FileUploadNotificationManager(),
|
EntityCapabilitiesManager('http://moxxy.im'),
|
||||||
EmeManager(),
|
CSIManager(),
|
||||||
CryptographicHashManager(),
|
CarbonsManager(),
|
||||||
DelayedDeliveryManager(),
|
PubSubManager(),
|
||||||
MessageRetractionManager(),
|
VCardManager(),
|
||||||
])
|
UserAvatarManager(),
|
||||||
..registerFeatureNegotiators([
|
StableIdManager(),
|
||||||
ResourceBindingNegotiator(),
|
MessageDeliveryReceiptManager(),
|
||||||
StartTlsNegotiator(),
|
ChatMarkerManager(),
|
||||||
StreamManagementNegotiator(),
|
OOBManager(),
|
||||||
CSINegotiator(),
|
SFSManager(),
|
||||||
RosterFeatureNegotiator(),
|
MessageRepliesManager(),
|
||||||
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
BlockingManager(),
|
||||||
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
ChatStateManager(),
|
||||||
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
HttpFileUploadManager(),
|
||||||
SaslPlainNegotiator(),
|
FileUploadNotificationManager(),
|
||||||
]);
|
EmeManager(),
|
||||||
|
CryptographicHashManager(),
|
||||||
|
DelayedDeliveryManager(),
|
||||||
|
MessageRetractionManager(),
|
||||||
|
LastMessageCorrectionManager(),
|
||||||
|
MessageReactionsManager(),
|
||||||
|
StickersManager(),
|
||||||
|
]);
|
||||||
|
|
||||||
GetIt.I.registerSingleton<XmppConnection>(connection);
|
GetIt.I.registerSingleton<XmppConnection>(connection);
|
||||||
GetIt.I.registerSingleton<ConnectivityWatcherService>(ConnectivityWatcherService());
|
|
||||||
GetIt.I.registerSingleton<ConnectivityService>(ConnectivityService());
|
|
||||||
await GetIt.I.get<ConnectivityService>().initialize();
|
|
||||||
|
|
||||||
GetIt.I.get<Logger>().finest('Done with xmpp');
|
GetIt.I.get<Logger>().finest('Done with xmpp');
|
||||||
|
|
||||||
@@ -229,26 +265,37 @@ Future<void> entrypoint() async {
|
|||||||
|
|
||||||
GetIt.I.get<Logger>().finest('Got settings');
|
GetIt.I.get<Logger>().finest('Got settings');
|
||||||
if (settings != null) {
|
if (settings != null) {
|
||||||
unawaited(GetIt.I.get<OmemoService>().initializeIfNeeded(settings.jid.toBare().toString()));
|
unawaited(
|
||||||
|
GetIt.I
|
||||||
|
.get<OmemoService>()
|
||||||
|
.initializeIfNeeded(settings.jid.toBare().toString()),
|
||||||
|
);
|
||||||
|
|
||||||
// The title of the notification will be changed as soon as the connection state
|
// The title of the notification will be changed as soon as the connection state
|
||||||
// of [XmppConnection] changes.
|
// of [XmppConnection] changes.
|
||||||
await connection.getManagerById<MoxxyStreamManagementManager>(smManager)!.loadState();
|
await connection
|
||||||
|
.getManagerById<MoxxyStreamManagementManager>(smManager)!
|
||||||
|
.loadState();
|
||||||
await xmpp.connect(settings, false);
|
await xmpp.connect(settings, false);
|
||||||
} else {
|
} else {
|
||||||
GetIt.I.get<BackgroundService>().setNotification(
|
GetIt.I.get<BackgroundService>().setNotification(
|
||||||
'Moxxy',
|
'Moxxy',
|
||||||
t.notifications.permanent.idle,
|
t.notifications.permanent.idle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetIt.I.get<Logger>().finest('Resolving startup future');
|
unawaited(
|
||||||
GetIt.I.get<Completer<void>>().complete();
|
GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock(),
|
||||||
|
);
|
||||||
sendEvent(ServiceReadyEvent());
|
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
|
// NOTE: *F*oreground to *S*ervice
|
||||||
final log = GetIt.I.get<Logger>();
|
final log = GetIt.I.get<Logger>();
|
||||||
|
|
||||||
|
|||||||
469
lib/service/stickers.dart
Normal file
469
lib/service/stickers.dart
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'package:archive/archive.dart';
|
||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:logging/logging.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/service/database/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/files.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||||
|
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||||
|
import 'package:moxxyv2/service/preferences.dart';
|
||||||
|
import 'package:moxxyv2/service/service.dart';
|
||||||
|
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||||
|
import 'package:moxxyv2/shared/events.dart';
|
||||||
|
import 'package:moxxyv2/shared/helpers.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/file_metadata.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 db = GetIt.I.get<DatabaseService>().database;
|
||||||
|
final rawPack = await db.query(
|
||||||
|
stickerPacksTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
limit: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rawPack.isEmpty) return null;
|
||||||
|
|
||||||
|
final rawStickers = await db.rawQuery(
|
||||||
|
'''
|
||||||
|
SELECT
|
||||||
|
sticker.*,
|
||||||
|
fm.id AS fm_id,
|
||||||
|
fm.path AS fm_path,
|
||||||
|
fm.sourceUrls AS fm_sourceUrls,
|
||||||
|
fm.mimeType AS fm_mimeType,
|
||||||
|
fm.thumbnailType AS fm_thumbnailType,
|
||||||
|
fm.thumbnailData AS fm_thumbnailData,
|
||||||
|
fm.width AS fm_width,
|
||||||
|
fm.height AS fm_height,
|
||||||
|
fm.plaintextHashes AS fm_plaintextHashes,
|
||||||
|
fm.encryptionKey AS fm_encryptionKey,
|
||||||
|
fm.encryptionIv AS fm_encryptionIv,
|
||||||
|
fm.encryptionScheme AS fm_encryptionScheme,
|
||||||
|
fm.cipherTextHashes AS fm_cipherTextHashes,
|
||||||
|
fm.filename AS fm_filename,
|
||||||
|
fm.size AS fm_size
|
||||||
|
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||||
|
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id;
|
||||||
|
''',
|
||||||
|
[id],
|
||||||
|
);
|
||||||
|
|
||||||
|
_stickerPacks[id] = StickerPack.fromDatabaseJson(
|
||||||
|
rawPack.first,
|
||||||
|
rawStickers.map((sticker) {
|
||||||
|
return Sticker.fromDatabaseJson(
|
||||||
|
sticker,
|
||||||
|
FileMetadata.fromDatabaseJson(
|
||||||
|
getPrefixedSubMap(sticker, 'fm_'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return _stickerPacks[id]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<StickerPack>> getStickerPacks() async {
|
||||||
|
if (_stickerPacks.isEmpty) {
|
||||||
|
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
|
||||||
|
stickerPacksTable,
|
||||||
|
columns: ['id'],
|
||||||
|
);
|
||||||
|
for (final rawPack in rawPackIds) {
|
||||||
|
final id = rawPack['id']! as String;
|
||||||
|
await getStickerPackById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_log.finest('Got ${_stickerPacks.length} sticker packs');
|
||||||
|
return _stickerPacks.values.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeStickerPack(String id) async {
|
||||||
|
final pack = await getStickerPackById(id);
|
||||||
|
assert(pack != null, 'The sticker pack must exist');
|
||||||
|
|
||||||
|
// Delete the files
|
||||||
|
for (final sticker in pack!.stickers) {
|
||||||
|
if (sticker.fileMetadata.path == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||||
|
sticker.fileMetadata.id,
|
||||||
|
path: null,
|
||||||
|
);
|
||||||
|
final file = File(sticker.fileMetadata.path!);
|
||||||
|
if (file.existsSync()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from the database
|
||||||
|
await GetIt.I.get<DatabaseService>().database.delete(
|
||||||
|
stickerPacksTable,
|
||||||
|
where: 'id = ?',
|
||||||
|
whereArgs: [id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove from the cache
|
||||||
|
_stickerPacks.remove(id);
|
||||||
|
|
||||||
|
// Retract from PubSub
|
||||||
|
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||||
|
final result = await GetIt.I
|
||||||
|
.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<XmppStateService>().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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> _addStickerPackFromData(StickerPack pack) async {
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
stickerPacksTable,
|
||||||
|
pack.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Sticker> _addStickerFromData(
|
||||||
|
String id,
|
||||||
|
String stickerPackId,
|
||||||
|
String desc,
|
||||||
|
Map<String, String> suggests,
|
||||||
|
FileMetadata fileMetadata,
|
||||||
|
) async {
|
||||||
|
final s = Sticker(
|
||||||
|
id,
|
||||||
|
stickerPackId,
|
||||||
|
desc,
|
||||||
|
suggests,
|
||||||
|
fileMetadata,
|
||||||
|
);
|
||||||
|
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
stickersTable,
|
||||||
|
s.toDatabaseJson(),
|
||||||
|
);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StickerPack?> installFromPubSub(StickerPack remotePack) async {
|
||||||
|
assert(!remotePack.local, 'Sticker pack must be remote');
|
||||||
|
|
||||||
|
var success = true;
|
||||||
|
final stickers = List<Sticker>.from(remotePack.stickers);
|
||||||
|
for (var i = 0; i < stickers.length; i++) {
|
||||||
|
final sticker = stickers[i];
|
||||||
|
final stickerPath = await computeCachedPathForFile(
|
||||||
|
sticker.fileMetadata.filename,
|
||||||
|
sticker.fileMetadata.plaintextHashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get file metadata
|
||||||
|
final fileMetadataRaw =
|
||||||
|
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||||
|
MediaFileLocation(
|
||||||
|
sticker.fileMetadata.sourceUrls!,
|
||||||
|
p.basename(stickerPath),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sticker.fileMetadata.plaintextHashes,
|
||||||
|
null,
|
||||||
|
sticker.fileMetadata.size,
|
||||||
|
),
|
||||||
|
sticker.fileMetadata.mimeType,
|
||||||
|
sticker.fileMetadata.size,
|
||||||
|
sticker.fileMetadata.width != null &&
|
||||||
|
sticker.fileMetadata.height != null
|
||||||
|
? Size(
|
||||||
|
sticker.fileMetadata.width!.toDouble(),
|
||||||
|
sticker.fileMetadata.height!.toDouble(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
path: stickerPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fileMetadataRaw.retrieved) {
|
||||||
|
final downloadStatusCode = await downloadFile(
|
||||||
|
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
|
||||||
|
stickerPath,
|
||||||
|
(_, __) {},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isRequestOkay(downloadStatusCode)) {
|
||||||
|
_log.severe('Request not okay: $downloadStatusCode');
|
||||||
|
success = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stickers[i] = await _addStickerFromData(
|
||||||
|
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
remotePack.hashValue,
|
||||||
|
sticker.desc,
|
||||||
|
sticker.suggests,
|
||||||
|
fileMetadataRaw.fileMetadata,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
_log.severe('Import failed');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the sticker pack to the database
|
||||||
|
await _addStickerPackFromData(remotePack);
|
||||||
|
|
||||||
|
// Publish but don't block
|
||||||
|
unawaited(
|
||||||
|
_publishStickerPack(remotePack.toMoxxmpp()),
|
||||||
|
);
|
||||||
|
|
||||||
|
return remotePack.copyWith(
|
||||||
|
stickers: stickers,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
moxxmpp.StickerPack packRaw;
|
||||||
|
try {
|
||||||
|
final content = utf8.decode(metadata.content as List<int>);
|
||||||
|
final node = moxxmpp.XMLNode.fromString(content);
|
||||||
|
packRaw = moxxmpp.StickerPack.fromXML(
|
||||||
|
'',
|
||||||
|
node,
|
||||||
|
hashAvailable: false,
|
||||||
|
);
|
||||||
|
} catch (ex) {
|
||||||
|
_log.severe('Invalid sticker pack description: $ex');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Create the sticker pack first
|
||||||
|
final stickerPack = StickerPack(
|
||||||
|
pack.hashValue,
|
||||||
|
pack.name,
|
||||||
|
pack.summary,
|
||||||
|
[],
|
||||||
|
pack.hashAlgorithm.toName(),
|
||||||
|
pack.hashValue,
|
||||||
|
pack.restricted,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await _addStickerPackFromData(stickerPack);
|
||||||
|
|
||||||
|
// Add all stickers
|
||||||
|
final stickers = List<Sticker>.empty(growable: true);
|
||||||
|
for (final sticker in pack.stickers) {
|
||||||
|
// Get the "path" to the sticker
|
||||||
|
final stickerPath = await computeCachedPathForFile(
|
||||||
|
sticker.metadata.name!,
|
||||||
|
sticker.metadata.hashes,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get metadata
|
||||||
|
final urlSources = sticker.sources
|
||||||
|
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||||
|
.map((src) => src.url)
|
||||||
|
.toList();
|
||||||
|
final fileMetadataRaw = await GetIt.I
|
||||||
|
.get<FilesService>()
|
||||||
|
.createFileMetadataIfRequired(
|
||||||
|
MediaFileLocation(
|
||||||
|
urlSources,
|
||||||
|
p.basename(stickerPath),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
sticker.metadata.hashes,
|
||||||
|
null,
|
||||||
|
sticker.metadata.size,
|
||||||
|
),
|
||||||
|
sticker.metadata.mediaType,
|
||||||
|
sticker.metadata.size,
|
||||||
|
sticker.metadata.width != null && sticker.metadata.height != null
|
||||||
|
? Size(
|
||||||
|
sticker.metadata.width!.toDouble(),
|
||||||
|
sticker.metadata.height!.toDouble(),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
// TODO(Unknown): Maybe consider the thumbnails one day
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
path: stickerPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only copy the sticker to storage if we don't already have it
|
||||||
|
if (!fileMetadataRaw.retrieved) {
|
||||||
|
final stickerFile = archive.findFile(sticker.metadata.name!)!;
|
||||||
|
await File(stickerPath).writeAsBytes(
|
||||||
|
stickerFile.content as List<int>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
stickers.add(
|
||||||
|
await _addStickerFromData(
|
||||||
|
getStrongestHashFromMap(sticker.metadata.hashes) ??
|
||||||
|
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||||
|
pack.hashValue,
|
||||||
|
sticker.metadata.desc!,
|
||||||
|
sticker.suggests,
|
||||||
|
fileMetadataRaw.fileMetadata,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
95
lib/service/subscription.dart
Normal file
95
lib/service/subscription.dart
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxxmpp/moxxmpp.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
|
||||||
|
class SubscriptionRequestService {
|
||||||
|
List<String>? _subscriptionRequests;
|
||||||
|
|
||||||
|
final Lock _lock = Lock();
|
||||||
|
|
||||||
|
/// Only load data from the database into
|
||||||
|
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
|
||||||
|
/// been loaded.
|
||||||
|
Future<void> _loadSubscriptionRequestsIfNeeded() async {
|
||||||
|
await _lock.synchronized(() async {
|
||||||
|
_subscriptionRequests ??= List<String>.from(
|
||||||
|
(await GetIt.I
|
||||||
|
.get<DatabaseService>()
|
||||||
|
.database
|
||||||
|
.query(subscriptionsTable))
|
||||||
|
.map((m) => m['jid']! as String)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<String>> getSubscriptionRequests() async {
|
||||||
|
await _loadSubscriptionRequestsIfNeeded();
|
||||||
|
return _subscriptionRequests!;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addSubscriptionRequest(String jid) async {
|
||||||
|
await _loadSubscriptionRequestsIfNeeded();
|
||||||
|
|
||||||
|
await _lock.synchronized(() async {
|
||||||
|
if (!_subscriptionRequests!.contains(jid)) {
|
||||||
|
_subscriptionRequests!.add(jid);
|
||||||
|
|
||||||
|
await GetIt.I.get<DatabaseService>().database.insert(
|
||||||
|
subscriptionsTable,
|
||||||
|
{
|
||||||
|
'jid': jid,
|
||||||
|
},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeSubscriptionRequest(String jid) async {
|
||||||
|
await _loadSubscriptionRequestsIfNeeded();
|
||||||
|
|
||||||
|
await _lock.synchronized(() async {
|
||||||
|
if (_subscriptionRequests!.contains(jid)) {
|
||||||
|
_subscriptionRequests!.remove(jid);
|
||||||
|
await GetIt.I.get<DatabaseService>().database.delete(
|
||||||
|
subscriptionsTable,
|
||||||
|
where: 'jid = ?',
|
||||||
|
whereArgs: [jid],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> hasPendingSubscriptionRequest(String jid) async {
|
||||||
|
return (await getSubscriptionRequests()).contains(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
PresenceManager get _presence =>
|
||||||
|
GetIt.I.get<XmppConnection>().getPresenceManager()!;
|
||||||
|
|
||||||
|
/// Accept a subscription request from [jid].
|
||||||
|
Future<void> acceptSubscriptionRequest(String jid) async {
|
||||||
|
_presence.sendSubscriptionRequestApproval(jid);
|
||||||
|
await removeSubscriptionRequest(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject a subscription request from [jid].
|
||||||
|
Future<void> rejectSubscriptionRequest(String jid) async {
|
||||||
|
_presence.sendSubscriptionRequestRejection(jid);
|
||||||
|
await removeSubscriptionRequest(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a subscription request to [jid].
|
||||||
|
void sendSubscriptionRequest(String jid) {
|
||||||
|
_presence.sendSubscriptionRequest(jid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a presence subscription with [jid].
|
||||||
|
void sendUnsubscriptionRequest(String jid) {
|
||||||
|
_presence.sendUnsubscriptionRequest(jid);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
39
lib/service/xmpp_state.dart
Normal file
39
lib/service/xmpp_state.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:moxxyv2/service/database/constants.dart';
|
||||||
|
import 'package:moxxyv2/service/database/database.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
||||||
|
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||||
|
|
||||||
|
class XmppStateService {
|
||||||
|
/// Persistent state around the connection, like the SM token, etc.
|
||||||
|
XmppState? _state;
|
||||||
|
|
||||||
|
Future<XmppState> getXmppState() async {
|
||||||
|
if (_state != null) return _state!;
|
||||||
|
|
||||||
|
final json = <String, String?>{};
|
||||||
|
final rowsRaw =
|
||||||
|
await GetIt.I.get<DatabaseService>().database.query(xmppStateTable);
|
||||||
|
for (final row in rowsRaw) {
|
||||||
|
json[row['key']! as String] = row['value'] as String?;
|
||||||
|
}
|
||||||
|
|
||||||
|
_state = XmppState.fromDatabaseTuples(json);
|
||||||
|
return _state!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A wrapper to modify the [XmppState] and commit it.
|
||||||
|
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
|
||||||
|
_state = func(_state!);
|
||||||
|
|
||||||
|
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||||
|
for (final tuple in _state!.toDatabaseTuples().entries) {
|
||||||
|
batch.insert(
|
||||||
|
xmppStateTable,
|
||||||
|
<String, String?>{'key': tuple.key, 'value': tuple.value},
|
||||||
|
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await batch.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:path/path.dart' as pathlib;
|
import 'package:path/path.dart' as pathlib;
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
/// Save the bytes [bytes] that represent the user's avatar under
|
/// Save the bytes [bytes] that represent the user's avatar under
|
||||||
/// the [cache directory]/users/[jid]/avatar_[hash].png.
|
/// the [cache directory]/users/[jid]/avatar_[hash].png.
|
||||||
/// [cache directory] is provided by path_provider.
|
/// [cache directory] is provided by path_provider.
|
||||||
Future<String> saveAvatarInCache(List<int> bytes, String hash, String jid, String oldPath) async {
|
Future<String> saveAvatarInCache(
|
||||||
|
List<int> bytes,
|
||||||
|
String hash,
|
||||||
|
String jid,
|
||||||
|
String oldPath,
|
||||||
|
) async {
|
||||||
final cacheDir = (await getApplicationDocumentsDirectory()).path;
|
final cacheDir = (await getApplicationDocumentsDirectory()).path;
|
||||||
final avatarsDir = Directory(pathlib.join(cacheDir, 'avatars'));
|
final avatarsDir = Directory(pathlib.join(cacheDir, 'avatars'));
|
||||||
await avatarsDir.create(recursive: true);
|
await avatarsDir.create(recursive: true);
|
||||||
|
|||||||
@@ -24,15 +24,15 @@ abstract class Cache<K, V> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LRUCacheEntry<V> {
|
class _LRUCacheEntry<V> {
|
||||||
|
|
||||||
const _LRUCacheEntry(this.value, this.t);
|
const _LRUCacheEntry(this.value, this.t);
|
||||||
final int t;
|
final int t;
|
||||||
final V value;
|
final V value;
|
||||||
}
|
}
|
||||||
|
|
||||||
class LRUCache<K, V> extends Cache<K, V> {
|
class LRUCache<K, V> extends Cache<K, V> {
|
||||||
|
LRUCache(this._maxSize)
|
||||||
LRUCache(this._maxSize) : _cache = {}, _t = 0;
|
: _cache = {},
|
||||||
|
_t = 0;
|
||||||
final Map<K, _LRUCacheEntry<V>> _cache;
|
final Map<K, _LRUCacheEntry<V>> _cache;
|
||||||
final int _maxSize;
|
final int _maxSize;
|
||||||
int _t;
|
int _t;
|
||||||
@@ -48,6 +48,13 @@ class LRUCache<K, V> extends Cache<K, V> {
|
|||||||
@override
|
@override
|
||||||
List<V> getValues() => _cache.values.map((i) => i.value).toList();
|
List<V> getValues() => _cache.values.map((i) => i.value).toList();
|
||||||
|
|
||||||
|
void replaceValue(K key, V newValue) {
|
||||||
|
_cache[key] = _LRUCacheEntry(
|
||||||
|
newValue,
|
||||||
|
_cache[key]!.t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void cache(K key, V value) {
|
void cache(K key, V value) {
|
||||||
if (_cache.length + 1 <= _maxSize) {
|
if (_cache.length + 1 <= _maxSize) {
|
||||||
|
|||||||
@@ -2,5 +2,7 @@ import 'package:moxlib/awaitabledatasender.dart';
|
|||||||
import 'package:moxplatform/moxplatform.dart';
|
import 'package:moxplatform/moxplatform.dart';
|
||||||
import 'package:moxxyv2/shared/models/message.dart';
|
import 'package:moxxyv2/shared/models/message.dart';
|
||||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||||
|
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||||
|
|
||||||
part 'commands.moxxy.dart';
|
part 'commands.moxxy.dart';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user