Compare commits
358 Commits
v0.4.1
...
e205785246
| Author | SHA1 | Date | |
|---|---|---|---|
| e205785246 | |||
| 3edda978fb | |||
|
|
ebabb0e445 | ||
| 117d263e25 | |||
| 63e66e5dce | |||
| 140a16ec0c | |||
| 44b95bbb5b | |||
| 4eeaa8c37b | |||
| c95f2efd65 | |||
| 9dbf4b5467 | |||
| 0a120f1073 | |||
|
|
269738e618 | ||
| 1e94910ebd | |||
| 63d251a7f1 | |||
| 799af75bcc | |||
| 8966c490fe | |||
| 4c09dbab60 | |||
| 6fd5c33b0a | |||
| 30e8a885bb | |||
| 42c695a2a1 | |||
| 3ef2f3b8d6 | |||
| ae995b8670 | |||
| 75c2f103bd | |||
| bc7958559a | |||
|
|
11228a0de0 | ||
| b6fe5cc29b | |||
| cb34b51149 | |||
| 6c9421a21a | |||
| 684a3a658d | |||
| 0ab7cccef6 | |||
| c405717bcc | |||
| 7dcd14ef3a | |||
| 4f033438ed | |||
| 21e40c38ca | |||
| 53e3b3c561 | |||
| d6416c77b8 | |||
| 9ade143352 | |||
| dc05876ac7 | |||
| d691d63353 | |||
| b666a06252 | |||
| 27b209b4d8 | |||
| d0cdb2cebe | |||
| 2123f5b51f | |||
| 93511da3f1 | |||
| 267eef2a55 | |||
| 8f69b9ff3d | |||
| 0d182cc4e5 | |||
| f6bfbff62c | |||
| dd0cb88841 | |||
| 23ed1f6b1a | |||
| d12154b4ba | |||
| b625ee5c4e | |||
| 4e48962fae | |||
| 21832042df | |||
| d48c8371e4 | |||
| 67f6fb8236 | |||
| 369cc3e823 | |||
| 4857245a96 | |||
|
|
2f39d4b60b | ||
|
|
0528aca3ad | ||
| d99e5d8801 | |||
| 5cda06db24 | |||
| c93c3140cf | |||
| f17bba7282 | |||
| fe3dbd265e | |||
| e6eee134bd | |||
| 1f5c75a647 | |||
| d70527d65f | |||
| efb3f4b371 | |||
| 71f5e8f0b4 | |||
| a13bdd2e2b | |||
| 7fbc1ba812 | |||
| 7f864f1d25 | |||
|
|
ef3c11e870 | ||
|
|
c20bc964c3 | ||
| dd2629d073 | |||
| e5fa199925 | |||
| 675a647a8d | |||
| f845c4134c | |||
| 6442e9cab5 | |||
| 0629a3d5bd | |||
| 62d18588d7 | |||
|
|
4ee191e238 | ||
| 351de5ee93 | |||
| dae8ddb3b5 | |||
| 631a62e8ce | |||
| 59877a3e60 | |||
| 08da843d50 | |||
| 949781003a | |||
| 4338c7a777 | |||
| 86de5cd22d | |||
| bf754a4e51 | |||
| 8913977c0a | |||
| f4be336e39 | |||
| 836fe1bf87 | |||
| 7bca95203e | |||
| 059a22cbe8 | |||
| 2740692772 | |||
| c0008fece9 | |||
| d7bb54a088 | |||
| eb41b3f0f7 | |||
| e3cbc47cc8 | |||
| 75767d26b4 | |||
| a01667a8f7 | |||
| e4dec4168c | |||
| 59f1a3a289 | |||
| 9c8aec6543 | |||
| 7c8a368d73 | |||
| 0bda382e40 | |||
| 330b4dd69f | |||
| 7a7e43eb3c | |||
| 5e797d6b54 | |||
| 1b3dd0634b | |||
| b1bdacb834 | |||
| a4b9485019 | |||
| 20489fbb25 | |||
| a2fa000a31 | |||
| 343f0e7dae | |||
| f0f13097c0 | |||
| 0025e83be5 | |||
| ffb8e9f3fc | |||
| 8081931c55 | |||
| 792276d06a | |||
| 58edc256fd | |||
| f30d04a593 | |||
| cc42f32b21 | |||
| 353623c5ae | |||
| a09c30a076 | |||
| 3bfd72fde1 | |||
| 39e6b4a48b | |||
| 32b2e35d42 | |||
| 8e1bcbfd1d | |||
| 336a6d56cd | |||
| a283454cae | |||
| 8b16a8a37b | |||
| 727a1a3423 | |||
| 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 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -62,4 +62,4 @@ lib/i18n/*.dart
|
||||
.android
|
||||
|
||||
# Build scripts
|
||||
release/
|
||||
release-*/
|
||||
|
||||
2
.gitlint
2
.gitlint
@@ -7,7 +7,7 @@ line-length=72
|
||||
[title-trailing-punctuation]
|
||||
[title-hard-tab]
|
||||
[title-match-regex]
|
||||
regex=^((feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\)|release): .*$
|
||||
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]
|
||||
|
||||
101
CONTRIBUTING.md
Normal file
101
CONTRIBUTING.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Contribution Guide
|
||||
|
||||
Thanks for your interest in the Moxxy XMPP client! This document contains guidelines and guides for working
|
||||
on the Moxxy codebase.
|
||||
|
||||
## Non-Code Contributions
|
||||
### Translations
|
||||
|
||||
You can contribute to Moxxy by translating parts of Moxxy into a language you can speak. To do that, head over to [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/), where you can start translating.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before building or working on Moxxy, please make sure that your development environment is correctly set up.
|
||||
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.
|
||||
|
||||
### Tips
|
||||
#### `data_classes.yaml`
|
||||
|
||||
When you add, remove, or modify data classes in `data_classes.yaml`, you need to rebuild the classes using `flutter pub run build_runner build`. However, there appears
|
||||
to be a bug in my own build runner script, which prevents the data classes from being
|
||||
rebuilt if they are changed. To fix this, remove the generated data classes by running
|
||||
`rm lib/shared/*.moxxy.dart`, after which build_runner will rebuild the data classes.
|
||||
|
||||
### Code Guidelines
|
||||
#### Translations
|
||||
|
||||
If your code adds new strings that should be translated, only add them to the base
|
||||
language, which is English. Even if you know more than English, do not add the keys
|
||||
to other language files. To prevent merge conflicts between Weblate and the repository,
|
||||
all other languages are managed via [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/).
|
||||
|
||||
#### Commit messages
|
||||
|
||||
Commit messages should be uniformly formatted. `gitlint` is a linter for commit messages that enforces those guidelines. They are defined in the `.gitlint` file
|
||||
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,38 +2,29 @@
|
||||
|
||||
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
|
||||
|
||||
[<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)
|
||||
|
||||
## Developing and Building
|
||||
## Building and Contributing
|
||||
|
||||
Clone using `git clone --recursive https://github.com/Polynomdivision/moxxyv2.git`.
|
||||
|
||||
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.
|
||||
For build and contribution guidelines, please refer to [`CONTRIBUTING.md`](./CONTRIBUTING.md)
|
||||
|
||||
Also, feel free to join the development chat at `moxxy@muc.moxxy.org`.
|
||||
|
||||
### Translating
|
||||
|
||||
If you want to contribute by translating Moxxy, you can do that on [Codeberg's Weblate instance](https://translate.codeberg.org/projects/moxxy/moxxy/).
|
||||
|
||||
[](https://translate.codeberg.org/engage/moxxy/)
|
||||
|
||||
## A Bit of History
|
||||
|
||||
This project is the successor of moxxyv1, which was written in *React Native* and abandoned
|
||||
|
||||
@@ -6,13 +6,11 @@ linter:
|
||||
use_setters_to_change_properties: false
|
||||
avoid_positional_boolean_parameters: false
|
||||
avoid_bool_literals_in_conditional_expressions: false
|
||||
file_names: false
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
- "**/*.g.dart"
|
||||
- "**/*.freezed.dart"
|
||||
- "**/*.moxxy.dart"
|
||||
- "test/"
|
||||
- "integration_test/"
|
||||
- "lib/service/database/migrations/*.dart"
|
||||
- "lib/i18n/*.dart"
|
||||
|
||||
@@ -38,6 +38,14 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Enable usage of direct share -->
|
||||
<meta-data
|
||||
android:name="android.service.chooser.chooser_target_service"
|
||||
android:value="androidx.sharetarget.ChooserTargetServiceCompat" />
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/share_targets" />
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
7
android/app/src/main/res/xml/share_targets.xml
Normal file
7
android/app/src/main/res/xml/share_targets.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<share-target android:targetClass="org.moxxy.moxxyv2.MainActivity">
|
||||
<data android:mimeType="*/*" />
|
||||
<category android:name="org.moxxy.moxxyv2.dynamic_share_target" />
|
||||
</share-target>
|
||||
</shortcuts>
|
||||
@@ -1,5 +1,5 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.6.10'
|
||||
ext.kotlin_version = '1.8.21'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
@@ -26,6 +26,6 @@ subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
{
|
||||
"@@name": "English",
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"moxxySubtitle": "An experiment into building a modern, easy and beautiful XMPP client.",
|
||||
"dialogAccept": "Okay",
|
||||
"dialogCancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"idle": "Idle",
|
||||
"ready": "Ready to receive messages",
|
||||
"connecting": "Connecting...",
|
||||
"disconnect": "Disconnected",
|
||||
"error": "Error"
|
||||
},
|
||||
"message": {
|
||||
"reply": "Reply",
|
||||
"markAsRead": "Mark as read"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Messages",
|
||||
"messagesChannelDescription": "The notification channel for received messages",
|
||||
"warningChannelName": "Warnings",
|
||||
"warningChannelDescription": "Warnings related to Moxxy"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Just now",
|
||||
"nMinutesAgo": "${min}min ago",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Tue",
|
||||
"wednessdayAbbrev": "Wed",
|
||||
"thursdayAbbrev": "Thu",
|
||||
"fridayAbbrev": "Fri",
|
||||
"saturdayAbbrev": "Sat",
|
||||
"sundayAbbrev": "Sun",
|
||||
"january": "January",
|
||||
"february": "February",
|
||||
"march": "March",
|
||||
"april": "April",
|
||||
"may": "May",
|
||||
"june": "June",
|
||||
"july": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "File",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "The message has been retracted",
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||
"you": "You"
|
||||
},
|
||||
"errors": {
|
||||
"omemo": {
|
||||
"couldNotPublish": "Could not publish the cryptographic identity to the server. This means that end-to-end encryption may not work.",
|
||||
"notEncryptedForDevice": "This message was not encrypted for this device",
|
||||
"invalidHmac": "Could not decrypt message",
|
||||
"noDecryptionKey": "No decryption key available",
|
||||
"messageInvalidAfixElement": "Invalid encrypted message",
|
||||
|
||||
"verificationInvalidOmemoUrl": "Invalid OMEMO:2 fingerprint",
|
||||
"verificationWrongJid": "Wrong XMPP-address",
|
||||
"verificationWrongDevice": "Wrong OMEMO:2 device",
|
||||
"verificationNotInList": "Wrong OMEMO:2 device",
|
||||
"verificationWrongFingerprint": "Wrong OMEMO:2 fingerprint"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Could not connect to server"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Invalid login credentials",
|
||||
"startTlsFailed": "Failed to establish a secure connection",
|
||||
"noConnection": "Failed to establish a connection",
|
||||
"unspecified": "Unspecified error"
|
||||
},
|
||||
"message": {
|
||||
"unspecified": "Unknown error",
|
||||
"fileUploadFailed": "The file upload failed",
|
||||
"contactDoesntSupportOmemo": "The contact does not support encryption using OMEMO:2",
|
||||
"fileDownloadFailed": "The file download failed",
|
||||
"serviceUnavailable": "The message could not be delivered to the contact",
|
||||
"remoteServerTimeout": "The message could not be delivered to the contact's server",
|
||||
"remoteServerNotFound": "The message could not be delivered to the contact's server as it cannot be found",
|
||||
"failedToEncrypt": "The message could not be encrypted",
|
||||
"failedToEncryptFile": "The file could not be encrypted",
|
||||
"failedToDecryptFile": "The file could not be decrypted",
|
||||
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Failed to finalize audio recording",
|
||||
"openFileNoAppError": "No app found to open this file",
|
||||
"openFileGenericError": "Failed to open file"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Could not verify file integrity"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Hold button longer to record a voice message"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"noAccount": "Have no XMPP account? No worries, creating one is really easy.",
|
||||
"loginButton": "Login",
|
||||
"registerButton": "Register"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"xmppAddress": "XMPP-Address",
|
||||
"password": "Password",
|
||||
"advancedOptions": "Advanced options",
|
||||
"createAccount": "Create account on server"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "New chat",
|
||||
"speeddialJoinGroupchat": "Join groupchat",
|
||||
"overlaySettings": "Settings",
|
||||
"noOpenChats": "You have no open chats",
|
||||
"startChat": "Start a chat",
|
||||
"closeChat": "Close chat",
|
||||
"closeChatBody": "Are you sure you want to close the chat with ${conversationTitle}?",
|
||||
"markAsRead": "Mark as read"
|
||||
},
|
||||
"conversation": {
|
||||
"unencrypted": "Unencrypted",
|
||||
"encrypted": "Encrypted",
|
||||
"closeChat": "Close chat",
|
||||
"closeChatConfirmTitle": "Close chat",
|
||||
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
||||
"blockShort": "Block",
|
||||
"blockUser": "Block user",
|
||||
"online": "Online",
|
||||
"retract": "Retract message",
|
||||
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
|
||||
"forward": "Forward",
|
||||
"edit": "Edit",
|
||||
"quote": "Quote",
|
||||
"copy": "Copy content",
|
||||
"addReaction": "Add reaction",
|
||||
"showError": "Show error",
|
||||
"showWarning": "Show warning",
|
||||
"addToContacts": "Add to contacts",
|
||||
"addToContactsTitle": "Add ${jid} to contacts",
|
||||
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
|
||||
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
||||
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
||||
"stickerSettings": "Sticker settings",
|
||||
"newDeviceMessage": "${title} added a new encryption device",
|
||||
"messageHint": "Send a message...",
|
||||
"sendImages": "Send images",
|
||||
"sendFiles": "Send files",
|
||||
"takePhotos": "Take photos"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Add new contact",
|
||||
"xmppAddress": "XMPP-Address",
|
||||
"subtitle": "You can add a contact either by typing in their XMPP address or by scanning their QR code",
|
||||
"buttonAddToContact": "Add to contacts"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Start new chat",
|
||||
"addContact": "Add contact",
|
||||
"createGroupchat": "Create groupchat"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Set as profile picture"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Share with...",
|
||||
"confirmTitle": "Send file",
|
||||
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Security"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Notifications",
|
||||
"notificationsMuted": "Muted",
|
||||
"notificationsEnabled": "Enabled",
|
||||
"sharedMedia": "Media"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Own Devices",
|
||||
"thisDevice": "This device",
|
||||
"otherDevices": "Other devices",
|
||||
"deleteDeviceConfirmTitle": "Delete device",
|
||||
"deleteDeviceConfirmBody": "This means that contacts will not be able to encrypt for that device. Continue?",
|
||||
"recreateOwnSessions": "Rebuild sessions",
|
||||
"recreateOwnSessionsConfirmTitle": "Recreate own sessions?",
|
||||
"recreateOwnSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
||||
"recreateOwnDevice": "Recreate device",
|
||||
"recreateOwnDeviceConfirmTitle": "Recreate own device?",
|
||||
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Devices",
|
||||
"recreateSessions": "Rebuild sessions",
|
||||
"recreateSessionsConfirmTitle": "Rebuild sessions?",
|
||||
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Blocklist",
|
||||
"noUsersBlocked": "You have no users blocked",
|
||||
"unblockAll": "Unblock all",
|
||||
"unblockAllConfirmTitle": "Are you sure?",
|
||||
"unblockAllConfirmBody": "Are you sure you want to unblock all users?",
|
||||
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
||||
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Blur background",
|
||||
"setAsBackground": "Set as background image"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Remove sticker pack",
|
||||
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
|
||||
"installConfirmTitle": "Install sticker pack",
|
||||
"installConfirmBody": "Are you sure you want to install this sticker pack?",
|
||||
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
|
||||
"fetchingFailure": "Could not find the sticker pack"
|
||||
},
|
||||
"settings": {
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"conversationsSection": "Conversations",
|
||||
"accountSection": "Account",
|
||||
"signOut": "Sign out",
|
||||
"signOutConfirmTitle": "Sign Out",
|
||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||
"miscellaneousSection": "Miscellaneous",
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "General"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"licensed": "Licensed under GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "View source code",
|
||||
"nMoreToGo": "${n} more to go...",
|
||||
"debugMenuShown": "You are now a developer!",
|
||||
"debugMenuAlreadyShown": "You are already a developer!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"languageSection": "Language",
|
||||
"language": "App language",
|
||||
"languageSubtext": "Currently selected: $selectedLanguage",
|
||||
"systemLanguage": "Default language"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Open-Source Licenses",
|
||||
"licensedUnder": "Licensed under $license"
|
||||
},
|
||||
"conversation": {
|
||||
"title": "Chat",
|
||||
"appearance": "Appearance",
|
||||
"selectBackgroundImage": "Select background image",
|
||||
"selectBackgroundImageDescription": "This image will be the background of all your chats",
|
||||
"removeBackgroundImage": "Remove background image",
|
||||
"removeBackgroundImageConfirmTitle": "Remove background image",
|
||||
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
||||
"newChatsSection": "New Conversations",
|
||||
"newChatsMuteByDefault": "Mute new chats by default",
|
||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
|
||||
"behaviourSection": "Behaviour",
|
||||
"contactsIntegration": "Contacts integration",
|
||||
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debugging options",
|
||||
"generalSection": "General",
|
||||
"generalEnableDebugging": "Enable debugging",
|
||||
"generalEncryptionPassword": "Encryption password",
|
||||
"generalEncryptionPasswordSubtext": "The logs may contain sensitive information so pick a strong passphrase",
|
||||
"generalLoggingIp": "Logging IP",
|
||||
"generalLoggingIpSubtext": "The IP the logs should be sent to",
|
||||
"generalLoggingPort": "Logging Port",
|
||||
"generalLoggingPortSubtext": "The IP the logs should be sent to"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"automaticDownloadsSection": "Automatic Downloads",
|
||||
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
||||
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
||||
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
||||
"automaticDownloadAlways": "Always",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Mobile data"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy",
|
||||
"generalSection": "General",
|
||||
"showContactRequests": "Show contact requests",
|
||||
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
|
||||
"profilePictureVisibility": "Make profile picture public",
|
||||
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
|
||||
"autoAcceptSubscriptionRequests": "Auto-accept subscription requests",
|
||||
"autoAcceptSubscriptionRequestsSubtext": "If enabled, subscription requests will be automatically accepted if the user is in the contact list.",
|
||||
"conversationsSection": "Conversation",
|
||||
"sendChatMarkers": "Send chat markers",
|
||||
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
|
||||
"sendChatStates": "Send chat states",
|
||||
"sendChatStatesSubtext": "This will show your conversation partner if you are typing or looking at the chat",
|
||||
"redirectsSection": "Redirects",
|
||||
"redirectText": "This will redirect $serviceName links that you tap to a proxy service, e.g. $exampleProxy",
|
||||
"currentlySelected": "Currently selected: $proxy",
|
||||
"redirectsTitle": "$serviceName Redirect",
|
||||
"cannotEnableRedirect": "Cannot enable $serviceName redirects",
|
||||
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
||||
"urlEmpty": "URL cannot be empty",
|
||||
"urlInvalid": "Invalid URL",
|
||||
"redirectDialogTitle": "$serviceName Redirect",
|
||||
"stickersPrivacy": "Keep sticker list public",
|
||||
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Stickers",
|
||||
"stickerSection": "Sticker",
|
||||
"displayStickers": "Display stickers in chat",
|
||||
"autoDownload": "Automatically download stickers",
|
||||
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
|
||||
"stickerPacksSection": "Sticker packs",
|
||||
"importStickerPack": "Import sticker pack",
|
||||
"importSuccess": "Sticker pack successfully imported",
|
||||
"importFailure": "Failed to import sticker pack"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"@@name": "Deutsch",
|
||||
"language": "Deutsch",
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
|
||||
@@ -25,6 +25,9 @@
|
||||
"messagesChannelDescription": "Empfangene Nachrichten",
|
||||
"warningChannelName": "Warnungen",
|
||||
"warningChannelDescription": "Warnungen im Bezug auf Moxxy"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Fehler"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
@@ -63,13 +66,18 @@
|
||||
"you": "Du"
|
||||
},
|
||||
"errors": {
|
||||
"general": {
|
||||
"noInternet": "Keine Internetverbindung."
|
||||
},
|
||||
"filePicker": {
|
||||
"permissionDenied": "Die Speicherberechtigung wurde nicht erteilt."
|
||||
},
|
||||
"omemo": {
|
||||
"couldNotPublish": "Konnte die kryptographische Identität nicht auf dem Server veröffentlichen. Ende-zu-Ende-Verschlüsselung funktioniert eventuell nicht.",
|
||||
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||
|
||||
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||
@@ -77,7 +85,10 @@
|
||||
"verificationWrongFingerprint": "Falscher OMEMO:2 Fingerabdruck"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Verbindung zum Server nicht möglich"
|
||||
"connectionTimeout": "Verbindung zum Server nicht möglich",
|
||||
"saslAccountDisabled": "Dein Konto ist deaktiviert",
|
||||
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
|
||||
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Ungültige Logindaten",
|
||||
@@ -101,7 +112,13 @@
|
||||
"conversation": {
|
||||
"audioRecordingError": "Fehler beim Fertigstellen der Audioaufnahme",
|
||||
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
||||
"openFileGenericError": "Fehler beim Öffnen der Datei"
|
||||
"openFileGenericError": "Fehler beim Öffnen der Datei",
|
||||
"messageErrorDialogTitle": "Fehler"
|
||||
},
|
||||
"newChat": {
|
||||
"remoteServerError": "Konnte den Server nicht erreichen.",
|
||||
"groupchatUnsupported": "Das Beitreten eines Gruppenchats ist aktuell nicht unterstützt.",
|
||||
"unknown": "Unbekannter Fehler."
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
@@ -114,7 +131,7 @@
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"noAccount": "Kein XMPP-Account vorhanden? Einen zu erstellen ist sehr einfach.",
|
||||
"noAccount": "Kein XMPP-Konto vorhanden? Keine Sorge, es ist ganz einfach, eines zu erstellen.",
|
||||
"loginButton": "Einloggen",
|
||||
"registerButton": "Registrieren"
|
||||
},
|
||||
@@ -123,11 +140,12 @@
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"password": "Passwort",
|
||||
"advancedOptions": "Fortgeschrittene Optionen",
|
||||
"createAccount": "Account auf dem Server erstellen"
|
||||
"createAccount": "Konto auf dem Server erstellen"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Neuer chat",
|
||||
"speeddialJoinGroupchat": "Gruppenchat beitreten",
|
||||
"speeddialAddNoteToSelf": "Notiz an mich",
|
||||
"overlaySettings": "Einstellungen",
|
||||
"noOpenChats": "Du hast keine offenen chats",
|
||||
"startChat": "Einen chat anfangen",
|
||||
@@ -150,30 +168,39 @@
|
||||
"edit": "Bearbeiten",
|
||||
"quote": "Zitieren",
|
||||
"copy": "Inhalt kopieren",
|
||||
"messageCopied": "Nachrichteninhalt in die Zwischenablage kopiert",
|
||||
"addReaction": "Reaktion hinzufügen",
|
||||
"showError": "Fehler anzeigen",
|
||||
"showWarning": "Warnung anzeigen",
|
||||
"warning": "Warnung",
|
||||
"addToContacts": "Zu Kontaken hinzufügen",
|
||||
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
|
||||
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
||||
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||
"stickerSettings": "Stickereinstellungen",
|
||||
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
|
||||
"newDeviceMessage": {
|
||||
"one": "Ein neues Gerät wurde hinzugefügt",
|
||||
"other": "Mehrere neue Geräte wurden hinzugefügt"
|
||||
},
|
||||
"replacedDeviceMessage": {
|
||||
"one": "Ein Gerät hat sich verändert",
|
||||
"other": "Mehrere Geräte haben sich verändert"
|
||||
},
|
||||
"messageHint": "Nachricht senden...",
|
||||
"sendImages": "Bilder senden",
|
||||
"sendFiles": "Dateien senden",
|
||||
"takePhotos": "Bilder aufnehmen"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Neuen Kontakt hinzufügen",
|
||||
"startchat": {
|
||||
"title": "Neuer Chat",
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"subtitle": "Du kannst einen Kontakt hinzufügen, indem Du entweder die XMPP-Adresse eingibst oder den QR-Code deines Kontaktes scannst",
|
||||
"buttonAddToContact": "Kontakt hinzufügen"
|
||||
"subtitle": "Du kannst einen neuen Chat beginnen, indem du entweder eine XMPP-Adresse eingibst oder einen QR-Code scannst.",
|
||||
"buttonAddToContact": "Neuen Chat beginnen"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Neuer chat",
|
||||
"addContact": "Kontakt hinzufügen",
|
||||
"title": "Neuer Chat",
|
||||
"startChat": "Neuen Chat beginnen",
|
||||
"createGroupchat": "Gruppenchat erstellen"
|
||||
},
|
||||
"crop": {
|
||||
@@ -186,7 +213,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Sicherheit"
|
||||
"omemo": "Sicherheit",
|
||||
"profile": "Profil",
|
||||
"media": "Medien"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Benachrichtigungen",
|
||||
@@ -208,10 +237,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?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Geräte",
|
||||
"title": "Sicherheit",
|
||||
"recreateSessions": "Sessions zurücksetzen",
|
||||
"recreateSessionsConfirmTitle": "Sessions zurücksetzen?",
|
||||
"recreateSessionsConfirmBody": "Dies wird alle Sessions mit Deinen Geräten neu erstellen. Tue dies nur, wenn deine Geräte Fehler beim Entschlüsseln erzeugen."
|
||||
"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": {
|
||||
@@ -239,7 +269,7 @@
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"accountSection": "Account",
|
||||
"accountSection": "Konto",
|
||||
"signOut": "Abmelden",
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
@@ -300,7 +330,7 @@
|
||||
"automaticDownloadsMaximumSize": "Maximale Downloadgröße",
|
||||
"automaticDownloadsMaximumSizeSubtext": "Die maximale Dateigröße, die automatisch heruntergeladen werden soll",
|
||||
"automaticDownloadAlways": "Immer",
|
||||
"wifi": "Wifi",
|
||||
"wifi": "WLAN",
|
||||
"mobileData": "Mobile Daten"
|
||||
},
|
||||
"privacy": {
|
||||
@@ -310,8 +340,6 @@
|
||||
"showContactRequestsSubtext": "Dies zeigt Personen in der Chatübersicht an, die Dich zu ihrer Kontaktliste hinzugefügt haben, aber noch keine Nachricht gesendet haben",
|
||||
"profilePictureVisibility": "Öffentliches Profilbild",
|
||||
"profilePictureVisibilitSubtext": "Wenn aktiviert, dann kann jeder Dein Profilbild sehen. Wenn deaktiviert, dann können nur Personen aus deiner Kontaktliste kein Profilbild sehen",
|
||||
"autoAcceptSubscriptionRequests": "Subscriptionanfragen automatisch annehmen",
|
||||
"autoAcceptSubscriptionRequestsSubtext": "Wenn aktiviert, dann werden Subscriptionanfragen automatisch angenommen, wenn die Person in deiner Kontaktliste ist",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"sendChatMarkers": "Chatmarker senden",
|
||||
"sendChatMarkersSubtext": "Dies teilt Deinem Gesprächspartner mit, ob du Nachrichten empfangen oder gelesen hast",
|
||||
@@ -338,7 +366,43 @@
|
||||
"stickerPacksSection": "Stickerpacks",
|
||||
"importStickerPack": "Stickerpack importieren",
|
||||
"importSuccess": "Stickerpack erfolgreich importiert",
|
||||
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten"
|
||||
"importFailure": "Beim Import des Stickerpacks ist ein Fehler aufgetreten",
|
||||
"stickerPackSize": "(${size})"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Speicher",
|
||||
"sizePlaceholder": "Berechne...",
|
||||
"storageManagement": "Speicherverwaltung",
|
||||
"removeOldMedia": {
|
||||
"title": "Alte Medien entfernen",
|
||||
"description": "Löscht alte Medien vom Gerät"
|
||||
},
|
||||
"removeOldMediaDialog": {
|
||||
"title": "Medien löschen",
|
||||
"options": {
|
||||
"all": "Alle Medien",
|
||||
"oneMonth": "Älter als 1 Monat",
|
||||
"oneWeek": "Älter als 1 Woche"
|
||||
},
|
||||
"delete": "Löschen",
|
||||
"confirmation": {
|
||||
"body": "Bist Du dir sicher, dass du alte Medien löschen möchtest?"
|
||||
}
|
||||
},
|
||||
"viewMediaFiles": "Medien anzeigen",
|
||||
"mediaFiles": "Medien",
|
||||
"types": {
|
||||
"media": "Medien",
|
||||
"stickers": "Sticker"
|
||||
},
|
||||
"manageStickers": "Stickerpacks verwalten",
|
||||
"storageUsed": "Speicherplatz verbraucht: ${size}"
|
||||
}
|
||||
},
|
||||
"sharedMedia": {
|
||||
"empty": {
|
||||
"chat": "Keine Medien für diesen Chat vorhanden",
|
||||
"general": "Keine Medien vorhanden"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
413
assets/i18n/strings_en.i18n.json
Normal file
413
assets/i18n/strings_en.i18n.json
Normal file
@@ -0,0 +1,413 @@
|
||||
{
|
||||
"language": "English",
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"moxxySubtitle": "An experiment into building a modern, easy and beautiful XMPP client.",
|
||||
"dialogAccept": "Okay",
|
||||
"dialogCancel": "Cancel",
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"idle": "Idle",
|
||||
"ready": "Ready to receive messages",
|
||||
"connecting": "Connecting...",
|
||||
"disconnect": "Disconnected",
|
||||
"error": "Error"
|
||||
},
|
||||
"message": {
|
||||
"reply": "Reply",
|
||||
"markAsRead": "Mark as read"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Messages",
|
||||
"messagesChannelDescription": "The notification channel for received messages",
|
||||
"warningChannelName": "Warnings",
|
||||
"warningChannelDescription": "Warnings related to Moxxy"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Just now",
|
||||
"nMinutesAgo": "${min}min ago",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Tue",
|
||||
"wednessdayAbbrev": "Wed",
|
||||
"thursdayAbbrev": "Thu",
|
||||
"fridayAbbrev": "Fri",
|
||||
"saturdayAbbrev": "Sat",
|
||||
"sundayAbbrev": "Sun",
|
||||
"january": "January",
|
||||
"february": "February",
|
||||
"march": "March",
|
||||
"april": "April",
|
||||
"may": "May",
|
||||
"june": "June",
|
||||
"july": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "File",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "The message has been retracted",
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||
"you": "You"
|
||||
},
|
||||
"errors": {
|
||||
"general": {
|
||||
"noInternet": "Not connected to the Internet."
|
||||
},
|
||||
"filePicker": {
|
||||
"permissionDenied": "The storage permission has been denied."
|
||||
},
|
||||
"omemo": {
|
||||
"couldNotPublish": "Could not publish the cryptographic identity to the server. This means that end-to-end encryption may not work.",
|
||||
"notEncryptedForDevice": "This message was not encrypted for this device",
|
||||
"invalidHmac": "Could not decrypt message",
|
||||
"noDecryptionKey": "No decryption key available",
|
||||
"messageInvalidAfixElement": "Invalid encrypted message",
|
||||
|
||||
"verificationInvalidOmemoUrl": "Invalid OMEMO:2 fingerprint",
|
||||
"verificationWrongJid": "Wrong XMPP-address",
|
||||
"verificationWrongDevice": "Wrong OMEMO:2 device",
|
||||
"verificationNotInList": "Wrong OMEMO:2 device",
|
||||
"verificationWrongFingerprint": "Wrong OMEMO:2 fingerprint"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Could not connect to server",
|
||||
"saslAccountDisabled": "Your account is disabled",
|
||||
"saslInvalidCredentials": "Your account credentials are invalid",
|
||||
"unrecoverable": "Connection lost due to unrecoverable error"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Invalid login credentials",
|
||||
"startTlsFailed": "Failed to establish a secure connection",
|
||||
"noConnection": "Failed to establish a connection",
|
||||
"unspecified": "Unspecified error"
|
||||
},
|
||||
"message": {
|
||||
"unspecified": "Unknown error",
|
||||
"fileUploadFailed": "The file upload failed",
|
||||
"contactDoesntSupportOmemo": "The contact does not support encryption using OMEMO:2",
|
||||
"fileDownloadFailed": "The file download failed",
|
||||
"serviceUnavailable": "The message could not be delivered to the contact",
|
||||
"remoteServerTimeout": "The message could not be delivered to the contact's server",
|
||||
"remoteServerNotFound": "The message could not be delivered to the contact's server as it cannot be found",
|
||||
"failedToEncrypt": "The message could not be encrypted",
|
||||
"failedToEncryptFile": "The file could not be encrypted",
|
||||
"failedToDecryptFile": "The file could not be decrypted",
|
||||
"fileNotEncrypted": "The chat is encrypted but the file is not encrypted"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Failed to finalize audio recording",
|
||||
"openFileNoAppError": "No app found to open this file",
|
||||
"openFileGenericError": "Failed to open file",
|
||||
"messageErrorDialogTitle": "Error"
|
||||
},
|
||||
"newChat": {
|
||||
"remoteServerError": "Failed to contact the remote server.",
|
||||
"groupchatUnsupported": "Joining a groupchat is currently not supported.",
|
||||
"unknown": "Unknown error."
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Could not verify file integrity"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Hold button longer to record a voice message"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"noAccount": "Have no XMPP account? No worries, creating one is really easy.",
|
||||
"loginButton": "Login",
|
||||
"registerButton": "Register"
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"xmppAddress": "XMPP-Address",
|
||||
"password": "Password",
|
||||
"advancedOptions": "Advanced options",
|
||||
"createAccount": "Create account on server"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "New chat",
|
||||
"speeddialJoinGroupchat": "Join groupchat",
|
||||
"speeddialAddNoteToSelf": "Note to self",
|
||||
"overlaySettings": "Settings",
|
||||
"noOpenChats": "You have no open chats",
|
||||
"startChat": "Start a chat",
|
||||
"closeChat": "Close chat",
|
||||
"closeChatBody": "Are you sure you want to close the chat with ${conversationTitle}?",
|
||||
"markAsRead": "Mark as read"
|
||||
},
|
||||
"conversation": {
|
||||
"unencrypted": "Unencrypted",
|
||||
"encrypted": "Encrypted",
|
||||
"closeChat": "Close chat",
|
||||
"closeChatConfirmTitle": "Close chat",
|
||||
"closeChatConfirmSubtext": "Are you sure you want to close this chat?",
|
||||
"blockShort": "Block",
|
||||
"blockUser": "Block user",
|
||||
"online": "Online",
|
||||
"retract": "Retract message",
|
||||
"retractBody": "Are you sure you want to retract the message? Keep in mind that this is only a request that the client does not have to honour.",
|
||||
"forward": "Forward",
|
||||
"edit": "Edit",
|
||||
"quote": "Quote",
|
||||
"copy": "Copy content",
|
||||
"messageCopied": "Message content copied to clipboard",
|
||||
"addReaction": "Add reaction",
|
||||
"showError": "Show error",
|
||||
"showWarning": "Show warning",
|
||||
"warning": "Warning",
|
||||
"addToContacts": "Add to contacts",
|
||||
"addToContactsTitle": "Add ${jid} to contacts",
|
||||
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
|
||||
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
||||
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
||||
"stickerSettings": "Sticker settings",
|
||||
"newDeviceMessage": {
|
||||
"one": "A new device has been added",
|
||||
"other": "Multiple new devices have been added"
|
||||
},
|
||||
"replacedDeviceMessage": {
|
||||
"one": "A device has been changed",
|
||||
"other": "Multiple devices have been added"
|
||||
},
|
||||
"messageHint": "Send a message...",
|
||||
"sendImages": "Send images",
|
||||
"sendFiles": "Send files",
|
||||
"takePhotos": "Take photos"
|
||||
},
|
||||
"startchat": {
|
||||
"title": "New Chat",
|
||||
"xmppAddress": "XMPP address",
|
||||
"subtitle": "You can start a new chat by either entering a XMPP address or by scanning their QR code.",
|
||||
"buttonAddToContact": "Start new chat"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "New chat",
|
||||
"startChat": "Start new chat",
|
||||
"createGroupchat": "New groupchat"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Set as profile picture"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Share with...",
|
||||
"confirmTitle": "Send file",
|
||||
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Security",
|
||||
"profile": "Profile",
|
||||
"media": "Media"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Notifications",
|
||||
"notificationsMuted": "Muted",
|
||||
"notificationsEnabled": "Enabled",
|
||||
"sharedMedia": "Media"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Own Devices",
|
||||
"thisDevice": "This device",
|
||||
"otherDevices": "Other devices",
|
||||
"deleteDeviceConfirmTitle": "Delete device",
|
||||
"deleteDeviceConfirmBody": "This means that contacts will not be able to encrypt for that device. Continue?",
|
||||
"recreateOwnSessions": "Rebuild sessions",
|
||||
"recreateOwnSessionsConfirmTitle": "Recreate own sessions?",
|
||||
"recreateOwnSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
||||
"recreateOwnDevice": "Recreate device",
|
||||
"recreateOwnDeviceConfirmTitle": "Recreate own device?",
|
||||
"recreateOwnDeviceConfirmBody": "This will recreate this device's cryptographic identity. It will take some time. If contacts verified your device, they will have to do it again. Continue?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Security",
|
||||
"recreateSessions": "Rebuild sessions",
|
||||
"recreateSessionsConfirmTitle": "Rebuild sessions?",
|
||||
"recreateSessionsConfirmBody": "This will recreate the cryptographic sessions with your own devices. Use only if your own devices throw decryption errors.",
|
||||
"noSessions": "There are no cryptographic sessions that are used for end-to-end encryption."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Blocklist",
|
||||
"noUsersBlocked": "You have no users blocked",
|
||||
"unblockAll": "Unblock all",
|
||||
"unblockAllConfirmTitle": "Are you sure?",
|
||||
"unblockAllConfirmBody": "Are you sure you want to unblock all users?",
|
||||
"unblockJidConfirmTitle": "Unblock ${jid}?",
|
||||
"unblockJidConfirmBody": "Are you sure you want to unblock ${jid}? You will receive messages from this user again."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Blur background",
|
||||
"setAsBackground": "Set as background image"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Remove sticker pack",
|
||||
"removeConfirmBody": "Are you sure you want to remove this sticker pack?",
|
||||
"installConfirmTitle": "Install sticker pack",
|
||||
"installConfirmBody": "Are you sure you want to install this sticker pack?",
|
||||
"restricted": "This sticker pack is restricted. That means that the stickers will be displayed but cannot be sent.",
|
||||
"fetchingFailure": "Could not find the sticker pack"
|
||||
},
|
||||
"sharedMedia": {
|
||||
"empty": {
|
||||
"chat": "No shared media for this chat",
|
||||
"general": "No media files available"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"conversationsSection": "Conversations",
|
||||
"accountSection": "Account",
|
||||
"signOut": "Sign out",
|
||||
"signOutConfirmTitle": "Sign Out",
|
||||
"signOutConfirmBody": "You are about to sign out. Proceed?",
|
||||
"miscellaneousSection": "Miscellaneous",
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "General"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"licensed": "Licensed under GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "View source code",
|
||||
"nMoreToGo": "${n} more to go...",
|
||||
"debugMenuShown": "You are now a developer!",
|
||||
"debugMenuAlreadyShown": "You are already a developer!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
"languageSection": "Language",
|
||||
"language": "App language",
|
||||
"languageSubtext": "Currently selected: $selectedLanguage",
|
||||
"systemLanguage": "Default language"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Open-Source Licenses",
|
||||
"licensedUnder": "Licensed under $license"
|
||||
},
|
||||
"conversation": {
|
||||
"title": "Chat",
|
||||
"appearance": "Appearance",
|
||||
"selectBackgroundImage": "Select background image",
|
||||
"selectBackgroundImageDescription": "This image will be the background of all your chats",
|
||||
"removeBackgroundImage": "Remove background image",
|
||||
"removeBackgroundImageConfirmTitle": "Remove background image",
|
||||
"removeBackgroundImageConfirmBody": "Are you sure you want to remove your conversation background image?",
|
||||
"newChatsSection": "New Conversations",
|
||||
"newChatsMuteByDefault": "Mute new chats by default",
|
||||
"newChatsE2EE": "Enable end-to-end encryption by default. WARNING: Experimental",
|
||||
"behaviourSection": "Behaviour",
|
||||
"contactsIntegration": "Contacts integration",
|
||||
"contactsIntegrationBody": "When enabled, data from the phonebook will be used to provide chat titles and profile pictures. No data will be sent to the server."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Debugging options",
|
||||
"generalSection": "General",
|
||||
"generalEnableDebugging": "Enable debugging",
|
||||
"generalEncryptionPassword": "Encryption password",
|
||||
"generalEncryptionPasswordSubtext": "The logs may contain sensitive information so pick a strong passphrase",
|
||||
"generalLoggingIp": "Logging IP",
|
||||
"generalLoggingIpSubtext": "The IP the logs should be sent to",
|
||||
"generalLoggingPort": "Logging Port",
|
||||
"generalLoggingPortSubtext": "The IP the logs should be sent to"
|
||||
},
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"automaticDownloadsSection": "Automatic Downloads",
|
||||
"automaticDownloadsText": "Moxxy will automatically download files on...",
|
||||
"automaticDownloadsMaximumSize": "Maximum Download Size",
|
||||
"automaticDownloadsMaximumSizeSubtext": "The maximum file size for a file to be automatically downloaded",
|
||||
"automaticDownloadAlways": "Always",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Mobile data"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy",
|
||||
"generalSection": "General",
|
||||
"showContactRequests": "Show contact requests",
|
||||
"showContactRequestsSubtext": "This will show people who added you to their contact list but sent no message yet",
|
||||
"profilePictureVisibility": "Make profile picture public",
|
||||
"profilePictureVisibilitSubtext": "If enabled, everyone can see your profile picture. If disabled, only users on your contact list can see your profile picture.",
|
||||
"conversationsSection": "Conversation",
|
||||
"sendChatMarkers": "Send chat markers",
|
||||
"sendChatMarkersSubtext": "This will tell your conversation partner if you received or read a message",
|
||||
"sendChatStates": "Send chat states",
|
||||
"sendChatStatesSubtext": "This will show your conversation partner if you are typing or looking at the chat",
|
||||
"redirectsSection": "Redirects",
|
||||
"redirectText": "This will redirect $serviceName links that you tap to a proxy service, e.g. $exampleProxy",
|
||||
"currentlySelected": "Currently selected: $proxy",
|
||||
"redirectsTitle": "$serviceName Redirect",
|
||||
"cannotEnableRedirect": "Cannot enable $serviceName redirects",
|
||||
"cannotEnableRedirectSubtext": "You must first set a proxy service to redirect to. To do so, tap the field next to the switch.",
|
||||
"urlEmpty": "URL cannot be empty",
|
||||
"urlInvalid": "Invalid URL",
|
||||
"redirectDialogTitle": "$serviceName Redirect",
|
||||
"stickersPrivacy": "Keep sticker list public",
|
||||
"stickersPrivacySubtext": "If enabled, everyone will be able to see your list of installed sticker packs."
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Stickers",
|
||||
"stickerSection": "Sticker",
|
||||
"displayStickers": "Display stickers in chat",
|
||||
"autoDownload": "Automatically download stickers",
|
||||
"autoDownloadBody": "If enabled, stickers are automatically downloaded when the sender is in your contact list.",
|
||||
"stickerPacksSection": "Sticker packs",
|
||||
"importStickerPack": "Import sticker pack",
|
||||
"importSuccess": "Sticker pack successfully imported",
|
||||
"importFailure": "Failed to import sticker pack",
|
||||
"stickerPackSize": "(${size})"
|
||||
},
|
||||
"stickerPacks": {
|
||||
"title": "Sticker Packs"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage",
|
||||
"storageUsed": "Storage used: ${size}",
|
||||
"sizePlaceholder": "Computing...",
|
||||
"storageManagement": "Storage Management",
|
||||
"removeOldMedia" : {
|
||||
"title": "Remove old media",
|
||||
"description": "Removes old media files from the device"
|
||||
},
|
||||
"removeOldMediaDialog": {
|
||||
"title": "Delete media files",
|
||||
"options": {
|
||||
"all": "All media files",
|
||||
"oneWeek": "Older than 1 week",
|
||||
"oneMonth": "Older than 1 month"
|
||||
},
|
||||
"delete": "Delete",
|
||||
"confirmation": {
|
||||
"body": "Are you sure you want to delete old media files?"
|
||||
}
|
||||
},
|
||||
"viewMediaFiles": "View media files",
|
||||
"mediaFiles": "Media Files",
|
||||
"types": {
|
||||
"media": "Media",
|
||||
"stickers": "Stickers"
|
||||
},
|
||||
"manageStickers": "Manage sticker packs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
assets/i18n/strings_ja.i18n.json
Normal file
90
assets/i18n/strings_ja.i18n.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"language": "日本語",
|
||||
"global": {
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"dialogCancel": "キャンセル"
|
||||
},
|
||||
"dateTime": {
|
||||
"thursdayAbbrev": "木",
|
||||
"fridayAbbrev": "金",
|
||||
"saturdayAbbrev": "土",
|
||||
"january": "1月",
|
||||
"february": "2月",
|
||||
"march": "3月",
|
||||
"may": "5月",
|
||||
"june": "6月",
|
||||
"july": "7月",
|
||||
"september": "9月",
|
||||
"october": "10月",
|
||||
"justNow": "ちょうど今",
|
||||
"nMinutesAgo": "${min}分前",
|
||||
"mondayAbbrev": "月",
|
||||
"tuesdayAbbrev": "火",
|
||||
"wednessdayAbbrev": "水",
|
||||
"sundayAbbrev": "日",
|
||||
"april": "4月",
|
||||
"august": "8月",
|
||||
"november": "11月",
|
||||
"december": "12月",
|
||||
"today": "今日",
|
||||
"yesterday": "昨日"
|
||||
},
|
||||
"messages": {
|
||||
"audio": "音声",
|
||||
"you": "自分",
|
||||
"image": "画像",
|
||||
"video": "ビデオ",
|
||||
"file": "ファイル",
|
||||
"sticker": "スタンプ",
|
||||
"retracted": "メッセージ取り消された"
|
||||
},
|
||||
"errors": {
|
||||
"connection": {
|
||||
"connectionTimeout": "サーバー接続中にタイムアウトが発生しました",
|
||||
"saslInvalidCredentials": "ユーザー名またはパスワードが無効"
|
||||
},
|
||||
"login": {
|
||||
"noConnection": "接続できませんでした",
|
||||
"startTlsFailed": "接続できませんでした"
|
||||
},
|
||||
"message": {
|
||||
"fileUploadFailed": "アップロードに失敗しました",
|
||||
"fileDownloadFailed": "ダウンロードに失敗しました",
|
||||
"serviceUnavailable": "配信に失敗しました"
|
||||
},
|
||||
"omemo": {
|
||||
"notEncryptedForDevice": "このデバイス向けにメッセージは暗号化されませんでした"
|
||||
},
|
||||
"conversation": {
|
||||
"messageErrorDialogTitle": "エラー"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"conversation": {
|
||||
"holdForLonger": "長押しすると音声記録できます"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"loginButton": "ログイン",
|
||||
"registerButton": "新規登録",
|
||||
"noAccount": "XMPPアドレスをお持ちですか?XMPPアドレスの作成は簡単です。"
|
||||
},
|
||||
"login": {
|
||||
"title": "ログイン",
|
||||
"xmppAddress": "XMPPアドレス",
|
||||
"password": "パスワード",
|
||||
"advancedOptions": "詳細設定"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"connecting": "接続中…"
|
||||
},
|
||||
"message": {
|
||||
"reply": "返信",
|
||||
"markAsRead": "既読"
|
||||
}
|
||||
}
|
||||
}
|
||||
412
assets/i18n/strings_nl.i18n.json
Normal file
412
assets/i18n/strings_nl.i18n.json
Normal file
@@ -0,0 +1,412 @@
|
||||
{
|
||||
"language": "Nederlands",
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"dialogAccept": "Oké",
|
||||
"dialogCancel": "Annuleren",
|
||||
"yes": "Ja",
|
||||
"no": "Nee",
|
||||
"moxxySubtitle": "Een xmpp-experiment: het bouwen van een moderne, eenvoudige en mooie client."
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"idle": "Inactief",
|
||||
"ready": "Klaar om berichten te ontvangen",
|
||||
"connecting": "Bezig met verbinden…",
|
||||
"disconnect": "Verbinding verbroken",
|
||||
"error": "Foutmelding"
|
||||
},
|
||||
"message": {
|
||||
"reply": "Beantwoorden",
|
||||
"markAsRead": "Markeren als gelezen"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Berichten",
|
||||
"warningChannelName": "Waarschuwingen",
|
||||
"warningChannelDescription": "Aan Moxxy gerelateerde waarschuwingen",
|
||||
"messagesChannelDescription": "Het meldingskanaal voor het ontvangen van berichten"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Foutmelding"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Zojuist",
|
||||
"nMinutesAgo": "${min} min. geleden",
|
||||
"mondayAbbrev": "ma",
|
||||
"tuesdayAbbrev": "di",
|
||||
"wednessdayAbbrev": "woe",
|
||||
"thursdayAbbrev": "do",
|
||||
"fridayAbbrev": "vrij",
|
||||
"saturdayAbbrev": "za",
|
||||
"sundayAbbrev": "zo",
|
||||
"january": "januari",
|
||||
"february": "februari",
|
||||
"march": "maart",
|
||||
"april": "april",
|
||||
"may": "mei",
|
||||
"june": "juni",
|
||||
"july": "juli",
|
||||
"august": "augustus",
|
||||
"september": "september",
|
||||
"october": "oktober",
|
||||
"november": "november",
|
||||
"december": "december",
|
||||
"today": "Vandaag",
|
||||
"yesterday": "Gisteren"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Afbeelding",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "Bestand",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "Dit bericht is herroepen",
|
||||
"retractedFallback": "Er is een eerder bericht herroepen, maar je client heeft hier geen ondersteuning voor",
|
||||
"you": "Ik"
|
||||
},
|
||||
"errors": {
|
||||
"filePicker": {
|
||||
"permissionDenied": "Het opslagrecht is geweigerd."
|
||||
},
|
||||
"omemo": {
|
||||
"notEncryptedForDevice": "Dit bericht is niet versleuteld op dit apparaat",
|
||||
"invalidHmac": "Het bericht kan niet worden ontsleuteld",
|
||||
"noDecryptionKey": "Er is geen sleutel beschikbaar",
|
||||
"messageInvalidAfixElement": "Het bericht is ongeldig versleuteld",
|
||||
"verificationInvalidOmemoUrl": "Ongeldige OMEMO:2-vingerafdruk",
|
||||
"verificationWrongJid": "Ongeldig xmpp-adres",
|
||||
"verificationWrongDevice": "Ongeldig OMEMO:2-apparaat",
|
||||
"verificationNotInList": "Ongeldig OMEMO:2-apparaat",
|
||||
"verificationWrongFingerprint": "Ongeldige OMEMO:2-vingerafdruk",
|
||||
"couldNotPublish": "De versleutelde identiteit kan niet worden gepubliceerd op de server. Hierdoor werkt eind-tot-eindversleuteling mogelijk niet."
|
||||
},
|
||||
"connection": {
|
||||
"saslAccountDisabled": "Je account is uitgeschakeld",
|
||||
"saslInvalidCredentials": "Je inloggegevens zijn ongeldig",
|
||||
"unrecoverable": "De verbinding is verbroken wegens een onoplosbare fout",
|
||||
"connectionTimeout": "Er kan geen verbinding worden gemaakt met de server"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "De inloggegevens zijn ongeldig",
|
||||
"noConnection": "Er kan geen verbinding worden opgezet",
|
||||
"unspecified": "Onbekende foutmelding",
|
||||
"startTlsFailed": "Er kan geen beveiligde verbinding worden opgezet"
|
||||
},
|
||||
"message": {
|
||||
"unspecified": "Onbekende foutmelding",
|
||||
"fileUploadFailed": "Het bestand kan niet worden geüpload",
|
||||
"contactDoesntSupportOmemo": "Deze contactpersoon heeft geen ondersteuning voon OMEMO:2-versleuteling",
|
||||
"fileDownloadFailed": "Het bestand kan niet worden opgehaald",
|
||||
"serviceUnavailable": "Het bericht kan niet worden bezorgd",
|
||||
"remoteServerTimeout": "Het bericht kan niet worden verstuurd naar de server",
|
||||
"failedToEncrypt": "Het bericht kan niet worden versleuteld",
|
||||
"failedToEncryptFile": "Het bestand kan niet worden versleuteld",
|
||||
"failedToDecryptFile": "Het bestand kan niet worden ontsleuteld",
|
||||
"fileNotEncrypted": "Het gesprek is versleuteld, maar het bestand niet",
|
||||
"remoteServerNotFound": "Het bericht kan niet worden verstuurd naar de server omdat het niet bestaat"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "De audio-opname kan niet worden afgerond",
|
||||
"openFileGenericError": "Het bestand kan niet worden geopend",
|
||||
"messageErrorDialogTitle": "Foutmelding",
|
||||
"openFileNoAppError": "Er is geen app die dit bestand kan openen"
|
||||
},
|
||||
"general": {
|
||||
"noInternet": "Er is geen internetverbinding."
|
||||
},
|
||||
"newChat": {
|
||||
"groupchatUnsupported": "Deelnemen aan groepsgesprekken wordt momenteel niet ondersteund.",
|
||||
"unknown": "Onbekende foutmelding.",
|
||||
"remoteServerError": "Er kan geen verbinding worden gemaakt met de externe server."
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "De bestandsintegriteit kan niet worden vastgesteld"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Houd langer ingedrukt om een spraakbericht op te nemen"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"loginButton": "Inloggen",
|
||||
"registerButton": "Registreren",
|
||||
"noAccount": "Geen xmpp-account? Geen zorgen: je maakt er in een handomdraai een aan."
|
||||
},
|
||||
"login": {
|
||||
"title": "Inloggen",
|
||||
"xmppAddress": "Xmpp-adres",
|
||||
"password": "Wachtwoord",
|
||||
"advancedOptions": "Geavanceerde opties",
|
||||
"createAccount": "Account aanmaken op server"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Nieuw gesprek",
|
||||
"speeddialJoinGroupchat": "Deelnemen aan groepsgesprek",
|
||||
"speeddialAddNoteToSelf": "Zelfmemo",
|
||||
"overlaySettings": "Instellingen",
|
||||
"noOpenChats": "Er zijn geen openstaande gesprekken",
|
||||
"startChat": "Gesprek starten",
|
||||
"closeChat": "Gesprek sluiten",
|
||||
"closeChatBody": "Weet je zeker dat je het gesprek “${conversationTitle}” wilt sluiten?",
|
||||
"markAsRead": "Markeren als gelezen"
|
||||
},
|
||||
"conversation": {
|
||||
"unencrypted": "Onversleuteld",
|
||||
"encrypted": "Versleuteld",
|
||||
"closeChat": "Gesprek sluiten",
|
||||
"closeChatConfirmSubtext": "Weet je zeker dat je dit gesprek wilt sluiten?",
|
||||
"blockShort": "Blokkeren",
|
||||
"blockUser": "Gebruiker blokkeren",
|
||||
"online": "Online",
|
||||
"retract": "Bericht herroepen",
|
||||
"forward": "Doorsturen",
|
||||
"edit": "Bewerken",
|
||||
"quote": "Citeren",
|
||||
"copy": "Inhoud kopiëren",
|
||||
"addReaction": "Reageren",
|
||||
"showError": "Foutmelding tonen",
|
||||
"showWarning": "Waarschuwing tonen",
|
||||
"addToContacts": "Toevoegen aan contactpersonen",
|
||||
"addToContactsTitle": "${jid} toevoegen aan contactpersonen",
|
||||
"addToContactsBody": "Weet je zeker dat je ${jid} wilt toevoegen aan je contactpersonen?",
|
||||
"stickerPickerNoStickersLine1": "Er zijn geen stickerpakketten beschikbaar.",
|
||||
"stickerSettings": "Stickerinstellingen",
|
||||
"newDeviceMessage": {
|
||||
"one": "Er is een nieuw apparaat toegevoegd",
|
||||
"other": "Er zijn meerdere nieuwe apparaten toegevoegd"
|
||||
},
|
||||
"replacedDeviceMessage": {
|
||||
"one": "Er is een apparaat gewijzigd",
|
||||
"other": "Er zijn meerdere apparaten toegevoegd"
|
||||
},
|
||||
"messageHint": "Verstuur een bericht…",
|
||||
"sendImages": "Afbeeldingen versturen",
|
||||
"sendFiles": "Bestanden versturen",
|
||||
"closeChatConfirmTitle": "Gesprek sluiten",
|
||||
"retractBody": "Weet je zeker dat je dit bericht wilt herroepen? Dit is slechts een verzoek aan de client dat niet in acht hoeft te worden genomen.",
|
||||
"stickerPickerNoStickersLine2": "Installeer pakketten via de stickerinstellingen.",
|
||||
"takePhotos": "Foto's maken",
|
||||
"warning": "Waarschuwing",
|
||||
"messageCopied": "De berichtinhoud is gekopieerd naar het klembord"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Nieuw gesprek",
|
||||
"startChat": "Gesprek starten",
|
||||
"createGroupchat": "Nieuw groepsgesprek"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Instellen als profielfoto"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Delen met…",
|
||||
"confirmTitle": "Bestand versturen",
|
||||
"confirmBody": "Een of meerdere gesprekken zijn onversleuteld. Dit houdt in dat het bestand kan worden uitgelezen door de server. Weet je zeker dat je wilt doorgaan?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Beveiliging",
|
||||
"profile": "Profiel",
|
||||
"media": "Media"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Meldingen",
|
||||
"notificationsMuted": "Gedempt",
|
||||
"notificationsEnabled": "Ingeschakeld",
|
||||
"sharedMedia": "Media"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Mijn apparaten",
|
||||
"thisDevice": "Dit apparaat",
|
||||
"otherDevices": "Overige apparaten",
|
||||
"deleteDeviceConfirmTitle": "Apparaat verwijderen",
|
||||
"deleteDeviceConfirmBody": "Let op: hierdoor kunnen contactpersonen het apparaat niet meer versleutelen. Wil je doorgaan?",
|
||||
"recreateOwnSessions": "Sessies heraanmaken",
|
||||
"recreateOwnSessionsConfirmTitle": "Wil je je sessies heraanmaken?",
|
||||
"recreateOwnDevice": "Apparaat heraanmaken",
|
||||
"recreateOwnDeviceConfirmTitle": "Wil je je apparaat heraanmaken?",
|
||||
"recreateOwnSessionsConfirmBody": "Hierdoor worden de versleutelde sessies opnieuw aangemaakt op je apparaten. Let op: doe dit alléén als apparaten ontsleutelfoutmeldingen tonen.",
|
||||
"recreateOwnDeviceConfirmBody": "Hierdoor wordt de versleutelde identiteit van dit apparaat opnieuw aangemaakt. Dit kan even duren. Als contactpersonen je apparaat hebben goedgekeurd, dan dienen ze dit opnieuw te doen. Wil je doorgaan?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Beveiliging",
|
||||
"recreateSessions": "Sessies heraanmaken",
|
||||
"recreateSessionsConfirmTitle": "Wil je je sessies heraanmaken?",
|
||||
"recreateSessionsConfirmBody": "Hierdoor worden alle versleutelde sessies opnieuw aangemaakt op je apparaten. Let op: doe dit alléén als je apparaten ontsleutelfoutmeldingen tonen.",
|
||||
"noSessions": "Er zijn geen versleutelde sessies die worden gebruikt voor eind-tot-eindversleuteling."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Blokkadelijst",
|
||||
"noUsersBlocked": "Er zijn geen geblokkeerde gebruikers",
|
||||
"unblockAll": "Iedereen deblokkeren",
|
||||
"unblockAllConfirmTitle": "Weet je het zeker?",
|
||||
"unblockAllConfirmBody": "Weet je zeker dat je alle gebruikers wilt deblokkeren?",
|
||||
"unblockJidConfirmTitle": "Wil je ${jid} deblokkeren?",
|
||||
"unblockJidConfirmBody": "Weet je zeker dat je ${jid} wilt deblokkeren? Hierdoor ontvang je weer berichten van deze gebruiker."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Achtergrond vervagen",
|
||||
"setAsBackground": "Instellen als achtergrond"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Stickerpakket verwijderen",
|
||||
"installConfirmTitle": "Stickerpakket installeren",
|
||||
"installConfirmBody": "Weet je zeker dat je dit stickerpakket wilt installeren?",
|
||||
"fetchingFailure": "Het stickerpakket is niet gevonden",
|
||||
"removeConfirmBody": "Weet je zeker dat je dit stickerpakket wilt verwijderen?",
|
||||
"restricted": "Dit stickerpakket is beperkt toegankelijk. Dit houdt in dat de stickers kunnen worden getoond, maar niet worden verstuurd."
|
||||
},
|
||||
"settings": {
|
||||
"settings": {
|
||||
"title": "Instellingen",
|
||||
"conversationsSection": "Gesprekken",
|
||||
"accountSection": "Account",
|
||||
"signOut": "Uitloggen",
|
||||
"signOutConfirmTitle": "Uitloggen",
|
||||
"signOutConfirmBody": "Je staat op het punt om uit te loggen. Wil je doorgaan?",
|
||||
"miscellaneousSection": "Overig",
|
||||
"debuggingSection": "Foutopsporing",
|
||||
"general": "Algemeen"
|
||||
},
|
||||
"about": {
|
||||
"title": "Over",
|
||||
"licensed": "Uitgebracht onder de GPL3-licentie",
|
||||
"version": "Versie ${version}",
|
||||
"viewSourceCode": "Broncode bekijken",
|
||||
"nMoreToGo": "Nog ${n} te gaan…",
|
||||
"debugMenuShown": "Je bent nu een ontwikkelaar!",
|
||||
"debugMenuAlreadyShown": "Je bent al een ontwikkelaar!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Vormgeving",
|
||||
"languageSection": "Taal",
|
||||
"language": "Apptaal",
|
||||
"languageSubtext": "Huidige taal: $selectedLanguage",
|
||||
"systemLanguage": "Standaardtaal"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Opensourcelicenties",
|
||||
"licensedUnder": "Uitgebracht onder de $license-licentie"
|
||||
},
|
||||
"conversation": {
|
||||
"title": "Gesprek",
|
||||
"appearance": "Vormgeving",
|
||||
"selectBackgroundImage": "Kies een achtergrond",
|
||||
"removeBackgroundImage": "Afbeelding verwijderen",
|
||||
"removeBackgroundImageConfirmTitle": "Afbeelding verwijderen",
|
||||
"removeBackgroundImageConfirmBody": "Weet je zeker dat je de huidige gespreksachtergrond wilt verwijderen?",
|
||||
"newChatsSection": "Nieuwe gesprekken",
|
||||
"newChatsMuteByDefault": "Nieuwe gesprekken dempen",
|
||||
"newChatsE2EE": "Eind-tot-eindversleuteling standaard inschakelen (WAARSCHUWING: experimenteel)",
|
||||
"behaviourSection": "Gedrag",
|
||||
"contactsIntegration": "Contactpersoonintegratie",
|
||||
"contactsIntegrationBody": "Schakel in om het adresboek te gebruiken om gesprekstitels en profielfoto's in te stellen. Er worden geen gegevens verstuurd naar de server.",
|
||||
"selectBackgroundImageDescription": "Deze afbeelding wordt gebruikt als achtergrond in al je gesprekken"
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Foutopsporingsopties",
|
||||
"generalSection": "Algemeen",
|
||||
"generalEnableDebugging": "Foutopsporing inschakelen",
|
||||
"generalEncryptionPassword": "Versleutelwachtwoord",
|
||||
"generalLoggingIp": "Ip-log",
|
||||
"generalLoggingIpSubtext": "Het ip-adres waar de logboeken naartoe dienen te worden gestuurd",
|
||||
"generalLoggingPort": "Logpoort",
|
||||
"generalLoggingPortSubtext": "Het ip-adres waar de logboeken naartoe dienen te worden gestuurd",
|
||||
"generalEncryptionPasswordSubtext": "Let op: de logboeken kunnen privéinformatie bevatten, dus stel een sterk wachtwoord in"
|
||||
},
|
||||
"network": {
|
||||
"title": "Netwerk",
|
||||
"automaticDownloadsSection": "Automatisch ophalen",
|
||||
"automaticDownloadsMaximumSize": "Maximale downloadomvang",
|
||||
"automaticDownloadsMaximumSizeSubtext": "De maximale bestandsgrootte van automatisch op te halen bestanden",
|
||||
"automaticDownloadAlways": "Ieder netwerk",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Mobiel internet",
|
||||
"automaticDownloadsText": "Moxxy zal bestanden automatisch ophalen bij gebruik van…"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy",
|
||||
"generalSection": "Algemeen",
|
||||
"showContactRequests": "Contactpersoonverzoeken tonen",
|
||||
"profilePictureVisibility": "Profielfoto aan iedereen tonen",
|
||||
"conversationsSection": "Gesprek",
|
||||
"sendChatMarkersSubtext": "Hiermee kan je gesprekspartner zien of je een bericht gelezen of ontvangen hebt",
|
||||
"sendChatMarkers": "Gespreksacties versturen",
|
||||
"sendChatStates": "Gespreksstatussen versturen",
|
||||
"sendChatStatesSubtext": "Hiermee kan je gesprekspartner zien of je aan het typen of het gesprek aan het bekijken bent",
|
||||
"redirectsSection": "Doorverwijzingen",
|
||||
"redirectText": "Hiermee worden $serviceName-links doorverwezen naar een proxy, bijvoorbeeld $exampleProxy",
|
||||
"currentlySelected": "Huidige proxy: $proxy",
|
||||
"redirectsTitle": "$serviceName-doorverwijzing",
|
||||
"cannotEnableRedirect": "$serviceName-doorverwijzingen mislukt",
|
||||
"cannotEnableRedirectSubtext": "Stel eerst een proxy als doorverwijzing in. Druk hiervoor op het veld naast de schakelaar.",
|
||||
"urlEmpty": "Voer een url in",
|
||||
"urlInvalid": "De url is ongeldig",
|
||||
"redirectDialogTitle": "$serviceName-doorverwijzing",
|
||||
"stickersPrivacy": "Stickerlijst aan iedereen tonen",
|
||||
"stickersPrivacySubtext": "Schakel in om je lijst met stickerpakketten aan iedereen te tonen",
|
||||
"showContactRequestsSubtext": "Hiermee worden verzoeken getoond van personen die je hebben toegevoegd, maar nog geen bericht hebben gestuurd",
|
||||
"profilePictureVisibilitSubtext": "Schakel in om iedereen je profielfoto te tonen; schakel uit om alleen gebruikers op je lijst je profielfoto te tonen"
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Stickers",
|
||||
"stickerSection": "Sticker",
|
||||
"displayStickers": "Stickers in gesprekken tonen",
|
||||
"autoDownload": "Stickers automatisch ophalen",
|
||||
"autoDownloadBody": "Schakel in om stickers automatisch op te halen na het toevoegen van de afzender",
|
||||
"stickerPacksSection": "Stickerpakketten",
|
||||
"importStickerPack": "Stickerpakket importeren",
|
||||
"importSuccess": "Het stickerpakket is geïmporteerd",
|
||||
"importFailure": "Het stickerpakket kan niet worden geïmporteerd",
|
||||
"stickerPackSize": "(${size})"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Opslag",
|
||||
"storageUsed": "In gebruik: ${size}",
|
||||
"sizePlaceholder": "Bezig met berekenen…",
|
||||
"storageManagement": "Opslagbeheer",
|
||||
"removeOldMedia": {
|
||||
"title": "Oude media verwijderen",
|
||||
"description": "Verwijdert oude mediabestanden van het apparaat"
|
||||
},
|
||||
"removeOldMediaDialog": {
|
||||
"title": "Mediabestanden verwijderen",
|
||||
"options": {
|
||||
"all": "Alle mediabestanden",
|
||||
"oneWeek": "Ouder dan 1 week",
|
||||
"oneMonth": "Ouder dan 1 maand"
|
||||
},
|
||||
"delete": "Verwijderen",
|
||||
"confirmation": {
|
||||
"body": "Weet je zeker dat je oude mediabestanden wilt verwijderen?"
|
||||
}
|
||||
},
|
||||
"viewMediaFiles": "Mediabestanden bekijken",
|
||||
"mediaFiles": "Mediabestanden",
|
||||
"types": {
|
||||
"media": "Media",
|
||||
"stickers": "Stickers"
|
||||
},
|
||||
"manageStickers": "Stickerpakketten beheren"
|
||||
},
|
||||
"stickerPacks": {
|
||||
"title": "Stickerpakketten"
|
||||
}
|
||||
},
|
||||
"startchat": {
|
||||
"title": "Nieuw gesprek",
|
||||
"xmppAddress": "Xmpp-adres",
|
||||
"subtitle": "Je kunt een nieuw gesprek starten door een xmpp-adres in te voeren of een QR-code te scannen.",
|
||||
"buttonAddToContact": "Gesprek starten"
|
||||
},
|
||||
"sharedMedia": {
|
||||
"empty": {
|
||||
"chat": "Er is geen gedeelde media in dit gesprek",
|
||||
"general": "Er zijn geen mediabestanden beschikbaar"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
379
assets/i18n/strings_ru.i18n.json
Normal file
379
assets/i18n/strings_ru.i18n.json
Normal file
@@ -0,0 +1,379 @@
|
||||
{
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"dialogAccept": "Принять",
|
||||
"dialogCancel": "Отмена",
|
||||
"yes": "Да",
|
||||
"no": "Нет",
|
||||
"moxxySubtitle": "Эксперементальный XMPP-клиент, простой, современный и красивый."
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"idle": "Idle",
|
||||
"ready": "Готов к приему сообщений",
|
||||
"connecting": "Подключение...",
|
||||
"disconnect": "Отключен",
|
||||
"error": "Ошибка"
|
||||
},
|
||||
"message": {
|
||||
"reply": "Ответ",
|
||||
"markAsRead": "Прочитано"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Сообщения",
|
||||
"warningChannelName": "Предупреждения",
|
||||
"warningChannelDescription": "Предупреждения, связанные с Moxxy",
|
||||
"messagesChannelDescription": "Канал уведомлений о полученных сообщениях"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Ошибка"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Сейчас",
|
||||
"nMinutesAgo": "${min}минут назад",
|
||||
"mondayAbbrev": "Пн",
|
||||
"tuesdayAbbrev": "Вт",
|
||||
"wednessdayAbbrev": "Ср",
|
||||
"thursdayAbbrev": "Чт",
|
||||
"fridayAbbrev": "Пт",
|
||||
"sundayAbbrev": "Вс",
|
||||
"january": "Январь",
|
||||
"february": "Февраль",
|
||||
"may": "Май",
|
||||
"june": "Июнь",
|
||||
"october": "Октябрь",
|
||||
"november": "Ноябрь",
|
||||
"december": "Декабрь",
|
||||
"today": "Сегодня",
|
||||
"saturdayAbbrev": "Сб",
|
||||
"march": "Март",
|
||||
"april": "Апрель",
|
||||
"july": "Июль",
|
||||
"august": "Август",
|
||||
"september": "Сентябрь",
|
||||
"yesterday": "Вчера"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Изображение",
|
||||
"video": "Видео",
|
||||
"file": "Файл",
|
||||
"sticker": "Стикер",
|
||||
"you": "Ты",
|
||||
"audio": "Аудио",
|
||||
"retracted": "Сообщение удалено",
|
||||
"retractedFallback": "Предыдущее сообщение было удалено, но это не поддерживается Вашим клиентом"
|
||||
},
|
||||
"errors": {
|
||||
"filePicker": {
|
||||
"permissionDenied": "Доступ к хранилищу не был выдан"
|
||||
},
|
||||
"omemo": {
|
||||
"notEncryptedForDevice": "Сообщение зашифровано, но не для этого устройства",
|
||||
"invalidHmac": "Не удалось расшифровать сообщение",
|
||||
"noDecryptionKey": "Нет ключа для расшифровки",
|
||||
"verificationWrongFingerprint": "Неправильный OMEMO:2 отпечаток",
|
||||
"couldNotPublish": "Не удалось опубликовать ключи шифрования на сервере. Это означает, что сквозное шифрование может не работать.",
|
||||
"messageInvalidAfixElement": "Ошибка в зашифрованном сообщении",
|
||||
"verificationInvalidOmemoUrl": "неверный отпечаток OMEMO:2",
|
||||
"verificationWrongJid": "Неправильный XMPP-адрес",
|
||||
"verificationWrongDevice": "Неправильное OMEMO:2 устройство",
|
||||
"verificationNotInList": "Неправильное OMEMO:2 устройство"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Нет соединения с сервером",
|
||||
"saslInvalidCredentials": "Данные учетной записи недействительны",
|
||||
"unrecoverable": "Соединение прервано из-за ошибки",
|
||||
"saslAccountDisabled": "Аккаунт отключен"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Неверный логин",
|
||||
"startTlsFailed": "Не удалось установить безопасное соединение",
|
||||
"noConnection": "Не удалось установить соединение",
|
||||
"unspecified": "Неопределенная ошибка"
|
||||
},
|
||||
"message": {
|
||||
"fileDownloadFailed": "Не удалось загрузить файл",
|
||||
"remoteServerTimeout": "Сообщение не доставлено на сервер получателя",
|
||||
"unspecified": "Неизвесная ошибка",
|
||||
"fileUploadFailed": "Не удалось отправить файл",
|
||||
"contactDoesntSupportOmemo": "Получатель не поддерживает OMEMO:2 шифрование",
|
||||
"serviceUnavailable": "Сообщение не доставлено получателю",
|
||||
"remoteServerNotFound": "Cообщение не доставлено, не найден сервер получателя",
|
||||
"failedToEncrypt": "Сообщение не может быть зашифровано",
|
||||
"failedToEncryptFile": "Файл не может быть зашифрован",
|
||||
"failedToDecryptFile": "Файл не может быть расшифрован",
|
||||
"fileNotEncrypted": "Этот чат зашифрован, но файл нет"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Не удалось завершить аудиозапись",
|
||||
"openFileNoAppError": "Приложения для открытия этого файла не найдены",
|
||||
"openFileGenericError": "Не удалось открыть файл",
|
||||
"messageErrorDialogTitle": "Ошибка"
|
||||
},
|
||||
"newChat": {
|
||||
"groupchatUnsupported": "Вступление в групповой чат пока не поддерживается.",
|
||||
"remoteServerError": "Не удалось связаться с удалённым сервером.",
|
||||
"unknown": "Неизвестная ошибка."
|
||||
},
|
||||
"general": {
|
||||
"noInternet": "Нет подключения к интернету."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"registerButton": "Регистрация",
|
||||
"loginButton": "Login",
|
||||
"noAccount": "Нет XMPP аккаунта? Зарегистрируйтесь на одном из серверов, это не сложно."
|
||||
},
|
||||
"login": {
|
||||
"title": "Login",
|
||||
"password": "Пароль",
|
||||
"xmppAddress": "XMPP-адрес",
|
||||
"advancedOptions": "Расширенные опции",
|
||||
"createAccount": "Зарегистрироваться на сервере"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Написать",
|
||||
"speeddialJoinGroupchat": "Групповой чат",
|
||||
"speeddialAddNoteToSelf": "Заметка себе",
|
||||
"overlaySettings": "Настройки",
|
||||
"startChat": "Начать диалог",
|
||||
"markAsRead": "Пометить как прочитанное",
|
||||
"noOpenChats": "У вас пока нет диалогов",
|
||||
"closeChat": "Завершить диалог",
|
||||
"closeChatBody": "Вы уверены, что хотите завершить диалог с ${conversationTitle}?"
|
||||
},
|
||||
"conversation": {
|
||||
"unencrypted": "Незашифрованно",
|
||||
"encrypted": "Зашифрованно",
|
||||
"closeChat": "Закрыть чат",
|
||||
"closeChatConfirmTitle": "Закрыть чат",
|
||||
"closeChatConfirmSubtext": "Вы уверены что хотите закрыть этот чат?",
|
||||
"blockShort": "Заблокировать",
|
||||
"blockUser": "Заблокировать пользователя",
|
||||
"online": "В сети",
|
||||
"retract": "Отозвать сообщение",
|
||||
"copy": "Копировать содержимое",
|
||||
"addReaction": "Реакция",
|
||||
"showError": "Показать ошибки",
|
||||
"showWarning": "Показать предупреждения",
|
||||
"sendFiles": "Отправить файл",
|
||||
"takePhotos": "Сделать фотографии",
|
||||
"retractBody": "Вы уверены, что хотите отозвать сообщение? Помните, что это всего лишь просьба, которую клиент не обязан выполнять.",
|
||||
"forward": "Переслать",
|
||||
"edit": "Изменить",
|
||||
"quote": "Цитировать",
|
||||
"addToContacts": "Добавить в контакты",
|
||||
"addToContactsTitle": "Добавить ${jid} в контакты",
|
||||
"addToContactsBody": "Вы уверены, что хотите добавить ${jid} в контакты?",
|
||||
"stickerPickerNoStickersLine1": "Нет установленных стикерпаков.",
|
||||
"stickerPickerNoStickersLine2": "Их можно установить в настройках стикеров.",
|
||||
"stickerSettings": "Настройки стикеров",
|
||||
"newDeviceMessage": {
|
||||
"one": "Добавлено новое устройство",
|
||||
"other": "Добавлено несколько новых устройств"
|
||||
},
|
||||
"replacedDeviceMessage": {
|
||||
"one": "Устройство было изменено",
|
||||
"other": "Добавлено несколько устройств"
|
||||
},
|
||||
"messageHint": "Сообщение...",
|
||||
"sendImages": "Отправить изображение",
|
||||
"messageCopied": "Сообщение скопировано в буфер",
|
||||
"warning": "Предупреждение"
|
||||
},
|
||||
"startchat": {
|
||||
"xmppAddress": "XMPP-адрес",
|
||||
"buttonAddToContact": "Добавить в контакты",
|
||||
"title": "Добавить контакт",
|
||||
"subtitle": "Вы можете добавить контакт введя его XMPP адрес или отсканировав QR код"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Новый чат",
|
||||
"startChat": "Добавить контакт",
|
||||
"createGroupchat": "Создать новый групповой чат"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Поделиться с...",
|
||||
"confirmTitle": "Отправить файл",
|
||||
"confirmBody": "Один или несколько чатов не зашифрованы, из-за чего файл будет доступен администрации сервера. Вы уверены, что хотите продолжить?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Безопасность",
|
||||
"profile": "Профиль",
|
||||
"media": "Медиа"
|
||||
},
|
||||
"conversation": {
|
||||
"sharedMedia": "Медиа",
|
||||
"notifications": "Уведомления",
|
||||
"notificationsMuted": "Без звука",
|
||||
"notificationsEnabled": "Включено"
|
||||
},
|
||||
"owndevices": {
|
||||
"thisDevice": "Это устройство",
|
||||
"recreateOwnDevice": "Восстановить устройство",
|
||||
"title": "Мои устройства",
|
||||
"otherDevices": "Другие устройства",
|
||||
"deleteDeviceConfirmTitle": "Удалить устройство",
|
||||
"deleteDeviceConfirmBody": "Контакты не смогут быть зашифрованы для этого устройства. Продолжить?",
|
||||
"recreateOwnSessions": "Пересоздать сеанс",
|
||||
"recreateOwnSessionsConfirmTitle": "Пересоздать свои сеансы?",
|
||||
"recreateOwnSessionsConfirmBody": "Создать новые ключи шифрования для этого устройства. Используйте только в крайнем случае.",
|
||||
"recreateOwnDeviceConfirmTitle": "Восстановить это устройство?",
|
||||
"recreateOwnDeviceConfirmBody": "Это создаст новый криптографический отпечаток устройства, что займёт некоторое время. Если ваше устройство было подтверждено контактами, им придётся сделать это снова. Продолжить?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Безопасность",
|
||||
"recreateSessionsConfirmTitle": "Пересоздать сеанс?",
|
||||
"noSessions": "Нет криптографических сессий, используемых для сквозного шифрования.",
|
||||
"recreateSessions": "Пересоздать сеанс",
|
||||
"recreateSessionsConfirmBody": "Создать новые ключи шифрования для этого устройства. Используйте только в крайнем случае."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"unblockAll": "Разблокировать всех",
|
||||
"unblockJidConfirmTitle": "Разблокировать ${jid}?",
|
||||
"title": "Блоклист",
|
||||
"noUsersBlocked": "Нет заблокированных пользователей",
|
||||
"unblockAllConfirmTitle": "Вы уверены?",
|
||||
"unblockAllConfirmBody": "Вы действительно хотите разблокировать всех пользователей?",
|
||||
"unblockJidConfirmBody": "Вы уверены, что хотите разблокировать ${jid}? Вы снова будете получать сообщения от этого пользователя."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Размыть фон",
|
||||
"setAsBackground": "Установить фоновое изображение"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Загрузить аватар"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Удалить стикерпак",
|
||||
"removeConfirmBody": "Вы действительно хотите удалить этот стикерпак?",
|
||||
"installConfirmTitle": "добавить стикерпак",
|
||||
"installConfirmBody": "Вы действительно хотите установить этот стикерпак?",
|
||||
"restricted": "Этот стикерпак ограничен, стикеры будут отображаться, но отправить их нельзя.",
|
||||
"fetchingFailure": "Стикерпак не найден"
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"version": "Версия ${version}",
|
||||
"debugMenuShown": "Теперь ты разработчик ^_^",
|
||||
"debugMenuAlreadyShown": "Ты уже разработчик :^",
|
||||
"title": "О нас",
|
||||
"viewSourceCode": "Исходный код",
|
||||
"nMoreToGo": "Осталось еще ${n}...",
|
||||
"licensed": "Лицензировано под GPL3"
|
||||
},
|
||||
"conversation": {
|
||||
"removeBackgroundImageConfirmBody": "Вы действительно хотите удалить фон?",
|
||||
"title": "Чат",
|
||||
"appearance": "Внешний вид",
|
||||
"selectBackgroundImage": "Выбрать фон",
|
||||
"removeBackgroundImage": "Удалить фон",
|
||||
"removeBackgroundImageConfirmTitle": "Удалить фон",
|
||||
"newChatsSection": "Новые чаты",
|
||||
"newChatsMuteByDefault": "Отключать звук в новых чатах по умолчанию",
|
||||
"newChatsE2EE": "Включить оконечное шифрование по умолчанию",
|
||||
"behaviourSection": "Поведение",
|
||||
"contactsIntegration": "Синхронизация контактов",
|
||||
"selectBackgroundImageDescription": "Это изображение будет фоном для ваших чатов",
|
||||
"contactsIntegrationBody": "При включении данные из Контактов будут использованы для названий чатов и фото профилей. На сервер ничего отправлено не будет."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Опции отладки",
|
||||
"generalSection": "Основные",
|
||||
"generalEnableDebugging": "Включить отладку",
|
||||
"generalEncryptionPassword": "Пароль шифрования",
|
||||
"generalEncryptionPasswordSubtext": "Журналы могут содержать конфиденциальную информацию, поэтому поставте надежный пароль",
|
||||
"generalLoggingIpSubtext": "IP, на который должны отправляться журналы",
|
||||
"generalLoggingIp": "IP для логов",
|
||||
"generalLoggingPort": "порт для логов",
|
||||
"generalLoggingPortSubtext": "IP, на который должны отправляться журналы"
|
||||
},
|
||||
"network": {
|
||||
"automaticDownloadsSection": "Автоматическая загрузка",
|
||||
"title": "Сеть",
|
||||
"automaticDownloadsMaximumSizeSubtext": "Максимальный размер, при котором файлы будут автоматически загружаться",
|
||||
"automaticDownloadsMaximumSize": "Максимальный размер для загрузки",
|
||||
"automaticDownloadAlways": "Всегда",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Мобильный интернет",
|
||||
"automaticDownloadsText": "Moxxy будет автоматически загружать файлы до..."
|
||||
},
|
||||
"privacy": {
|
||||
"showContactRequests": "Показывать запрос в контакты",
|
||||
"showContactRequestsSubtext": "Это покажет людей, добавивших вас в свой список контактов",
|
||||
"generalSection": "Основные",
|
||||
"profilePictureVisibility": "Сделать фото профиля публичным",
|
||||
"sendChatMarkers": "Отправлять маркеры",
|
||||
"redirectText": "Это позволит перенаправлять ссылки с ${serviceName} на прокси, такие как ${exampleProxy}",
|
||||
"redirectsSection": "Перенаправление",
|
||||
"currentlySelected": "Выбрано сейчас: $proxy",
|
||||
"redirectsTitle": "$serviceName Перенаправление",
|
||||
"urlEmpty": "URL не может быть пустым",
|
||||
"title": "Приватность",
|
||||
"conversationsSection": "Диалог",
|
||||
"sendChatMarkersSubtext": "Это сообщит вашему собеседнику о получении или прочтении сообщения",
|
||||
"sendChatStates": "Отправлять состояние чата",
|
||||
"sendChatStatesSubtext": "Собеседник будет видеть, когда вы набираете сообщение или просматриваете чат",
|
||||
"cannotEnableRedirect": "Не работает перенаправление $serviceName",
|
||||
"cannotEnableRedirectSubtext": "Сначала нужно добавить прокси сервер. Для этого нажмите слева от переключателя",
|
||||
"urlInvalid": "Недопустимый URL",
|
||||
"redirectDialogTitle": "$serviceName Перенаправление",
|
||||
"stickersPrivacy": "Публиковать список стикеров",
|
||||
"stickersPrivacySubtext": "Когда включено, все могут видет установленные у вас стикеры",
|
||||
"profilePictureVisibilitSubtext": "Когда включено, все видят Ваш аватар; когда выключено - только контакты"
|
||||
},
|
||||
"stickers": {
|
||||
"importSuccess": "Стикерпаки успешно импортированы",
|
||||
"title": "Стикеры",
|
||||
"stickerSection": "Стикеры",
|
||||
"displayStickers": "Показывать стикеры",
|
||||
"autoDownload": "Загружать стикеры автоматически",
|
||||
"autoDownloadBody": "Стикеры будут автоматически загружаться, если их отправитель у вас в контактах",
|
||||
"stickerPacksSection": "Стикерпаки",
|
||||
"importStickerPack": "Импортировать стикерпаки",
|
||||
"importFailure": "Ошибка при импорте стикерпаков"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
"conversationsSection": "Чаты",
|
||||
"accountSection": "Учётная запись",
|
||||
"signOut": "Выйти",
|
||||
"signOutConfirmTitle": "Выйти",
|
||||
"signOutConfirmBody": "Вы хотите выйти, продолжить?",
|
||||
"miscellaneousSection": "Другое",
|
||||
"debuggingSection": "Отладка",
|
||||
"general": "Основные"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Внешний вид",
|
||||
"languageSection": "Язык",
|
||||
"language": "Язык в приложении",
|
||||
"systemLanguage": "Как в системе",
|
||||
"languageSubtext": "Выбранный язык: ${selectedLanguage}"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Открытые лицензии",
|
||||
"licensedUnder": "Лицензировано под ${license}"
|
||||
}
|
||||
},
|
||||
"sharedMedia": {
|
||||
"empty": {
|
||||
"chat": "Нет общих медиафайлов для этого чата",
|
||||
"general": "Нет доступных медиаустройств"
|
||||
}
|
||||
}
|
||||
},
|
||||
"language": "Русский",
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Не удалось проверить целостность файла"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Удерживайте для записи"
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
assets/images/empty.png
Normal file
BIN
assets/images/empty.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
@@ -5,3 +5,5 @@ targets:
|
||||
options:
|
||||
input_directory: assets/i18n
|
||||
output_directory: lib/i18n
|
||||
fallback_strategy: base_locale
|
||||
base_locale: en
|
||||
|
||||
1
fastlane/metadata/android/de-DE/short_description.txt
Normal file
1
fastlane/metadata/android/de-DE/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Moxxy ist ein experimenteller XMPP-Client, der modern und einfach sein soll.
|
||||
1
fastlane/metadata/android/de-DE/title.txt
Normal file
1
fastlane/metadata/android/de-DE/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Moxxy
|
||||
12
fastlane/metadata/android/en-US/changelogs/11.txt
Normal file
12
fastlane/metadata/android/en-US/changelogs/11.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
Many changes in this release are under the hood, but there are many changes nonetheless:
|
||||
|
||||
- Messages that are sent while offline are now queued up until we're online again
|
||||
- Moxxy now makes use of SFS's caching possibilities. Receiving files sent via SFS are thus only downloaded if the file is not already locally available
|
||||
- Messages and shared media files are now shown in paged lists
|
||||
- Reworked various pages, like the Conversation page and the profile page
|
||||
- Rework the reactions UI
|
||||
- Add a "note to self" feature. This was a teaser task in the context of this year's GSoC
|
||||
- Chat states are no longer sent if a chat is no longer focused
|
||||
- Sending a sticker when a message is selected for quoting, the sticker is sent as a reply to that message
|
||||
- The database design was massively overhauled
|
||||
- The emoji/sticker picker should no longer jump around when switching from the keyboard
|
||||
7
fastlane/metadata/android/en-US/changelogs/12.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/12.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
This is a hotfix release.
|
||||
|
||||
Sending a message with no attached file results in a gray
|
||||
box being displayed over the entire message list. This release
|
||||
contains a fix for that.
|
||||
|
||||
(I also dropped my fork of the Flutter SDK)
|
||||
6
fastlane/metadata/android/en-US/changelogs/13.txt
Normal file
6
fastlane/metadata/android/en-US/changelogs/13.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
- (Hopefully) fix OMEMO between two Moxxy clients.
|
||||
- Allow correcting messages older than the last one. Whether all clients will accept such a correction is unclear.
|
||||
- Add (incomplete) translations for Dutch, Japanese, and Russian.
|
||||
- Fix having to long-press a message bubble on its corner to active the selection menu.
|
||||
- If enabled, read markers are automatically sent.
|
||||
- Highlight legacy quotes in text messages.
|
||||
10
fastlane/metadata/android/nl-NL/changelogs/11.txt
Normal file
10
fastlane/metadata/android/nl-NL/changelogs/11.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
- Offline-berichten worden verstuurd als de verbinding hersteld is;
|
||||
- SFS-cache, waardoor downloaden alleen plaatsvindt indien niet lokaal beschikbaar;
|
||||
- Berichten en mediabestanden worden op pagina's getoond;
|
||||
- Diverse pagina's bijgewerkt;
|
||||
- Reacties herontworpen;
|
||||
- Zelfmemofunctie;
|
||||
- Gespreksstatussen worden niet meer verstuurd indien ongefocust;
|
||||
- Stickers als antwoord op citaten;
|
||||
- Nieuw databankontwerp;
|
||||
- Verbeterde emoji-/stickerkeuze i.c.m. toetsenbord.
|
||||
5
fastlane/metadata/android/nl-NL/changelogs/12.txt
Normal file
5
fastlane/metadata/android/nl-NL/changelogs/12.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
Dit is een oplossingsversie:
|
||||
|
||||
Het versturen van een bericht zonder bijlage zorgde voor een grijs vlak op de berichtenlijst. Dat is nu opgelost.
|
||||
|
||||
(Ook ben ik gestopt met de ontwikkeling van mijn afsplitsing van de Flutter-sdk.)
|
||||
5
fastlane/metadata/android/nl-NL/changelogs/13.txt
Normal file
5
fastlane/metadata/android/nl-NL/changelogs/13.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
-(Hopelijk) Oplossing voor OMEMO tussen twee Moxxy-clients;
|
||||
-Oplossing voor het lang ingedrukt houden van een bericht om het keuzemenu te openen;
|
||||
-Leesmarkeringen worden voortaan automatisch verzonden (indien ingeschakeld);
|
||||
-Nieuw: (onvolledige) Nederlandse, Japanse en Russische vertalingen;
|
||||
-Nieuw: bewerken van berichten ouder dan het recentste bericht. Onduidelijk of alle clients dit op de juiste manier tonen.
|
||||
7
fastlane/metadata/android/nl-NL/changelogs/9.txt
Normal file
7
fastlane/metadata/android/nl-NL/changelogs/9.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
|
||||
* Maybe fix a connection race condition
|
||||
* Allow sharing media with the app when it was closed
|
||||
* Make quotes prettier
|
||||
* Make the bottom part of the conversation page prettier
|
||||
* Fix roster fetching
|
||||
* Fix OMEMO key generation
|
||||
24
fastlane/metadata/android/nl-NL/full_description.txt
Normal file
24
fastlane/metadata/android/nl-NL/full_description.txt
Normal file
@@ -0,0 +1,24 @@
|
||||
Moxxy is een experimentele xmpp-client met als doel modern gebruiksgemak.
|
||||
|
||||
Let op: Moxxy is momenteel in de alfafase. Dit houdt in dat er gegarandeerd bugs en
|
||||
problemen zullen zijn. Gebruik Moxxy dus niet voor belangrijke zaken.
|
||||
|
||||
Huidige functies:
|
||||
<ul>
|
||||
<li>Verstuur bestanden en afbeeldingen;</li>
|
||||
<li>Stel je profielfoto in;</li>
|
||||
<li>Typmeldingen en berichtstatussen;</li>
|
||||
<li>Gespreksachtergronden;</li>
|
||||
<li>Draait op de achtergrond zónder pushmeldingen;</li>
|
||||
<li>OMEMO (momenteel niet compatibel met de meeste apps);</li>
|
||||
<li>Stickers.</li>
|
||||
</ul>
|
||||
|
||||
Voor de beste gebruikservaring is het belangrijk om een server te gebruiken met:
|
||||
<ul>
|
||||
<li>Ondersteuning voor TLS/StartTLS op dezelfde domeinnaam als in de Jid;</li>
|
||||
<li>Ondersteuning voor SCRAM-SHA-1, SCRAM-SHA-256 of SCRAM-SHA-512;</li>
|
||||
<li>Ondersteuning voor HTTP-bestandsupload;</li>
|
||||
<li>Ondersteuning voor streambeheer;</li>
|
||||
<li>Ondersteuning voor Client State Indication.</li>
|
||||
</ul>
|
||||
1
fastlane/metadata/android/nl-NL/short_description.txt
Normal file
1
fastlane/metadata/android/nl-NL/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Moxxy is een experimentele xmpp-client met als doel modern gebruiksgemak.
|
||||
1
fastlane/metadata/android/nl-NL/title.txt
Normal file
1
fastlane/metadata/android/nl-NL/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Moxxy
|
||||
117
flake.lock
generated
117
flake.lock
generated
@@ -1,6 +1,66 @@
|
||||
{
|
||||
"nodes": {
|
||||
"android-nixpkgs": {
|
||||
"inputs": {
|
||||
"devshell": "devshell",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1689798050,
|
||||
"narHash": "sha256-ZyFPra7N0MF803o55dYQQyX9b/BmXr6QTCyN7slRThY=",
|
||||
"owner": "tadfisher",
|
||||
"repo": "android-nixpkgs",
|
||||
"rev": "9aa0e2990da86de8ca203af313668851dcb9ea6e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "tadfisher",
|
||||
"repo": "android-nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"devshell": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"android-nixpkgs",
|
||||
"nixpkgs"
|
||||
],
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1688380630,
|
||||
"narHash": "sha256-8ilApWVb1mAi4439zS3iFeIT0ODlbrifm/fegWwgHjA=",
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"rev": "f9238ec3d75cefbb2b42a44948c4e8fb1ae9a205",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "devshell",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"locked": {
|
||||
"lastModified": 1667395993,
|
||||
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
||||
@@ -17,11 +77,27 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1669165918,
|
||||
"narHash": "sha256-hIVruk2+0wmw/Kfzy11rG3q7ev3VTi/IKVODeHcVjFo=",
|
||||
"lastModified": 1689679375,
|
||||
"narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "3b400a525d92e4085e46141ff48cbf89fd89739e",
|
||||
"rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1689752456,
|
||||
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -33,8 +109,39 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"android-nixpkgs": "android-nixpkgs",
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
71
flake.nix
71
flake.nix
@@ -3,33 +3,46 @@
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
android-nixpkgs.url = "github:tadfisher/android-nixpkgs";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let
|
||||
outputs = { self, nixpkgs, flake-utils, android-nixpkgs }: flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config = {
|
||||
android_sdk.accept_license = true;
|
||||
allowUnfree = true;
|
||||
|
||||
# Fix to allow building the NDK package
|
||||
# TODO: Remove once https://github.com/tadfisher/android-nixpkgs/issues/62 is resolved
|
||||
permittedInsecurePackages = [
|
||||
"python-2.7.18.6"
|
||||
];
|
||||
};
|
||||
};
|
||||
android = pkgs.androidenv.composeAndroidPackages {
|
||||
# TODO: Find a way to pin these
|
||||
#toolsVersion = "26.1.1";
|
||||
#platformToolsVersion = "31.0.3";
|
||||
#buildToolsVersions = [ "31.0.0" ];
|
||||
#includeEmulator = true;
|
||||
#emulatorVersion = "30.6.3";
|
||||
platformVersions = [ "28" ];
|
||||
includeSources = false;
|
||||
includeSystemImages = true;
|
||||
systemImageTypes = [ "default" ];
|
||||
abiVersions = [ "x86_64" ];
|
||||
includeNDK = false;
|
||||
useGoogleAPIs = false;
|
||||
useGoogleTVAddOns = false;
|
||||
};
|
||||
pinnedJDK = pkgs.jdk;
|
||||
# Everything to make Flutter happy
|
||||
sdk = android-nixpkgs.sdk.${system} (sdkPkgs: with sdkPkgs; [
|
||||
cmdline-tools-latest
|
||||
build-tools-30-0-3
|
||||
build-tools-33-0-2
|
||||
build-tools-34-0-0
|
||||
platform-tools
|
||||
emulator
|
||||
patcher-v4
|
||||
platforms-android-28
|
||||
platforms-android-29
|
||||
platforms-android-30
|
||||
platforms-android-31
|
||||
platforms-android-33
|
||||
|
||||
# For flutter_zxing
|
||||
cmake-3-18-1
|
||||
#ndk-21-4-7075529
|
||||
(ndk-21-4-7075529.overrideAttrs (old: {
|
||||
buildInputs = old.buildInputs ++ [ pkgs.python27 ];
|
||||
}))
|
||||
]);
|
||||
pinnedJDK = pkgs.jdk17;
|
||||
|
||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||
requests pyyaml # For the build scripts
|
||||
@@ -38,13 +51,27 @@
|
||||
in {
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
flutter pinnedJDK android.platform-tools dart scrcpy # Flutter/Android
|
||||
pythonEnv gnumake # Build scripts
|
||||
gitlint jq # Code hygiene
|
||||
ripgrep # General utilities
|
||||
# Android
|
||||
pinnedJDK sdk
|
||||
scrcpy
|
||||
|
||||
# Flutter
|
||||
flutter37
|
||||
|
||||
# Build scripts
|
||||
pythonEnv gnumake
|
||||
|
||||
# Code hygiene
|
||||
gitlint jq
|
||||
];
|
||||
|
||||
ANDROID_SDK_ROOT = "${sdk}/share/android-sdk";
|
||||
ANDROID_HOME = "${sdk}/share/android-sdk";
|
||||
JAVA_HOME = pinnedJDK;
|
||||
|
||||
# Fix an issue with Flutter using an older version of aapt2, which does not know
|
||||
# an used parameter.
|
||||
GRADLE_OPTS = "-Dorg.gradle.project.android.aapt2FromMavenOverride=${sdk}/share/android-sdk/build-tools/34.0.0/aapt2";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,18 +36,6 @@ files:
|
||||
roster:
|
||||
type: List<RosterItem>?
|
||||
deserialise: true
|
||||
stickers:
|
||||
type: List<StickerPack>?
|
||||
deserialise: true
|
||||
# Returned by [GetMessagesForJidCommand]
|
||||
- name: MessagesResultEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messages:
|
||||
type: List<Message>
|
||||
deserialise: true
|
||||
# Triggered if a conversation has been added.
|
||||
# Also returned by [AddConversationCommand]
|
||||
- name: ConversationAddedEvent
|
||||
@@ -74,7 +62,7 @@ files:
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- 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
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
@@ -217,7 +205,7 @@ files:
|
||||
attributes:
|
||||
conversationJid: String
|
||||
title: String
|
||||
avatarUrl: String
|
||||
avatarPath: String
|
||||
- name: StickerPackImportSuccessEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
@@ -265,6 +253,88 @@ files:
|
||||
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
|
||||
# Triggered when the stream negotiations have been completed
|
||||
- name: StreamNegotiationsCompletedEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
resumed: bool
|
||||
- name: AvatarUpdatedEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
path: String
|
||||
# Returned when attempting to start a chat with a groupchat
|
||||
- name: JidIsGroupchatEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
# Returned when an error occured
|
||||
- name: ErrorEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
errorId: int
|
||||
# Returned after a [GetStorageUsageCommand]
|
||||
- name: GetStorageUsageEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
# The used storage in bytes for media files
|
||||
mediaUsage: int
|
||||
# The used storage in bytes for stickers
|
||||
stickerUsage: int
|
||||
# Returned after [DeleteOldMediaFilesCommand]
|
||||
- name: DeleteOldMediaFilesDoneEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
# The used storage in bytes after the deletion operation is done
|
||||
newUsage: int
|
||||
# The new list of Conversations
|
||||
conversations:
|
||||
type: List<Conversation>
|
||||
deserialize: true
|
||||
- name: PagedStickerPackResult
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
stickerPacks:
|
||||
type: List<StickerPack>
|
||||
deserialise: true
|
||||
- name: GetStickerPackByIdResult
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
stickerPack:
|
||||
type: StickerPack?
|
||||
deserialise: true
|
||||
generate_builder: true
|
||||
builder_name: "Event"
|
||||
builder_baseclass: "BackgroundEvent"
|
||||
@@ -294,12 +364,7 @@ files:
|
||||
lastMessageBody: String
|
||||
avatarUrl: String
|
||||
jid: String
|
||||
- name: GetMessagesForJidCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
conversationType: String
|
||||
- name: SetOpenConversationCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -319,6 +384,7 @@ files:
|
||||
deserialise: true
|
||||
editSid: String?
|
||||
editId: int?
|
||||
currentConversationJid: String?
|
||||
- name: SendFilesCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -362,6 +428,12 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
- name: RemoveContactCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
- name: RequestDownloadCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -467,15 +539,14 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationId: int
|
||||
conversationJid: String
|
||||
- name: MarkMessageAsReadCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationJid: String
|
||||
sid: String
|
||||
newUnreadCounter: int
|
||||
id: int
|
||||
sendMarker: bool
|
||||
- name: AddReactionToMessageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -516,9 +587,13 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
stickerPackId: String
|
||||
stickerHashKey: String
|
||||
sticker:
|
||||
type: Sticker
|
||||
deserialise: true
|
||||
recipient: String
|
||||
quotes:
|
||||
type: Message?
|
||||
deserialise: true
|
||||
- name: FetchStickerPackCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -539,6 +614,68 @@ files:
|
||||
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
|
||||
- name: RequestAvatarForJidCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
hash: String?
|
||||
ownAvatar: bool
|
||||
- name: GetStorageUsageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
- name: DeleteOldMediaFilesCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
# Milliseconds from now in the past; The maximum age of a file to not
|
||||
# get deleted.
|
||||
timeOffset: int
|
||||
- name: GetPagedStickerPackCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
olderThan: bool
|
||||
timestamp: int?
|
||||
includeStickers: bool
|
||||
- name: GetStickerPackByIdCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
id: String
|
||||
- name: DebugCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
id: int
|
||||
generate_builder: true
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
||||
146
lib/main.dart
146
lib/main.dart
@@ -10,7 +10,6 @@ import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/synchronized_queue.dart';
|
||||
import 'package:moxxyv2/ui/bloc/addcontact_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/blocklist_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/conversations_bloc.dart';
|
||||
@@ -26,16 +25,16 @@ import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/server_info_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/share_selection_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sharedmedia_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/startchat_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sticker_pack_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/stickers_bloc.dart';
|
||||
import 'package:moxxyv2/ui/constants.dart';
|
||||
import 'package:moxxyv2/ui/controller/conversation_controller.dart';
|
||||
import 'package:moxxyv2/ui/events.dart';
|
||||
/*
|
||||
import "package:moxxyv2/ui/pages/register/register.dart";
|
||||
import "package:moxxyv2/ui/pages/postregister/postregister.dart";
|
||||
*/
|
||||
import 'package:moxxyv2/ui/pages/addcontact.dart';
|
||||
import 'package:moxxyv2/ui/pages/blocklist.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversation/conversation.dart';
|
||||
import 'package:moxxyv2/ui/pages/conversations.dart';
|
||||
@@ -57,14 +56,21 @@ import 'package:moxxyv2/ui/pages/settings/licenses.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/network.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/privacy/privacy.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/settings.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/sticker_packs.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/stickers.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/storage/shared_media.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/storage/storage.dart';
|
||||
import 'package:moxxyv2/ui/pages/share_selection.dart';
|
||||
import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||
//import 'package:moxxyv2/ui/pages/sharedmedia.dart';
|
||||
import 'package:moxxyv2/ui/pages/splashscreen/splashscreen.dart';
|
||||
import 'package:moxxyv2/ui/pages/startchat.dart';
|
||||
import 'package:moxxyv2/ui/pages/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/pages/util/qrcode.dart';
|
||||
import 'package:moxxyv2/ui/service/avatars.dart';
|
||||
import 'package:moxxyv2/ui/service/connectivity.dart';
|
||||
import 'package:moxxyv2/ui/service/data.dart';
|
||||
import 'package:moxxyv2/ui/service/progress.dart';
|
||||
import 'package:moxxyv2/ui/service/read.dart';
|
||||
import 'package:moxxyv2/ui/service/sharing.dart';
|
||||
import 'package:moxxyv2/ui/theme.dart';
|
||||
import 'package:page_transition/page_transition.dart';
|
||||
@@ -73,7 +79,9 @@ void setupLogging() {
|
||||
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
|
||||
Logger.root.onRecord.listen((record) {
|
||||
// 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'));
|
||||
}
|
||||
@@ -81,19 +89,25 @@ void setupLogging() {
|
||||
Future<void> setupUIServices() async {
|
||||
GetIt.I.registerSingleton<UIProgressService>(UIProgressService());
|
||||
GetIt.I.registerSingleton<UIDataService>(UIDataService());
|
||||
GetIt.I.registerSingleton<UIAvatarsService>(UIAvatarsService());
|
||||
GetIt.I.registerSingleton<UISharingService>(UISharingService());
|
||||
GetIt.I.registerSingleton<UIConnectivityService>(UIConnectivityService());
|
||||
GetIt.I.registerSingleton<UIReadMarkerService>(UIReadMarkerService());
|
||||
|
||||
/// Initialize services
|
||||
await GetIt.I.get<UIConnectivityService>().initialize();
|
||||
}
|
||||
|
||||
void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: navKey));
|
||||
GetIt.I
|
||||
.registerSingleton<NavigationBloc>(NavigationBloc(navigationKey: navKey));
|
||||
GetIt.I.registerSingleton<ConversationsBloc>(ConversationsBloc());
|
||||
GetIt.I.registerSingleton<NewConversationBloc>(NewConversationBloc());
|
||||
GetIt.I.registerSingleton<ConversationBloc>(ConversationBloc());
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||
GetIt.I.registerSingleton<SharedMediaBloc>(SharedMediaBloc());
|
||||
GetIt.I.registerSingleton<StartChatBloc>(StartChatBloc());
|
||||
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
||||
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
||||
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
||||
@@ -116,6 +130,8 @@ void main() async {
|
||||
|
||||
await initializeServiceIfNeeded();
|
||||
|
||||
imageCache.maximumSizeBytes = 500 * 1024 * 1024;
|
||||
|
||||
runApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
@@ -143,11 +159,8 @@ void main() async {
|
||||
BlocProvider<PreferencesBloc>(
|
||||
create: (_) => GetIt.I.get<PreferencesBloc>(),
|
||||
),
|
||||
BlocProvider<AddContactBloc>(
|
||||
create: (_) => GetIt.I.get<AddContactBloc>(),
|
||||
),
|
||||
BlocProvider<SharedMediaBloc>(
|
||||
create: (_) => GetIt.I.get<SharedMediaBloc>(),
|
||||
BlocProvider<StartChatBloc>(
|
||||
create: (_) => GetIt.I.get<StartChatBloc>(),
|
||||
),
|
||||
BlocProvider<CropBloc>(
|
||||
create: (_) => GetIt.I.get<CropBloc>(),
|
||||
@@ -209,7 +222,9 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
await GetIt.I.get<UISharingService>().initialize();
|
||||
|
||||
// Lift the UI block
|
||||
await GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||
await GetIt.I
|
||||
.get<SynchronizedQueue<Map<String, dynamic>?>>()
|
||||
.removeQueueLock();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -228,13 +243,15 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
sender.sendData(
|
||||
SetCSIStateCommand(active: false),
|
||||
);
|
||||
GetIt.I.get<ConversationBloc>().add(AppStateChanged(false));
|
||||
BidirectionalConversationController.currentController
|
||||
?.handleAppStateChange(false);
|
||||
break;
|
||||
case AppLifecycleState.resumed:
|
||||
sender.sendData(
|
||||
SetCSIStateCommand(active: true),
|
||||
);
|
||||
GetIt.I.get<ConversationBloc>().add(AppStateChanged(true));
|
||||
BidirectionalConversationController.currentController
|
||||
?.handleAppStateChange(true);
|
||||
break;
|
||||
case AppLifecycleState.detached:
|
||||
case AppLifecycleState.inactive:
|
||||
@@ -254,39 +271,80 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
navigatorKey: widget.navigationKey,
|
||||
onGenerateRoute: (settings) {
|
||||
switch (settings.name) {
|
||||
case introRoute: return Intro.route;
|
||||
case loginRoute: return Login.route;
|
||||
case conversationsRoute: return ConversationsPage.route;
|
||||
case newConversationRoute: return NewConversationPage.route;
|
||||
case conversationRoute: return PageTransition<dynamic>(
|
||||
case introRoute:
|
||||
return Intro.route;
|
||||
case loginRoute:
|
||||
return Login.route;
|
||||
case conversationsRoute:
|
||||
return ConversationsPage.route;
|
||||
case newConversationRoute:
|
||||
return NewConversationPage.route;
|
||||
case conversationRoute:
|
||||
final args = settings.arguments! as ConversationPageArguments;
|
||||
return PageTransition<dynamic>(
|
||||
type: PageTransitionType.rightToLeft,
|
||||
settings: settings,
|
||||
child: const ConversationPage(),
|
||||
child: ConversationPage(
|
||||
conversationJid: args.conversationJid,
|
||||
initialText: args.initialText,
|
||||
),
|
||||
);
|
||||
case sharedMediaRoute: return SharedMediaPage.route;
|
||||
case blocklistRoute: return BlocklistPage.route;
|
||||
case profileRoute: return ProfilePage.route;
|
||||
case settingsRoute: 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(
|
||||
// case sharedMediaRoute:
|
||||
// return SharedMediaPage.getRoute(
|
||||
// settings.arguments! as SharedMediaPageArguments,
|
||||
// );
|
||||
case blocklistRoute:
|
||||
return BlocklistPage.route;
|
||||
case profileRoute:
|
||||
return ProfilePage.getRoute(
|
||||
settings.arguments! as ProfileArguments,
|
||||
);
|
||||
case settingsRoute:
|
||||
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 StartChatPage.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;
|
||||
case stickersRoute:
|
||||
return StickersSettingsPage.route;
|
||||
case stickerPacksRoute:
|
||||
return StickerPacksSettingsPage.route;
|
||||
case stickerPackRoute:
|
||||
return StickerPackPage.route;
|
||||
case storageSettingsRoute:
|
||||
return StorageSettingsPage.route;
|
||||
case storageSharedMediaSettingsRoute:
|
||||
return StorageSharedMediaPage.route;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -1,150 +1,139 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/roster.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/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
|
||||
/// Removes line breaks and spaces from [original]. This might happen when we request the
|
||||
/// avatar data. Returns the cleaned version.
|
||||
String _cleanBase64String(String original) {
|
||||
var ret = original;
|
||||
for (final char in ['\n', ' ']) {
|
||||
ret = ret.replaceAll(char, '');
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
class _AvatarData {
|
||||
const _AvatarData(this.data, this.id);
|
||||
final List<int> data;
|
||||
final String id;
|
||||
}
|
||||
|
||||
class AvatarService {
|
||||
final Logger _log = Logger('AvatarService');
|
||||
|
||||
Future<void> handleAvatarUpdate(AvatarUpdatedEvent event) async {
|
||||
await updateAvatarForJid(
|
||||
event.jid,
|
||||
event.hash,
|
||||
base64Decode(_cleanBase64String(event.base64)),
|
||||
);
|
||||
/// List of JIDs for which we have already requested the avatar in the current stream.
|
||||
final List<JID> _requestedInStream = [];
|
||||
|
||||
void resetCache() {
|
||||
_requestedInStream.clear();
|
||||
}
|
||||
|
||||
Future<void> updateAvatarForJid(String jid, String hash, List<int> data) async {
|
||||
Future<bool> _fetchAvatarForJid(JID jid, String hash) async {
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final rawAvatar = await am.getUserAvatar(jid);
|
||||
if (rawAvatar.isType<AvatarError>()) {
|
||||
_log.warning('Failed to request avatar for $jid');
|
||||
return false;
|
||||
}
|
||||
|
||||
final avatar = rawAvatar.get<UserAvatarData>();
|
||||
await _updateAvatarForJid(
|
||||
jid,
|
||||
avatar.hash,
|
||||
avatar.data,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Requests the avatar for [jid]. [oldHash], if given, is the last SHA-1 hash of the known avatar.
|
||||
/// If the avatar for [jid] has already been requested in this stream session, does nothing. Otherwise,
|
||||
/// requests the XEP-0084 metadata and queries the new avatar only if the queried SHA-1 != [oldHash].
|
||||
///
|
||||
/// Returns true, if everything went okay. Returns false if an error occurred.
|
||||
Future<bool> requestAvatar(JID jid, String? oldHash) async {
|
||||
if (_requestedInStream.contains(jid)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
_requestedInStream.add(jid);
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final am = conn.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final rawId = await am.getAvatarId(jid);
|
||||
|
||||
if (rawId.isType<AvatarError>()) {
|
||||
_log.finest(
|
||||
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final id = rawId.get<String>();
|
||||
if (id == oldHash) {
|
||||
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||
return true;
|
||||
}
|
||||
|
||||
return _fetchAvatarForJid(jid, id);
|
||||
}
|
||||
|
||||
Future<void> handleAvatarUpdate(UserAvatarUpdatedEvent event) async {
|
||||
if (event.metadata.isEmpty) return;
|
||||
|
||||
// TODO(Unknown): Maybe make a better decision?
|
||||
await _fetchAvatarForJid(event.jid, event.metadata.first.id);
|
||||
}
|
||||
|
||||
/// Updates the avatar path and hash for the conversation and/or roster item with jid [JID].
|
||||
/// [hash] is the new hash of the avatar. [data] is the raw avatar data.
|
||||
Future<void> _updateAvatarForJid(
|
||||
JID jid,
|
||||
String hash,
|
||||
List<int> data,
|
||||
) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final originalConversation = await cs.getConversationByJid(jid);
|
||||
final originalRoster = await rs.getRosterItemByJid(jid);
|
||||
final originalConversation = await cs.getConversationByJid(jid.toString());
|
||||
final originalRoster = await rs.getRosterItemByJid(jid.toString());
|
||||
|
||||
if (originalConversation == null && originalRoster == null) return;
|
||||
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
data,
|
||||
hash,
|
||||
jid,
|
||||
(originalConversation?.avatarUrl ?? originalRoster?.avatarUrl)!,
|
||||
jid.toString(),
|
||||
(originalConversation?.avatarPath ?? originalRoster?.avatarPath)!,
|
||||
);
|
||||
|
||||
if (originalConversation != null) {
|
||||
final conv = await cs.updateConversation(
|
||||
originalConversation.id,
|
||||
avatarUrl: avatarPath,
|
||||
final conversation = await cs.createOrUpdateConversation(
|
||||
jid.toString(),
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
jid.toString(),
|
||||
avatarPath: avatarPath,
|
||||
avatarHash: hash,
|
||||
);
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: conv));
|
||||
},
|
||||
);
|
||||
if (conversation != null) {
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(conversation: conversation),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (originalRoster != null) {
|
||||
final roster = await rs.updateRosterItem(
|
||||
originalRoster.id,
|
||||
avatarUrl: avatarPath,
|
||||
avatarPath: avatarPath,
|
||||
avatarHash: hash,
|
||||
);
|
||||
|
||||
sendEvent(RosterDiffEvent(modified: [roster]));
|
||||
}
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleUserAvatar(String jid, String oldHash) async {
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final idResult = await am.getAvatarId(jid);
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
if (id == oldHash) return null;
|
||||
|
||||
final avatarResult = await am.getUserAvatar(jid);
|
||||
if (avatarResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final avatar = avatarResult.get<UserAvatar>();
|
||||
|
||||
return _AvatarData(
|
||||
base64Decode(_cleanBase64String(avatar.base64)),
|
||||
avatar.hash,
|
||||
sendEvent(
|
||||
AvatarUpdatedEvent(
|
||||
jid: jid.toString(),
|
||||
path: avatarPath,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<_AvatarData?> _handleVcardAvatar(String jid, String oldHash) async {
|
||||
// Query the vCard
|
||||
final vm = GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<VCardManager>(vcardManager)!;
|
||||
final vcardResult = await vm.requestVCard(jid);
|
||||
if (vcardResult.isType<VCardError>()) return null;
|
||||
|
||||
final binval = vcardResult.get<VCard>().photo?.binval;
|
||||
if (binval == null) return null;
|
||||
|
||||
final data = base64Decode(_cleanBase64String(binval));
|
||||
final rawHash = await Sha1().hash(data);
|
||||
final hash = HEX.encode(rawHash.bytes);
|
||||
|
||||
vm.setLastHash(jid, hash);
|
||||
|
||||
return _AvatarData(
|
||||
data,
|
||||
hash,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> fetchAndUpdateAvatarForJid(String jid, String oldHash) async {
|
||||
_AvatarData? data;
|
||||
data ??= await _handleUserAvatar(jid, oldHash);
|
||||
data ??= await _handleVcardAvatar(jid, oldHash);
|
||||
|
||||
if (data != null) {
|
||||
await updateAvatarForJid(jid, data.id, data.data);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> subscribeJid(String jid) async {
|
||||
return (await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||
.subscribe(jid)).isType<bool>();
|
||||
}
|
||||
|
||||
Future<bool> unsubscribeJid(String jid) async {
|
||||
return (await GetIt.I.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!
|
||||
.unsubscribe(jid)).isType<bool>();
|
||||
}
|
||||
|
||||
/// Publishes the data at [path] as an avatar with PubSub ID
|
||||
/// [hash]. [hash] must be the hex-encoded version of the SHA-1 hash
|
||||
/// of the avatar data.
|
||||
@@ -159,7 +148,8 @@ class AvatarService {
|
||||
final imageSize = (await getImageSizeFromData(bytes))!;
|
||||
|
||||
// Publish data and metadata
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
final am = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
|
||||
_log.finest('Publishing avatar...');
|
||||
@@ -182,6 +172,7 @@ class AvatarService {
|
||||
imageSize.height.toInt(),
|
||||
// TODO(PapaTutuWawa): Maybe do a check here
|
||||
'image/png',
|
||||
null,
|
||||
),
|
||||
public,
|
||||
);
|
||||
@@ -194,41 +185,52 @@ class AvatarService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
|
||||
Future<void> requestOwnAvatar() async {
|
||||
final am = GetIt.I.get<XmppConnection>()
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.getXmppState();
|
||||
final jid = JID.fromString(state.jid!);
|
||||
|
||||
if (_requestedInStream.contains(jid)) {
|
||||
return;
|
||||
}
|
||||
_requestedInStream.add(jid);
|
||||
|
||||
final am = GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<UserAvatarManager>(userAvatarManager)!;
|
||||
final xmpp = GetIt.I.get<XmppService>();
|
||||
final state = await xmpp.getXmppState();
|
||||
final jid = state.jid!;
|
||||
final idResult = await am.getAvatarId(jid);
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.info('Error while getting latest avatar id for own avatar');
|
||||
final rawId = await am.getAvatarId(jid);
|
||||
if (rawId.isType<AvatarError>()) {
|
||||
_log.finest(
|
||||
'Failed to get avatar metadata for $jid using XEP-0084: ${rawId.get<AvatarError>()}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
final id = rawId.get<String>();
|
||||
|
||||
if (id == state.avatarHash) return;
|
||||
|
||||
_log.info('Mismatch between saved avatar data and server-side avatar data about ourself');
|
||||
final avatarDataResult = await am.getUserAvatar(jid);
|
||||
if (avatarDataResult.isType<AvatarError>()) {
|
||||
_log.severe('Failed to fetch our avatar');
|
||||
if (id == state.avatarHash) {
|
||||
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||
return;
|
||||
}
|
||||
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||
|
||||
_log.info('Received data for our own avatar');
|
||||
|
||||
final rawAvatar = await am.getUserAvatar(jid);
|
||||
if (rawAvatar.isType<AvatarError>()) {
|
||||
_log.warning('Failed to request avatar for $jid');
|
||||
return;
|
||||
}
|
||||
final avatarData = rawAvatar.get<UserAvatarData>();
|
||||
final avatarPath = await saveAvatarInCache(
|
||||
base64Decode(_cleanBase64String(avatarData.base64)),
|
||||
avatarData.data,
|
||||
avatarData.hash,
|
||||
jid,
|
||||
jid.toString(),
|
||||
state.avatarUrl,
|
||||
);
|
||||
await xmpp.modifyXmppState((state) => state.copyWith(
|
||||
await xss.modifyXmppState(
|
||||
(state) => state.copyWith(
|
||||
avatarUrl: avatarPath,
|
||||
avatarHash: avatarData.hash,
|
||||
),);
|
||||
),
|
||||
);
|
||||
|
||||
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
|
||||
enum BlockPushType {
|
||||
block,
|
||||
unblock
|
||||
}
|
||||
enum BlockPushType { block, unblock }
|
||||
|
||||
class BlocklistService {
|
||||
BlocklistService();
|
||||
@@ -18,6 +16,23 @@ class BlocklistService {
|
||||
bool? _supported;
|
||||
final Logger _log = Logger('BlocklistService');
|
||||
|
||||
Future<void> _removeBlocklistEntry(String jid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
blocklistTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBlocklistEntry(String jid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
blocklistTable,
|
||||
{
|
||||
'jid': jid,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onNewConnection() {
|
||||
// Invalidate the caches
|
||||
_blocklist = null;
|
||||
@@ -26,13 +41,17 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
Future<bool> _checkSupport() async {
|
||||
return _supported ??= await GetIt.I.get<XmppConnection>()
|
||||
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');
|
||||
assert(
|
||||
_blocklist != null,
|
||||
'The blocklist must be loaded from the database before requesting',
|
||||
);
|
||||
|
||||
// Check if blocking is supported
|
||||
if (!(await _checkSupport())) {
|
||||
@@ -40,17 +59,17 @@ class BlocklistService {
|
||||
return;
|
||||
}
|
||||
|
||||
final blocklist = await GetIt.I.get<XmppConnection>()
|
||||
final blocklist = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.getBlocklist();
|
||||
|
||||
// Diff the received blocklist with the cache
|
||||
final newItems = List<String>.empty(growable: true);
|
||||
final removedItems = List<String>.empty(growable: true);
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
for (final item in blocklist) {
|
||||
if (!_blocklist!.contains(item)) {
|
||||
await db.addBlocklistEntry(item);
|
||||
await _addBlocklistEntry(item);
|
||||
_blocklist!.add(item);
|
||||
newItems.add(item);
|
||||
}
|
||||
@@ -59,7 +78,7 @@ class BlocklistService {
|
||||
// Diff the cache with the received blocklist
|
||||
for (final item in _blocklist!) {
|
||||
if (!blocklist.contains(item)) {
|
||||
await db.removeBlocklistEntry(item);
|
||||
await _removeBlocklistEntry(item);
|
||||
_blocklist!.remove(item);
|
||||
removedItems.add(item);
|
||||
}
|
||||
@@ -81,7 +100,9 @@ class BlocklistService {
|
||||
/// Returns the blocklist from the database
|
||||
Future<List<String>> getBlocklist() async {
|
||||
if (_blocklist == null) {
|
||||
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries();
|
||||
final blocklistRaw =
|
||||
await GetIt.I.get<DatabaseService>().database.query(blocklistTable);
|
||||
_blocklist = blocklistRaw.map((m) => m['jid']! as String).toList();
|
||||
|
||||
if (!_requested) {
|
||||
unawaited(_requestBlocklist());
|
||||
@@ -112,19 +133,21 @@ class BlocklistService {
|
||||
final removedBlocks = List<String>.empty(growable: true);
|
||||
for (final item in items) {
|
||||
switch (type) {
|
||||
case BlockPushType.block: {
|
||||
case BlockPushType.block:
|
||||
{
|
||||
if (_blocklist!.contains(item)) continue;
|
||||
_blocklist!.add(item);
|
||||
newBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().addBlocklistEntry(item);
|
||||
await _addBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
case BlockPushType.unblock: {
|
||||
case BlockPushType.unblock:
|
||||
{
|
||||
_blocklist!.removeWhere((i) => i == item);
|
||||
removedBlocks.add(item);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().removeBlocklistEntry(item);
|
||||
await _removeBlocklistEntry(item);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -146,9 +169,9 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.add(jid);
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.addBlocklistEntry(jid);
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
await _addBlocklistEntry(jid);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.block([jid]);
|
||||
}
|
||||
@@ -161,9 +184,9 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.remove(jid);
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.removeBlocklistEntry(jid);
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
await _removeBlocklistEntry(jid);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.unblock([jid]);
|
||||
}
|
||||
@@ -171,14 +194,17 @@ class BlocklistService {
|
||||
Future<bool> unblockAll() async {
|
||||
// Check if blocking is supported
|
||||
if (!(await _checkSupport())) {
|
||||
_log.warning('Unblocking all JIDs requested but server does not support it.');
|
||||
_log.warning(
|
||||
'Unblocking all JIDs requested but server does not support it.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
_blocklist!.clear();
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.removeAllBlocklistEntries();
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
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:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/reconnect.dart';
|
||||
|
||||
class ConnectivityEvent {
|
||||
const ConnectivityEvent(this.regained, this.lost);
|
||||
final bool regained;
|
||||
final bool lost;
|
||||
}
|
||||
|
||||
class ConnectivityService {
|
||||
ConnectivityService() : _log = Logger('ConnectivityService');
|
||||
final Logger _log;
|
||||
/// The internal stream controller
|
||||
final StreamController<ConnectivityEvent> _controller =
|
||||
StreamController<ConnectivityEvent>.broadcast();
|
||||
|
||||
/// The logger
|
||||
final Logger _log = Logger('ConnectivityService');
|
||||
|
||||
/// Caches the current connectivity state
|
||||
late ConnectivityResult _connectivity;
|
||||
|
||||
Stream<ConnectivityEvent> get stream => _controller.stream;
|
||||
|
||||
@visibleForTesting
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -24,23 +34,24 @@ class ConnectivityService {
|
||||
final conn = Connectivity();
|
||||
_connectivity = await conn.checkConnectivity();
|
||||
|
||||
// TODO(Unknown): At least on Android, the stream fires directly after listening although the
|
||||
// network does not change. So just skip it.
|
||||
// See https://github.com/fluttercommunity/plus_plugins/issues/567
|
||||
final skipAmount = Platform.isAndroid ? 1 : 0;
|
||||
conn.onConnectivityChanged.skip(skipAmount).listen((ConnectivityResult result) {
|
||||
final regained = _connectivity == ConnectivityResult.none && result != ConnectivityResult.none;
|
||||
conn.onConnectivityChanged.listen((ConnectivityResult result) {
|
||||
final regained = _connectivity == ConnectivityResult.none &&
|
||||
result != ConnectivityResult.none;
|
||||
final lost = result == ConnectivityResult.none;
|
||||
_connectivity = result;
|
||||
|
||||
// TODO(PapaTutuWawa): Should we use Streams?
|
||||
// Notify other services
|
||||
(GetIt.I.get<XmppConnection>().reconnectionPolicy as MoxxyReconnectionPolicy)
|
||||
.onConnectivityChanged(regained, lost);
|
||||
|
||||
GetIt.I.get<HttpFileTransferService>().onConnectivityChanged(regained);
|
||||
_controller.add(
|
||||
ConnectivityEvent(
|
||||
regained,
|
||||
lost,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
ConnectivityResult get currentState => _connectivity;
|
||||
|
||||
Future<bool> hasConnection() async {
|
||||
return _connectivity != ConnectivityResult.none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +1,75 @@
|
||||
import 'dart:async';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class ConnectivityWatcherService {
|
||||
/// Logger.
|
||||
final Logger _log = Logger('ConnectivityWatcherService');
|
||||
|
||||
ConnectivityWatcherService() : _log = Logger('ConnectivityWatcherService');
|
||||
final Logger _log;
|
||||
|
||||
// Timer counting how much time has passed since we were last connected
|
||||
/// Timer counting how much time has passed since we were last connected.
|
||||
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 {
|
||||
await _stopTimer();
|
||||
await GetIt.I.get<NotificationsService>().showWarningNotification(
|
||||
'Moxxy',
|
||||
t.errors.connection.connectionTimeout,
|
||||
);
|
||||
_stopTimer();
|
||||
}
|
||||
|
||||
/// Stops the currently running timer, if there is one.
|
||||
void _stopTimer() {
|
||||
if (_timer != null) {
|
||||
_timer!.cancel();
|
||||
Future<void> _stopTimer() async {
|
||||
await _lock.synchronized(() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts the timer. If it is already running, it stops the currently running one before
|
||||
/// starting the new one.
|
||||
void _startTimer() {
|
||||
_stopTimer();
|
||||
Future<void> _startTimer() async {
|
||||
await _stopTimer();
|
||||
_timer = Timer(const Duration(minutes: 30), _onTimerElapsed);
|
||||
}
|
||||
|
||||
/// Called when the XMPP connection state changed
|
||||
Future<void> onConnectionStateChanged(XmppConnectionState before, XmppConnectionState current) async {
|
||||
if (before == XmppConnectionState.connected && current != XmppConnectionState.connected) {
|
||||
Future<void> onConnectionStateChanged(
|
||||
XmppConnectionState before,
|
||||
XmppConnectionState current,
|
||||
) async {
|
||||
if (before == XmppConnectionState.connected &&
|
||||
current != XmppConnectionState.connected) {
|
||||
// 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...');
|
||||
_startTimer();
|
||||
await _startTimer();
|
||||
} else {
|
||||
_log.finest('Lost connection to server but no network connectivity available. Stopping warning timer...');
|
||||
_stopTimer();
|
||||
_log.finest(
|
||||
'Lost connection to server but no network connectivity available. Stopping warning timer...',
|
||||
);
|
||||
await _stopTimer();
|
||||
}
|
||||
} else if (current == XmppConnectionState.connected) {
|
||||
_stopTimer();
|
||||
await _stopTimer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
@@ -28,28 +29,37 @@ class ContactsService {
|
||||
// are not returned.
|
||||
FlutterContacts.config.includeNonVisibleOnAndroid = true;
|
||||
}
|
||||
|
||||
/// Logger.
|
||||
final Logger _log = Logger('ContactsService');
|
||||
|
||||
/// JID -> Id
|
||||
/// JID -> Id.
|
||||
Map<String, String>? _contactIds;
|
||||
|
||||
/// Contact ID -> Display name from the contact or null if we cached that there is
|
||||
/// none
|
||||
final Map<String, String?> _contactDisplayNames = {};
|
||||
|
||||
Future<void> init() async {
|
||||
if (await _canUseContactIntegration()) {
|
||||
enableDatabaseListener();
|
||||
}
|
||||
Future<void> initialize() async {
|
||||
await enable(shouldScan: false);
|
||||
}
|
||||
|
||||
/// Enable listening to contact database events
|
||||
void enableDatabaseListener() {
|
||||
/// Enable listening to contact database events. If [shouldScan] is true, also
|
||||
/// performs a scan of the contacts database, if we're allowed.
|
||||
Future<void> enable({bool shouldScan = true}) async {
|
||||
FlutterContacts.addListener(_onContactsDatabaseUpdate);
|
||||
|
||||
if (shouldScan && await _canUseContactIntegration()) {
|
||||
unawaited(scanContacts());
|
||||
}
|
||||
}
|
||||
|
||||
/// Disable listening to contact database events
|
||||
void disableDatabaseListener() {
|
||||
/// Disable listening to contact database events. Also removes all roster items
|
||||
/// that are pseudo roster items.
|
||||
Future<void> disable() async {
|
||||
FlutterContacts.removeListener(_onContactsDatabaseUpdate);
|
||||
|
||||
await GetIt.I.get<RosterService>().removePseudoRosterItems();
|
||||
}
|
||||
|
||||
Future<void> _onContactsDatabaseUpdate() async {
|
||||
@@ -67,8 +77,8 @@ class ContactsService {
|
||||
|
||||
final jabberContacts = List<ContactWrapper>.empty(growable: true);
|
||||
for (final c in contacts) {
|
||||
final index = c.socialMedias
|
||||
.indexWhere((s) => s.label == SocialMediaLabel.jabber);
|
||||
final index =
|
||||
c.socialMedias.indexWhere((s) => s.label == SocialMediaLabel.jabber);
|
||||
if (index == -1) continue;
|
||||
|
||||
jabberContacts.add(
|
||||
@@ -97,13 +107,17 @@ class ContactsService {
|
||||
/// 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');
|
||||
_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");
|
||||
_log.finest(
|
||||
"_canUseContactIntegration: Returning false since we don't have the contacts permission",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -115,7 +129,14 @@ class ContactsService {
|
||||
Future<Map<String, String>> _getContactIds() async {
|
||||
if (_contactIds != null) return _contactIds!;
|
||||
|
||||
_contactIds = await GetIt.I.get<DatabaseService>().getContactIds();
|
||||
_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!;
|
||||
}
|
||||
|
||||
@@ -125,8 +146,7 @@ class ContactsService {
|
||||
/// [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 (id == null || !(await _canUseContactIntegration())) return null;
|
||||
if (_contactDisplayNames.containsKey(id)) return _contactDisplayNames[id];
|
||||
|
||||
final result = await FlutterContacts.getContact(
|
||||
@@ -155,21 +175,18 @@ class ContactsService {
|
||||
if (id == null) return null;
|
||||
|
||||
final avatarPath = await getContactProfilePicturePath(id);
|
||||
return File(avatarPath).existsSync() ?
|
||||
avatarPath :
|
||||
null;
|
||||
return File(avatarPath).existsSync() ? avatarPath : null;
|
||||
}
|
||||
|
||||
Future<void> scanContacts() async {
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final contacts = await _fetchContactsWithJabber();
|
||||
// JID -> Id
|
||||
final knownContactIds = await _getContactIds();
|
||||
// Id -> JID
|
||||
final knownContactIdsReverse = knownContactIds
|
||||
.map((key, value) => MapEntry(value, key));
|
||||
final 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);
|
||||
@@ -179,7 +196,12 @@ class ContactsService {
|
||||
if (index != -1) continue;
|
||||
|
||||
final jid = knownContactIdsReverse[id]!;
|
||||
await db.removeContactId(id);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
contactsTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
_contactIds!.remove(knownContactIdsReverse[id]);
|
||||
|
||||
// Remove the avatar file, if it existed
|
||||
@@ -190,17 +212,21 @@ class ContactsService {
|
||||
}
|
||||
|
||||
// Remove the contact attributes from the conversation, if it existed
|
||||
final c = await cs.getConversationByJid(jid);
|
||||
if (c != null) {
|
||||
final newConv = await cs.updateConversation(
|
||||
c.id,
|
||||
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: newConv,
|
||||
conversation: conversation,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -227,7 +253,13 @@ class ContactsService {
|
||||
for (final contact in contacts) {
|
||||
// Add the ID to the cache and the database if it does not already exist
|
||||
if (!knownContactIds.containsKey(contact.jid)) {
|
||||
await db.addContactId(contact.id, contact.jid);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
contactsTable,
|
||||
<String, String>{
|
||||
'id': contact.id,
|
||||
'jid': contact.jid,
|
||||
},
|
||||
);
|
||||
_contactIds![contact.jid] = contact.id;
|
||||
}
|
||||
|
||||
@@ -243,17 +275,22 @@ class ContactsService {
|
||||
}
|
||||
|
||||
// Update a possibly existing conversation
|
||||
final c = await cs.getConversationByJid(contact.jid);
|
||||
if (c != null) {
|
||||
final newConv = await cs.updateConversation(
|
||||
c.id,
|
||||
final conversation = await cs.createOrUpdateConversation(
|
||||
contact.jid,
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
contact.jid,
|
||||
contactId: contact.id,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactAvatarPath:
|
||||
contact.thumbnail != null ? contactAvatarPath : null,
|
||||
contactDisplayName: contact.displayName,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (conversation != null) {
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConv,
|
||||
conversation: conversation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,67 +1,142 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.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/helpers.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/not_specified.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/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 {
|
||||
ConversationService()
|
||||
: _conversationCache = LRUCache(100),
|
||||
_loadedConversations = false;
|
||||
/// The list of known conversations.
|
||||
Map<String, Conversation>? _conversationCache;
|
||||
|
||||
final LRUCache<int, Conversation> _conversationCache;
|
||||
bool _loadedConversations;
|
||||
/// The lock for accessing _conversationCache
|
||||
final Lock _lock = Lock();
|
||||
|
||||
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
||||
/// to the cache.
|
||||
Future<void> _loadConversations() async {
|
||||
final conversations = await GetIt.I.get<DatabaseService>().loadConversations();
|
||||
for (final c in conversations) {
|
||||
_conversationCache.cache(c.id, c);
|
||||
/// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the conversation with jid [jid] or null if not found.
|
||||
Future<Conversation?> getConversationByJid(String jid) async {
|
||||
if (!_loadedConversations) {
|
||||
await _loadConversations();
|
||||
_loadedConversations = true;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
return firstWhereOrNull(
|
||||
// TODO(Unknown): Maybe have it accept an iterable
|
||||
_conversationCache.getValues(),
|
||||
(Conversation c) => c.jid == jid,
|
||||
/// 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,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the conversation by its database id or null if it does not exist.
|
||||
Future<Conversation?> _getConversationById(int id) async {
|
||||
if (!_loadedConversations) {
|
||||
await _loadConversations();
|
||||
_loadedConversations = true;
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _conversationCache.getValue(id);
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
||||
/// to the cache.
|
||||
Future<void> _loadConversationsIfNeeded() async {
|
||||
if (_conversationCache != null) return;
|
||||
|
||||
final conversations = await loadConversations();
|
||||
_conversationCache = Map<String, Conversation>.fromEntries(
|
||||
conversations.map((c) => MapEntry(c.jid, c)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the conversation with jid [jid] or null if not found.
|
||||
Future<Conversation?> _getConversationByJid(String jid) async {
|
||||
await _loadConversationsIfNeeded();
|
||||
return _conversationCache![jid];
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// changing the chat state.
|
||||
void setConversation(Conversation conversation) {
|
||||
_conversationCache.cache(conversation.id, conversation);
|
||||
_conversationCache![conversation.jid] = conversation;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s [updateConversation] that modifies the cache.
|
||||
Future<Conversation> updateConversation(int id, {
|
||||
/// Updates the conversation with JID [jid] inside the database.
|
||||
///
|
||||
/// To prevent issues with the cache, only call from within
|
||||
/// [ConversationService.createOrUpdateConversation].
|
||||
Future<Conversation> updateConversation(
|
||||
String jid, {
|
||||
int? lastChangeTimestamp,
|
||||
Message? lastMessage,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
String? avatarPath,
|
||||
Object? avatarHash = notSpecified,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
@@ -69,36 +144,80 @@ class ConversationService {
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
}) async {
|
||||
final conversation = (await _getConversationById(id))!;
|
||||
var newConversation = await GetIt.I.get<DatabaseService>().updateConversation(
|
||||
id,
|
||||
lastMessage: lastMessage,
|
||||
lastChangeTimestamp: lastChangeTimestamp,
|
||||
open: open,
|
||||
unreadCounter: unreadCounter,
|
||||
avatarUrl: avatarUrl,
|
||||
chatState: conversation.chatState,
|
||||
muted: muted,
|
||||
encrypted: encrypted,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
final conversation = (await _getConversationByJid(jid))!;
|
||||
|
||||
final c = <String, dynamic>{};
|
||||
|
||||
if (lastMessage != null) {
|
||||
c['lastMessageId'] = lastMessage.id;
|
||||
}
|
||||
if (lastChangeTimestamp != null) {
|
||||
c['lastChangeTimestamp'] = lastChangeTimestamp;
|
||||
}
|
||||
if (open != null) {
|
||||
c['open'] = boolToInt(open);
|
||||
}
|
||||
if (unreadCounter != null) {
|
||||
c['unreadCounter'] = unreadCounter;
|
||||
}
|
||||
if (avatarPath != null) {
|
||||
c['avatarPath'] = avatarPath;
|
||||
}
|
||||
if (avatarHash != notSpecified) {
|
||||
c['avatarHash'] = avatarHash as String?;
|
||||
}
|
||||
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],
|
||||
);
|
||||
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
var newConversation = Conversation.fromDatabaseJson(
|
||||
result,
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
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);
|
||||
newConversation =
|
||||
newConversation.copyWith(lastMessage: conversation.lastMessage);
|
||||
}
|
||||
|
||||
_conversationCache.cache(id, newConversation);
|
||||
_conversationCache![jid] = 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(
|
||||
String title,
|
||||
Message? lastMessage,
|
||||
String avatarUrl,
|
||||
ConversationType type,
|
||||
String avatarPath,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
int lastChangeTimestamp,
|
||||
@@ -109,22 +228,35 @@ class ConversationService {
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
) async {
|
||||
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
final newConversation = Conversation(
|
||||
title,
|
||||
lastMessage,
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
null,
|
||||
jid,
|
||||
unreadCounter,
|
||||
type,
|
||||
lastChangeTimestamp,
|
||||
open,
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
muted,
|
||||
encrypted,
|
||||
contactId,
|
||||
contactAvatarPath,
|
||||
contactDisplayName,
|
||||
ChatState.gone,
|
||||
contactId: contactId,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
conversationsTable,
|
||||
newConversation.toDatabaseJson(),
|
||||
);
|
||||
|
||||
_conversationCache.cache(newConversation.id, newConversation);
|
||||
if (_conversationCache != null) {
|
||||
_conversationCache![newConversation.jid] = newConversation;
|
||||
}
|
||||
|
||||
return newConversation;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,24 +20,30 @@ List<int> _randomBuffer(int length) {
|
||||
|
||||
CipherAlgorithm _sfsToCipher(SFSEncryptionType type) {
|
||||
switch (type) {
|
||||
case SFSEncryptionType.aes128GcmNoPadding: return CipherAlgorithm.aes128GcmNoPadding;
|
||||
case SFSEncryptionType.aes256GcmNoPadding: return CipherAlgorithm.aes256GcmNoPadding;
|
||||
case SFSEncryptionType.aes256CbcPkcs7: return CipherAlgorithm.aes256CbcPkcs7;
|
||||
case SFSEncryptionType.aes128GcmNoPadding:
|
||||
return CipherAlgorithm.aes128GcmNoPadding;
|
||||
case SFSEncryptionType.aes256GcmNoPadding:
|
||||
return CipherAlgorithm.aes256GcmNoPadding;
|
||||
case SFSEncryptionType.aes256CbcPkcs7:
|
||||
return CipherAlgorithm.aes256CbcPkcs7;
|
||||
}
|
||||
}
|
||||
|
||||
class CryptographyService {
|
||||
|
||||
CryptographyService() : _log = Logger('CryptographyService');
|
||||
final Logger _log;
|
||||
|
||||
/// Encrypt the file at path [source] and write the encrypted data to [dest]. For the
|
||||
/// 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');
|
||||
final key = encryption == SFSEncryptionType.aes128GcmNoPadding ?
|
||||
_randomBuffer(16) :
|
||||
_randomBuffer(32);
|
||||
final key = encryption == SFSEncryptionType.aes128GcmNoPadding
|
||||
? _randomBuffer(16)
|
||||
: _randomBuffer(32);
|
||||
final iv = _randomBuffer(12);
|
||||
final result = (await MoxplatformPlugin.crypto.encryptFile(
|
||||
source,
|
||||
@@ -52,11 +58,11 @@ class CryptographyService {
|
||||
return EncryptionResult(
|
||||
key,
|
||||
iv,
|
||||
<String, String>{
|
||||
hashSha256: base64Encode(result.plaintextHash),
|
||||
<HashFunction, String>{
|
||||
HashFunction.sha256: base64Encode(result.plaintextHash),
|
||||
},
|
||||
<String, String>{
|
||||
hashSha256: base64Encode(result.ciphertextHash),
|
||||
<HashFunction, String>{
|
||||
HashFunction.sha256: base64Encode(result.ciphertextHash),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -70,8 +76,8 @@ class CryptographyService {
|
||||
SFSEncryptionType encryption,
|
||||
List<int> key,
|
||||
List<int> iv,
|
||||
Map<String, String> plaintextHashes,
|
||||
Map<String, String> ciphertextHashes,
|
||||
Map<HashFunction, String> plaintextHashes,
|
||||
Map<HashFunction, String> ciphertextHashes,
|
||||
) async {
|
||||
_log.finest('Beginning decryption for $source');
|
||||
final result = await MoxplatformPlugin.crypto.encryptFile(
|
||||
@@ -88,7 +94,7 @@ class CryptographyService {
|
||||
var passedPlaintextIntegrityCheck = true;
|
||||
var passedCiphertextIntegrityCheck = true;
|
||||
for (final entry in plaintextHashes.entries) {
|
||||
if (entry.key == hashSha256) {
|
||||
if (entry.key == HashFunction.sha256) {
|
||||
if (base64Encode(result!.plaintextHash) != entry.value) {
|
||||
passedPlaintextIntegrityCheck = false;
|
||||
} else {
|
||||
@@ -99,7 +105,7 @@ class CryptographyService {
|
||||
}
|
||||
}
|
||||
for (final entry in ciphertextHashes.entries) {
|
||||
if (entry.key == hashSha256) {
|
||||
if (entry.key == HashFunction.sha256) {
|
||||
if (base64Encode(result!.ciphertextHash) != entry.value) {
|
||||
passedCiphertextIntegrityCheck = false;
|
||||
} 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
|
||||
class EncryptionResult {
|
||||
|
||||
const EncryptionResult(this.key, this.iv, this.plaintextHashes, this.ciphertextHashes);
|
||||
const EncryptionResult(
|
||||
this.key,
|
||||
this.iv,
|
||||
this.plaintextHashes,
|
||||
this.ciphertextHashes,
|
||||
);
|
||||
final List<int> key;
|
||||
final List<int> iv;
|
||||
|
||||
final Map<String, String> plaintextHashes;
|
||||
final Map<String, String> ciphertextHashes;
|
||||
final Map<HashFunction, String> plaintextHashes;
|
||||
final Map<HashFunction, String> ciphertextHashes;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class EncryptionRequest {
|
||||
|
||||
const EncryptionRequest(this.source, this.dest, this.encryption);
|
||||
final String source;
|
||||
final String dest;
|
||||
@@ -23,7 +26,6 @@ class EncryptionRequest {
|
||||
|
||||
@immutable
|
||||
class DecryptionResult {
|
||||
|
||||
const DecryptionResult(
|
||||
this.decryptionOkay,
|
||||
this.plaintextOkay,
|
||||
@@ -36,7 +38,6 @@ class DecryptionResult {
|
||||
|
||||
@immutable
|
||||
class DecryptionRequest {
|
||||
|
||||
const DecryptionRequest(
|
||||
this.source,
|
||||
this.dest,
|
||||
@@ -51,14 +52,6 @@ class DecryptionRequest {
|
||||
final SFSEncryptionType encryption;
|
||||
final List<int> key;
|
||||
final List<int> iv;
|
||||
final Map<String, String> plaintextHashes;
|
||||
final Map<String, String> ciphertextHashes;
|
||||
}
|
||||
|
||||
@immutable
|
||||
class HashRequest {
|
||||
|
||||
const HashRequest(this.path, this.hash);
|
||||
final String path;
|
||||
final HashFunction hash;
|
||||
final Map<HashFunction, String> plaintextHashes;
|
||||
final Map<HashFunction, String> ciphertextHashes;
|
||||
}
|
||||
|
||||
@@ -3,18 +3,19 @@ const messagesTable = 'Messages';
|
||||
const rosterTable = 'RosterItems';
|
||||
const mediaTable = 'SharedMedia';
|
||||
const preferenceTable = 'Preferences';
|
||||
const omemoDeviceTable = 'OmemoDevices';
|
||||
const omemoDeviceListTable = 'OmemoDeviceList';
|
||||
const omemoRatchetsTable = 'OmemoSessions';
|
||||
const omemoTrustCacheTable = 'OmemoTrustCacheList';
|
||||
const omemoTrustDeviceListTable = 'OmemoTrustDeviceList';
|
||||
const omemoTrustEnableListTable = 'OmemoTrustEnableList';
|
||||
const omemoFingerprintCache = 'OmemoFingerprintCache';
|
||||
const xmppStateTable = 'XmppState';
|
||||
const contactsTable = 'Contacts';
|
||||
const stickersTable = 'Stickers';
|
||||
const stickerPacksTable = 'StickerPacks';
|
||||
const blocklistTable = 'Blocklist';
|
||||
const subscriptionsTable = 'SubscriptionRequests';
|
||||
const fileMetadataTable = 'FileMetadata';
|
||||
const fileMetadataHashesTable = 'FileMetadataHashes';
|
||||
const reactionsTable = 'Reactions';
|
||||
const omemoDevicesTable = 'OmemoDevices';
|
||||
const omemoDeviceListTable = 'OmemoDeviceList';
|
||||
const omemoRatchetsTable = 'OmemoRatchets';
|
||||
const omemoTrustTable = 'OmemoTrustTable';
|
||||
|
||||
const typeString = 0;
|
||||
const typeInt = 1;
|
||||
|
||||
@@ -27,51 +27,93 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
timestamp INTEGER NOT NULL,
|
||||
sid TEXT NOT NULL,
|
||||
conversationJid TEXT NOT NULL,
|
||||
isMedia INTEGER NOT NULL,
|
||||
isFileUploadNotification INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
errorType INTEGER,
|
||||
warningType INTEGER,
|
||||
mediaUrl TEXT,
|
||||
mediaType TEXT,
|
||||
thumbnailData TEXT,
|
||||
mediaWidth INTEGER,
|
||||
mediaHeight INTEGER,
|
||||
srcUrl TEXT,
|
||||
key TEXT,
|
||||
iv TEXT,
|
||||
encryptionScheme TEXT,
|
||||
received INTEGER,
|
||||
displayed INTEGER,
|
||||
acked INTEGER,
|
||||
originId TEXT,
|
||||
quote_id INTEGER,
|
||||
filename TEXT,
|
||||
plaintextHashes TEXT,
|
||||
ciphertextHashes TEXT,
|
||||
file_metadata_id TEXT,
|
||||
isDownloading INTEGER NOT NULL,
|
||||
isUploading INTEGER NOT NULL,
|
||||
mediaSize INTEGER,
|
||||
isRetracted INTEGER,
|
||||
isEdited INTEGER NOT NULL,
|
||||
reactions TEXT NOT NULL,
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
stickerPackId TEXT,
|
||||
stickerHashKey TEXT,
|
||||
pseudoMessageType INTEGER,
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||
CONSTRAINT fk_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
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $conversationsTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jid TEXT NOT NULL,
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
avatarUrl TEXT NOT NULL,
|
||||
avatarPath TEXT NOT NULL,
|
||||
avatarHash TEXT,
|
||||
type TEXT NOT NULL,
|
||||
lastChangeTimestamp INTEGER NOT NULL,
|
||||
unreadCounter INTEGER NOT NULL,
|
||||
open INTEGER NOT NULL,
|
||||
@@ -86,6 +128,9 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
|
||||
);
|
||||
|
||||
// Contacts
|
||||
await db.execute(
|
||||
@@ -93,21 +138,6 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
CREATE TABLE $contactsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL
|
||||
)'''
|
||||
);
|
||||
|
||||
// Shared media
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $mediaTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
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 $messagesTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -118,7 +148,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
avatarUrl TEXT NOT NULL,
|
||||
avatarPath TEXT NOT NULL,
|
||||
avatarHash TEXT NOT NULL,
|
||||
subscription TEXT NOT NULL,
|
||||
ask TEXT NOT NULL,
|
||||
@@ -135,19 +165,14 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $stickersTable (
|
||||
hashKey TEXT PRIMARY KEY,
|
||||
mediaType TEXT NOT NULL,
|
||||
id TEXT PRIMARY KEY,
|
||||
desc TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
hashes TEXT NOT NULL,
|
||||
urlSources TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
stickerPackId TEXT NOT NULL,
|
||||
suggests TEXT NOT NULL,
|
||||
file_metadata_id TEXT NOT NULL,
|
||||
stickerPackId TEXT NOT NULL,
|
||||
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||
ON DELETE CASCADE
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
@@ -158,7 +183,8 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
description TEXT NOT NULL,
|
||||
hashAlgorithm TEXT NOT NULL,
|
||||
hashValue TEXT NOT NULL,
|
||||
restricted INTEGER NOT NULL
|
||||
restricted INTEGER NOT NULL,
|
||||
addedTimestamp INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -174,72 +200,58 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
// OMEMO
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoRatchetsTable (
|
||||
CREATE TABLE $omemoDevicesTable (
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
id INTEGER NOT NULL,
|
||||
ikPub TEXT NOT NULL,
|
||||
ik TEXT NOT NULL,
|
||||
spkPub TEXT NOT NULL,
|
||||
spk TEXT NOT NULL,
|
||||
spkId INTEGER NOT NULL,
|
||||
spkSig TEXT NOT NULL,
|
||||
oldSpkPub TEXT,
|
||||
oldSpk TEXT,
|
||||
oldSpkId INTEGER,
|
||||
opks TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceListTable (
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
devices TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoRatchetsTable (
|
||||
jid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL,
|
||||
dhsPub TEXT NOT NULL,
|
||||
dhs TEXT NOT NULL,
|
||||
dhs_pub TEXT NOT NULL,
|
||||
dhr TEXT,
|
||||
dhrPub TEXT,
|
||||
rk TEXT NOT NULL,
|
||||
cks TEXT,
|
||||
ckr TEXT,
|
||||
ns INTEGER NOT NULL,
|
||||
nr INTEGER NOT NULL,
|
||||
pn INTEGER NOT NULL,
|
||||
ik_pub TEXT NOT NULL,
|
||||
session_ad TEXT NOT NULL,
|
||||
acknowledged INTEGER NOT NULL,
|
||||
mkskipped TEXT NOT NULL,
|
||||
kex_timestamp INTEGER NOT NULL,
|
||||
kex TEXT,
|
||||
PRIMARY KEY (jid, id)
|
||||
ik TEXT NOT NULL,
|
||||
ad TEXT NOT NULL,
|
||||
skipped TEXT NOT NULL,
|
||||
kex TEXT NOT NULL,
|
||||
acked INTEGER NOT NULL,
|
||||
PRIMARY KEY (jid, device)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustCacheTable (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
trust INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustDeviceListTable (
|
||||
CREATE TABLE $omemoTrustTable (
|
||||
jid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustEnableListTable (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
enabled INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceTable (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceListTable (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoFingerprintCache (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
device INTEGER NOT NULL,
|
||||
trust INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
PRIMARY KEY (jid, device)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -316,14 +328,6 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
'true',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'autoAcceptSubscriptionRequests',
|
||||
typeBool,
|
||||
'false',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
|
||||
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
|
||||
int boolToInt(bool b) => b ? 1 : 0;
|
||||
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';
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV22ToV23(Database db) async {
|
||||
|
||||
@@ -4,13 +4,11 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV13ToV14(Database db) async {
|
||||
// Create the new table
|
||||
await db.execute(
|
||||
'''
|
||||
await db.execute('''
|
||||
CREATE TABLE $contactsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL
|
||||
)'''
|
||||
);
|
||||
)''');
|
||||
|
||||
// Migrate the conversations
|
||||
await db.execute(
|
||||
@@ -32,9 +30,13 @@ Future<void> upgradeFromV13ToV14(Database db) async {
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute('INSERT INTO ${conversationsTable}_new SELECT *, NULL from $conversationsTable');
|
||||
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;');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;',
|
||||
);
|
||||
|
||||
// Migrate the roster items
|
||||
await db.execute(
|
||||
@@ -52,7 +54,9 @@ Future<void> upgradeFromV13ToV14(Database db) async {
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute('INSERT INTO ${rosterTable}_new SELECT *, NULL from $rosterTable');
|
||||
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;');
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV6ToV7(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable ADD COLUMN lastMessageState INTEGER NOT NULL DEFAULT 0;'
|
||||
'ALTER TABLE $conversationsTable ADD COLUMN lastMessageState INTEGER NOT NULL DEFAULT 0;',
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';"
|
||||
"ALTER TABLE $conversationsTable ADD COLUMN lastMessageSender TEXT NOT NULL DEFAULT '';",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV7ToV8(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageState;'
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageState;',
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageSender;"
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageSender;',
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageBody;"
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageBody;',
|
||||
);
|
||||
await db.execute(
|
||||
"ALTER TABLE $conversationsTable DROP COLUMN lastMessageRetracted;"
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN lastMessageRetracted;',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,13 +25,17 @@ Future<void> upgradeFromV8ToV9(Database db) async {
|
||||
);
|
||||
|
||||
// Step 5
|
||||
await db.execute('INSERT INTO ${conversationsTable}_new SELECT * from $conversationsTable');
|
||||
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;');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable;',
|
||||
);
|
||||
|
||||
// Step 10
|
||||
//await db.execute('PRAGMA foreign_key_check;');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV9ToV10(Database db) async {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV12ToV13(Database db) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoFingerprintCache (
|
||||
CREATE TABLE OmemoFingerprintCache (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV23ToV24(Database db) async {
|
||||
|
||||
@@ -3,6 +3,6 @@ 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 '[]';"
|
||||
"ALTER TABLE $messagesTable ADD COLUMN reactions TEXT NOT NULL DEFAULT '[]';",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@ 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)};'
|
||||
'ALTER TABLE $messagesTable ADD COLUMN containsNoStore INTEGER NOT NULL DEFAULT ${boolToInt(false)};',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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> upgradeFromV3ToV4(Database db) async {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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> upgradeFromV4ToV5(Database db) async {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV5ToV6(Database db) async {
|
||||
|
||||
@@ -11,12 +11,8 @@ Future<void> upgradeFromV17ToV18(Database db) async {
|
||||
);
|
||||
|
||||
// Drop stickers
|
||||
await db.execute(
|
||||
'DROP TABLE $stickerPacksTable;'
|
||||
);
|
||||
await db.execute(
|
||||
'DROP TABLE $stickersTable;'
|
||||
);
|
||||
await db.execute('DROP TABLE $stickerPacksTable;');
|
||||
await db.execute('DROP TABLE $stickersTable;');
|
||||
|
||||
await db.execute(
|
||||
'''
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV21ToV22(Database db) async {
|
||||
|
||||
@@ -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";',
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
13
lib/service/database/migrations/0003_avatar_hashes.dart
Normal file
13
lib/service/database/migrations/0003_avatar_hashes.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV37ToV38(Database db) async {
|
||||
await db
|
||||
.execute('ALTER TABLE $conversationsTable ADD COLUMN avatarHash TEXT');
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable RENAME COLUMN avatarUrl TO avatarPath',
|
||||
);
|
||||
await db.execute(
|
||||
'ALTER TABLE $rosterTable RENAME COLUMN avatarUrl TO avatarPath',
|
||||
);
|
||||
}
|
||||
72
lib/service/database/migrations/0003_new_omemo.dart
Normal file
72
lib/service/database/migrations/0003_new_omemo.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV39ToV40(Database db) async {
|
||||
// Remove the old tables
|
||||
await db.execute('DROP TABLE OmemoDevices');
|
||||
await db.execute('DROP TABLE OmemoDeviceList');
|
||||
await db.execute('DROP TABLE OmemoTrustCacheList');
|
||||
await db.execute('DROP TABLE OmemoTrustDeviceList');
|
||||
await db.execute('DROP TABLE OmemoTrustEnableList');
|
||||
await db.execute('DROP TABLE OmemoFingerprintCache');
|
||||
|
||||
// Create the new tables
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDevicesTable (
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
id INTEGER NOT NULL,
|
||||
ikPub TEXT NOT NULL,
|
||||
ik TEXT NOT NULL,
|
||||
spkPub TEXT NOT NULL,
|
||||
spk TEXT NOT NULL,
|
||||
spkId INTEGER NOT NULL,
|
||||
spkSig TEXT NOT NULL,
|
||||
oldSpkPub TEXT,
|
||||
oldSpk TEXT,
|
||||
oldSpkId INTEGER,
|
||||
opks TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceListTable (
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
devices TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoRatchetsTable (
|
||||
jid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL,
|
||||
dhsPub TEXT NOT NULL,
|
||||
dhs TEXT NOT NULL,
|
||||
dhrPub TEXT,
|
||||
rk TEXT NOT NULL,
|
||||
cks TEXT,
|
||||
ckr TEXT,
|
||||
ns INTEGER NOT NULL,
|
||||
nr INTEGER NOT NULL,
|
||||
pn INTEGER NOT NULL,
|
||||
ik TEXT NOT NULL,
|
||||
ad TEXT NOT NULL,
|
||||
skipped TEXT NOT NULL,
|
||||
kex TEXT NOT NULL,
|
||||
acked INTEGER NOT NULL,
|
||||
PRIMARY KEY (jid, device)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustTable (
|
||||
jid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL,
|
||||
trust INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
PRIMARY KEY (jid, device)
|
||||
)''',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV40ToV41(Database db) async {
|
||||
final messages = await db.query(
|
||||
messagesTable,
|
||||
where: 'pseudoMessageType IS NOT NULL',
|
||||
);
|
||||
|
||||
for (final message in messages) {
|
||||
await db.insert(
|
||||
messagesTable,
|
||||
{
|
||||
...message,
|
||||
'pseudoMessageData': jsonEncode({
|
||||
'ratchetsAdded': 1,
|
||||
'ratchetsReplaced': 0,
|
||||
}),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV38ToV39(Database db) async {
|
||||
await db.execute('DROP TABLE $subscriptionsTable');
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV41ToV42(Database db) async {
|
||||
/// Add the new column
|
||||
await db.execute(
|
||||
'''
|
||||
ALTER TABLE $stickerPacksTable ADD COLUMN addedTimestamp INTEGER NOT NULL DEFAULT 0;
|
||||
''',
|
||||
);
|
||||
|
||||
/// Ensure that the sticker packs are sorted (albeit randomly)
|
||||
final stickerPackIds = await db.query(
|
||||
stickerPacksTable,
|
||||
columns: ['id'],
|
||||
);
|
||||
|
||||
var counter = 0;
|
||||
for (final id in stickerPackIds) {
|
||||
await db.update(
|
||||
stickerPacksTable,
|
||||
{
|
||||
'addedTimestamp': counter,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
counter++;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
346
lib/service/files.dart
Normal file
346
lib/service/files.dart
Normal file
@@ -0,0 +1,346 @@
|
||||
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';
|
||||
import 'package:sqflite_common/sql.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
|
||||
// NOTE: [ext] already includes a leading "."
|
||||
? '$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,
|
||||
},
|
||||
// TODO(Unknown): I would like to get rid of this. In events.dart, when processing
|
||||
// a request to manually download a file, we should check if we already
|
||||
// have hash pointers for a file metadata item.
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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:moxxmpp/moxxmpp.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;
|
||||
|
||||
Future<String?> _generateBlurhashThumbnailImpl(String path) async {
|
||||
@@ -65,36 +67,89 @@ Future<String?> generateBlurhashThumbnail(String path) async {
|
||||
String xmppErrorToTranslatableString(XmppError error) {
|
||||
if (error is StartTLSFailedError) {
|
||||
return t.errors.login.startTlsFailed;
|
||||
} else if (error is SaslFailedError) {
|
||||
} else if (error is SaslError) {
|
||||
return t.errors.login.saslFailed;
|
||||
} else if (error is NoConnectionError) {
|
||||
} else if (error is NoConnectionPossibleError) {
|
||||
return t.errors.login.noConnection;
|
||||
}
|
||||
|
||||
return t.errors.login.unspecified;
|
||||
}
|
||||
|
||||
String getStickerHashKeyType(Map<String, String> hashes) {
|
||||
if (hashes.containsKey('blake2b-512')) {
|
||||
return 'blake2b-512';
|
||||
} else if (hashes.containsKey('blake2b-512')) {
|
||||
return 'blake2b-256';
|
||||
} else if (hashes.containsKey('sha3-512')) {
|
||||
return 'sha3-512';
|
||||
} else if (hashes.containsKey('sha3-256')) {
|
||||
return 'sha3-256';
|
||||
} else if (hashes.containsKey('sha3-256')) {
|
||||
return 'sha-512';
|
||||
} else if (hashes.containsKey('sha-256')) {
|
||||
return 'sha-256';
|
||||
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 '';
|
||||
|
||||
return HashFunction.sha256;
|
||||
}
|
||||
|
||||
String getStickerHashKey(Map<String, String> hashes) {
|
||||
// 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.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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,11 @@ class HttpPeekResult {
|
||||
/// 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 {
|
||||
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();
|
||||
@@ -36,7 +40,8 @@ Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgre
|
||||
var bytes = 0;
|
||||
final downloadCompleter = Completer<void>();
|
||||
unawaited(
|
||||
resp.transform(
|
||||
resp
|
||||
.transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
@@ -48,7 +53,8 @@ Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgre
|
||||
downloadCompleter.complete();
|
||||
},
|
||||
),
|
||||
).pipe(fileSink),
|
||||
)
|
||||
.pipe(fileSink),
|
||||
);
|
||||
|
||||
// Wait for the download to complete
|
||||
@@ -69,7 +75,12 @@ Future<int?> downloadFile(Uri uri, String destination, ProgressCallback onProgre
|
||||
/// 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 {
|
||||
Future<int?> uploadFile(
|
||||
Uri destination,
|
||||
Map<String, String> headers,
|
||||
String filePath,
|
||||
ProgressCallback onProgress,
|
||||
) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.putUrl(destination);
|
||||
@@ -125,9 +136,7 @@ Future<HttpPeekResult?> peekUrl(Uri uri) async {
|
||||
client.close(force: true);
|
||||
final contentType = resp.headers['Content-Type'];
|
||||
return HttpPeekResult(
|
||||
contentType != null && contentType.isNotEmpty ?
|
||||
contentType.first :
|
||||
null,
|
||||
contentType != null && contentType.isNotEmpty ? contentType.first : null,
|
||||
resp.contentLength,
|
||||
);
|
||||
} catch (ex) {
|
||||
|
||||
@@ -1,40 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
/// 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].
|
||||
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
||||
@@ -42,8 +6,8 @@ bool isRequestOkay(int? statusCode) {
|
||||
return statusCode != null && statusCode >= 200 && statusCode <= 399;
|
||||
}
|
||||
|
||||
class FileMetadata {
|
||||
const FileMetadata({ this.mime, this.size });
|
||||
class FileUploadMetadata {
|
||||
const FileUploadMetadata({this.mime, this.size});
|
||||
final String? mime;
|
||||
final int? size;
|
||||
}
|
||||
@@ -51,10 +15,10 @@ class FileMetadata {
|
||||
/// 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.
|
||||
/// 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 result = await peekUrl(Uri.parse(url));
|
||||
|
||||
return FileMetadata(
|
||||
return FileUploadMetadata(
|
||||
mime: result?.contentType,
|
||||
size: result?.contentLength,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
@@ -13,10 +12,11 @@ import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart' as client;
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/jobs.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
@@ -32,37 +32,39 @@ import 'package:uuid/uuid.dart';
|
||||
|
||||
/// This service is responsible for managing the up- and download of files using Http.
|
||||
class HttpFileTransferService {
|
||||
HttpFileTransferService()
|
||||
: _uploadQueue = Queue<FileUploadJob>(),
|
||||
_downloadQueue = Queue<FileDownloadJob>(),
|
||||
_uploadLock = Lock(),
|
||||
_downloadLock = Lock(),
|
||||
_log = Logger('HttpFileTransferService');
|
||||
HttpFileTransferService() {
|
||||
GetIt.I.get<ConnectivityService>().stream.listen(_onConnectivityChanged);
|
||||
}
|
||||
|
||||
final Logger _log;
|
||||
final Logger _log = Logger('HttpFileTransferService');
|
||||
|
||||
/// Queues for tracking up- and download tasks
|
||||
final Queue<FileDownloadJob> _downloadQueue;
|
||||
final Queue<FileUploadJob> _uploadQueue;
|
||||
final Queue<FileDownloadJob> _downloadQueue = Queue<FileDownloadJob>();
|
||||
final Queue<FileUploadJob> _uploadQueue = Queue<FileUploadJob>();
|
||||
|
||||
/// The currently running job and their lock
|
||||
FileUploadJob? _currentUploadJob;
|
||||
FileDownloadJob? _currentDownloadJob;
|
||||
|
||||
/// Locks for upload and download state
|
||||
final Lock _uploadLock;
|
||||
final Lock _downloadLock;
|
||||
final Lock _uploadLock = Lock();
|
||||
final Lock _downloadLock = Lock();
|
||||
|
||||
/// Called by the ConnectivityService if the connection got lost but then was regained.
|
||||
Future<void> onConnectivityChanged(bool regained) async {
|
||||
if (!regained) return;
|
||||
Future<void> _onConnectivityChanged(ConnectivityEvent event) async {
|
||||
if (!event.regained) return;
|
||||
|
||||
await _uploadLock.synchronized(() async {
|
||||
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!));
|
||||
} else {
|
||||
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();
|
||||
unawaited(_performFileUpload(_currentUploadJob!));
|
||||
}
|
||||
@@ -71,11 +73,15 @@ class HttpFileTransferService {
|
||||
|
||||
await _downloadLock.synchronized(() async {
|
||||
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!));
|
||||
} else {
|
||||
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();
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
}
|
||||
@@ -115,29 +121,28 @@ class HttpFileTransferService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _copyFile(FileUploadJob job) async {
|
||||
for (final recipient in job.recipients) {
|
||||
final newPath = await getDownloadPath(
|
||||
pathlib.basename(job.path),
|
||||
recipient,
|
||||
job.mime,
|
||||
);
|
||||
|
||||
await File(job.path).copy(newPath);
|
||||
Future<void> _copyFile(
|
||||
FileUploadJob job,
|
||||
String to,
|
||||
) async {
|
||||
if (!File(to).existsSync()) {
|
||||
await File(job.path).copy(to);
|
||||
|
||||
// Let the media scanner index the file
|
||||
MoxplatformPlugin.media.scanFile(newPath);
|
||||
|
||||
// Update the message
|
||||
await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaUrl: newPath,
|
||||
MoxplatformPlugin.media.scanFile(to);
|
||||
} else {
|
||||
_log.finest(
|
||||
'Skipping file copy on upload as file is already at media location',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
||||
Future<void> _fileUploadFailed(
|
||||
FileUploadJob job,
|
||||
MessageErrorType error,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
// Notify UI of upload failure
|
||||
for (final recipient in job.recipients) {
|
||||
@@ -147,6 +152,19 @@ class HttpFileTransferService {
|
||||
isUploading: false,
|
||||
);
|
||||
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();
|
||||
@@ -175,7 +193,7 @@ class HttpFileTransferService {
|
||||
);
|
||||
} catch (ex) {
|
||||
_log.warning('Encrypting ${job.path} failed: $ex');
|
||||
await _fileUploadFailed(job, messageFailedToEncryptFile);
|
||||
await _fileUploadFailed(job, MessageErrorType.failedToEncryptFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -185,7 +203,8 @@ class HttpFileTransferService {
|
||||
|
||||
// Request the upload slot
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final httpManager = conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
||||
final httpManager =
|
||||
conn.getManagerById<HttpFileUploadManager>(httpFileUploadManager)!;
|
||||
final slotResult = await httpManager.requestUploadSlot(
|
||||
pathlib.basename(path),
|
||||
stat.size,
|
||||
@@ -193,7 +212,7 @@ class HttpFileTransferService {
|
||||
|
||||
if (slotResult.isType<HttpFileUploadError>()) {
|
||||
_log.severe('Failed to request upload slot for ${job.path}!');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||
return;
|
||||
}
|
||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||
@@ -219,38 +238,16 @@ class HttpFileTransferService {
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (!isRequestOkay(uploadStatusCode)) {
|
||||
_log.severe('Upload failed');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
_log.severe('Upload failed due to status code $uploadStatusCode');
|
||||
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
mediaSize: stat.size,
|
||||
errorType: noError,
|
||||
encryptionScheme: encryption != null ?
|
||||
SFSEncryptionType.aes256GcmNoPadding.toNamespace() :
|
||||
null,
|
||||
key: encryption != null ? base64Encode(encryption.key) : null,
|
||||
iv: encryption != null ? base64Encode(encryption.iv) : null,
|
||||
isUploading: false,
|
||||
srcUrl: slot.getUrl,
|
||||
);
|
||||
// TODO(Unknown): Maybe batch those two together?
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Get hashes
|
||||
StatelessFileSharingSource source;
|
||||
final plaintextHashes = <String, String>{};
|
||||
final plaintextHashes = <HashFunction, String>{};
|
||||
Map<HashFunction, String>? ciphertextHashes;
|
||||
if (encryption != null) {
|
||||
source = StatelessFileSharingEncryptedSource(
|
||||
SFSEncryptionType.aes256GcmNoPadding,
|
||||
@@ -261,45 +258,121 @@ class HttpFileTransferService {
|
||||
);
|
||||
|
||||
plaintextHashes.addAll(encryption.plaintextHashes);
|
||||
ciphertextHashes = encryption.ciphertextHashes;
|
||||
} else {
|
||||
source = StatelessFileSharingUrlSource(slot.getUrl);
|
||||
try {
|
||||
plaintextHashes[hashSha256] = await GetIt.I.get<CryptographyService>()
|
||||
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: null,
|
||||
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(
|
||||
await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
JID.fromString(recipient),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageBodyData(slot.getUrl),
|
||||
const MessageDeliveryReceiptData(true),
|
||||
StableIdData(msg.originId, null),
|
||||
StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
name: pathlib.basename(job.path),
|
||||
name: filename,
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
[source],
|
||||
includeOOBFallback: true,
|
||||
),
|
||||
FileUploadNotificationReplacementData(oldSid),
|
||||
MessageIdData(msg.sid),
|
||||
]),
|
||||
);
|
||||
_log.finest(
|
||||
'Sent message with file upload for ${job.path} to $recipient',
|
||||
);
|
||||
_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));
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +381,8 @@ class HttpFileTransferService {
|
||||
|
||||
Future<void> _pickNextUploadTask() async {
|
||||
// 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 {
|
||||
if (_uploadQueue.isNotEmpty) {
|
||||
_currentUploadJob = _uploadQueue.removeFirst();
|
||||
@@ -319,7 +393,10 @@ class HttpFileTransferService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _fileDownloadFailed(FileDownloadJob job, int error) async {
|
||||
Future<void> _fileDownloadFailed(
|
||||
FileDownloadJob job,
|
||||
MessageErrorType error,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
|
||||
// Notify UI of download failure
|
||||
@@ -336,7 +413,10 @@ class HttpFileTransferService {
|
||||
/// Actually attempt to download the file described by the job [job].
|
||||
Future<void> _performFileDownload(FileDownloadJob job) async {
|
||||
final filename = job.location.filename;
|
||||
final downloadedPath = await getDownloadPath(filename, job.conversationJid, job.mimeGuess);
|
||||
final downloadedPath = await computeCachedPathForFile(
|
||||
job.location.filename,
|
||||
job.location.plaintextHashes,
|
||||
);
|
||||
|
||||
var downloadPath = downloadedPath;
|
||||
if (job.location.key != null && job.location.iv != null) {
|
||||
@@ -345,13 +425,18 @@ class HttpFileTransferService {
|
||||
downloadPath = pathlib.join(tempDir.path, filename);
|
||||
}
|
||||
|
||||
_log.finest('Downloading ${job.location.url} as $filename (MIME guess ${job.mimeGuess}) to $downloadPath (-> $downloadedPath)');
|
||||
// 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 {
|
||||
_log.finest('Beginning download...');
|
||||
downloadStatusCode = await client.downloadFile(
|
||||
Uri.parse(job.location.url),
|
||||
Uri.parse(downloadUrl),
|
||||
downloadPath,
|
||||
(total, current) {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
@@ -369,15 +454,16 @@ class HttpFileTransferService {
|
||||
}
|
||||
|
||||
if (!isRequestOkay(downloadStatusCode)) {
|
||||
_log.warning('HTTP GET of ${job.location.url} returned $downloadStatusCode');
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
_log.warning(
|
||||
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
||||
);
|
||||
await _fileDownloadFailed(job, MessageErrorType.fileDownloadFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
var integrityCheckPassed = true;
|
||||
final conv = (await GetIt.I.get<ConversationService>()
|
||||
.getConversationByJid(job.conversationJid))!;
|
||||
final decryptionKeysAvailable = job.location.key != null && job.location.iv != null;
|
||||
final decryptionKeysAvailable =
|
||||
job.location.key != null && job.location.iv != null;
|
||||
final crypto = GetIt.I.get<CryptographyService>();
|
||||
if (decryptionKeysAvailable) {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
@@ -387,10 +473,10 @@ class HttpFileTransferService {
|
||||
);
|
||||
|
||||
try {
|
||||
final result = await GetIt.I.get<CryptographyService>().decryptFile(
|
||||
final result = await crypto.decryptFile(
|
||||
downloadPath,
|
||||
downloadedPath,
|
||||
encryptionTypeFromNamespace(job.location.encryptionScheme!),
|
||||
SFSEncryptionType.fromNamespace(job.location.encryptionScheme!),
|
||||
job.location.key!,
|
||||
job.location.iv!,
|
||||
job.location.plaintextHashes ?? {},
|
||||
@@ -399,18 +485,44 @@ class HttpFileTransferService {
|
||||
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
integrityCheckPassed = result.plaintextOkay && result.ciphertextOkay;
|
||||
} catch (ex) {
|
||||
_log.warning('Decryption of $downloadPath ($downloadedPath) failed: $ex');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
_log.warning(
|
||||
'Decryption of $downloadPath ($downloadedPath) failed: $ex',
|
||||
);
|
||||
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
unawaited(Directory(pathlib.dirname(downloadPath)).delete(recursive: true));
|
||||
unawaited(
|
||||
Directory(pathlib.dirname(downloadPath)).delete(recursive: true),
|
||||
);
|
||||
} else if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
||||
// Verify only the plaintext hash
|
||||
// TODO(Unknown): Allow verification of other hash functions
|
||||
if (job.location.plaintextHashes![HashFunction.sha256] != null) {
|
||||
final hash = await crypto.hashFile(
|
||||
downloadPath,
|
||||
HashFunction.sha256,
|
||||
);
|
||||
integrityCheckPassed =
|
||||
hash == job.location.plaintextHashes![HashFunction.sha256];
|
||||
} else if (job.location.plaintextHashes![HashFunction.sha512] != null) {
|
||||
final hash = await crypto.hashFile(
|
||||
downloadPath,
|
||||
HashFunction.sha512,
|
||||
);
|
||||
integrityCheckPassed =
|
||||
hash == job.location.plaintextHashes![HashFunction.sha512];
|
||||
} else {
|
||||
_log.warning(
|
||||
'Could not verify file integrity as no accelerated hash function is available (${job.location.plaintextHashes!.keys})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check the MIME type
|
||||
@@ -454,50 +566,57 @@ class HttpFileTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
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) &&
|
||||
integrityCheckPassed &&
|
||||
job.createMetadataHashes) {
|
||||
await fs.createMetadataHashEntries(
|
||||
job.location.plaintextHashes!,
|
||||
job.metadataId,
|
||||
);
|
||||
}
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conversation = (await cs.getConversationByJid(job.conversationJid))!;
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
mediaUrl: downloadedPath,
|
||||
mediaType: mime,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
mediaSize: File(downloadedPath).lengthSync(),
|
||||
fileMetadata: metadata,
|
||||
isFileUploadNotification: false,
|
||||
warningType: integrityCheckPassed ?
|
||||
null :
|
||||
warningFileIntegrityCheckFailed,
|
||||
errorType: conv.encrypted && !decryptionKeysAvailable ?
|
||||
messageChatEncryptedButFileNot :
|
||||
null,
|
||||
warningType:
|
||||
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
||||
errorType: conversation.encrypted && !decryptionKeysAvailable
|
||||
? MessageErrorType.chatEncryptedButPlaintextFile
|
||||
: 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 updatedConversation = conversation.copyWith(
|
||||
lastMessage: conversation.lastMessage?.id == job.mId
|
||||
? msg
|
||||
: conversation.lastMessage,
|
||||
);
|
||||
final newConv = conv.copyWith(
|
||||
lastMessage: conv.lastMessage?.id == job.mId ?
|
||||
msg :
|
||||
conv.lastMessage,
|
||||
sharedMedia: [
|
||||
sharedMedium,
|
||||
...conv.sharedMedia,
|
||||
],
|
||||
);
|
||||
GetIt.I.get<ConversationService>().setConversation(newConv);
|
||||
cs.setConversation(updatedConversation);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) && job.shouldShowNotification) {
|
||||
if (notification.shouldShowNotification(msg.conversationJid) &&
|
||||
job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(newConv, msg, '');
|
||||
await notification.showNotification(updatedConversation, msg, '');
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConv));
|
||||
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
|
||||
|
||||
// Free the download resources for the next one
|
||||
await _pickNextDownloadTask();
|
||||
@@ -509,7 +628,8 @@ class HttpFileTransferService {
|
||||
_currentDownloadJob = _downloadQueue.removeFirst();
|
||||
|
||||
// Only download if we have a connection
|
||||
if (GetIt.I.get<ConnectivityService>().currentState != ConnectivityResult.none) {
|
||||
if (GetIt.I.get<ConnectivityService>().currentState !=
|
||||
ConnectivityResult.none) {
|
||||
unawaited(_performFileDownload(_currentDownloadJob!));
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -6,7 +6,15 @@ import 'package:moxxyv2/shared/models/message.dart';
|
||||
/// A job describing the download of a file.
|
||||
@immutable
|
||||
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 String path;
|
||||
final String? mime;
|
||||
@@ -14,6 +22,7 @@ class FileUploadJob {
|
||||
final Map<String, bool> encryptMap;
|
||||
// Recipient -> Message
|
||||
final Map<String, Message> messageMap;
|
||||
final String metadataId;
|
||||
final List<Thumbnail> thumbnails;
|
||||
|
||||
@override
|
||||
@@ -24,11 +33,19 @@ class FileUploadJob {
|
||||
messageMap == other.messageMap &&
|
||||
mime == other.mime &&
|
||||
thumbnails == other.thumbnails &&
|
||||
encryptMap == other.encryptMap;
|
||||
encryptMap == other.encryptMap &&
|
||||
metadataId == other.metadataId;
|
||||
}
|
||||
|
||||
@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.
|
||||
@@ -37,14 +54,33 @@ class FileDownloadJob {
|
||||
const FileDownloadJob(
|
||||
this.location,
|
||||
this.mId,
|
||||
this.metadataId,
|
||||
this.createMetadataHashes,
|
||||
this.conversationJid,
|
||||
this.mimeGuess, {
|
||||
this.shouldShowNotification = true,
|
||||
});
|
||||
|
||||
/// The location where the file can be found.
|
||||
final MediaFileLocation location;
|
||||
|
||||
/// The id of the message associated with the download.
|
||||
final int mId;
|
||||
|
||||
/// The id of the file metadata describing the file.
|
||||
final String metadataId;
|
||||
|
||||
/// Flag indicating whether we should create hash pointers to the file metadata
|
||||
/// object.
|
||||
final bool createMetadataHashes;
|
||||
|
||||
/// The JID of the conversation this message was received in.
|
||||
final String conversationJid;
|
||||
|
||||
/// A guess to the files's MIME type.
|
||||
final String? mimeGuess;
|
||||
|
||||
/// Flag indicating whether a notification should be shown after successful download.
|
||||
final bool shouldShowNotification;
|
||||
|
||||
@override
|
||||
@@ -52,11 +88,18 @@ class FileDownloadJob {
|
||||
return other is FileDownloadJob &&
|
||||
location == other.location &&
|
||||
mId == other.mId &&
|
||||
metadataId == other.metadataId &&
|
||||
conversationJid == other.conversationJid &&
|
||||
mimeGuess == other.mimeGuess &&
|
||||
shouldShowNotification == other.shouldShowNotification;
|
||||
}
|
||||
|
||||
@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 'package:meta/meta.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
|
||||
@immutable
|
||||
class MediaFileLocation {
|
||||
|
||||
const MediaFileLocation(
|
||||
this.url,
|
||||
this.urls,
|
||||
this.filename,
|
||||
this.encryptionScheme,
|
||||
this.key,
|
||||
this.iv,
|
||||
this.plaintextHashes,
|
||||
this.ciphertextHashes,
|
||||
this.size,
|
||||
);
|
||||
final String url;
|
||||
final List<String> urls;
|
||||
final String filename;
|
||||
final String? encryptionScheme;
|
||||
final List<int>? key;
|
||||
final List<int>? iv;
|
||||
final Map<String, String>? plaintextHashes;
|
||||
final Map<String, String>? ciphertextHashes;
|
||||
final Map<HashFunction, String>? plaintextHashes;
|
||||
final Map<HashFunction, String>? ciphertextHashes;
|
||||
final int? size;
|
||||
|
||||
String? get keyBase64 {
|
||||
if (key != null) return base64Encode(key!);
|
||||
@@ -34,16 +36,24 @@ class MediaFileLocation {
|
||||
}
|
||||
|
||||
@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
|
||||
bool operator ==(Object other) {
|
||||
// TODO(PapaTutuWawa): Compare the Maps
|
||||
return other is MediaFileLocation &&
|
||||
url == other.url &&
|
||||
filename == other.filename &&
|
||||
encryptionScheme == other.encryptionScheme &&
|
||||
key == other.key &&
|
||||
iv == other.iv;
|
||||
iv == other.iv &&
|
||||
size == other.size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,333 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/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/not_specified.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/cache.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/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:synchronized/synchronized.dart';
|
||||
|
||||
class MessageService {
|
||||
MessageService() : _messageCache = HashMap(), _log = Logger('MessageService');
|
||||
final HashMap<String, List<Message>> _messageCache;
|
||||
final Logger _log;
|
||||
/// Logger
|
||||
final Logger _log = Logger('MessageService');
|
||||
|
||||
/// Returns the messages for [jid], either from cache or from the database.
|
||||
Future<List<Message>> getMessagesForJid(String jid) async {
|
||||
if (!_messageCache.containsKey(jid)) {
|
||||
_messageCache[jid] = await GetIt.I.get<DatabaseService>().loadMessagesForJid(jid);
|
||||
final LRUCache<String, List<Message>> _messageCache =
|
||||
LRUCache(conversationMessagePageCacheSize);
|
||||
final Lock _cacheLock = Lock();
|
||||
|
||||
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];
|
||||
if (messages == null) {
|
||||
_log.warning('No messages found for $jid. Returning [].');
|
||||
return [];
|
||||
return Message.fromDatabaseJson(
|
||||
msg,
|
||||
null,
|
||||
fm,
|
||||
queryReactionPreview
|
||||
? await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(msg['id']! as int)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
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 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. If [jid] is set to null, then the media messages for
|
||||
/// all conversations are queried.
|
||||
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
|
||||
String? jid,
|
||||
bool olderThan,
|
||||
int? oldestTimestamp,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final comparator = olderThan ? '<' : '>';
|
||||
final queryPrefix = jid != null ? 'conversationJid = ? AND' : '';
|
||||
final query = oldestTimestamp != null
|
||||
? 'file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||
: '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
|
||||
$queryPrefix $query
|
||||
ORDER BY timestamp
|
||||
DESC LIMIT $sharedMediaPaginationSize
|
||||
) AS msg
|
||||
LEFT JOIN
|
||||
$fileMetadataTable fm
|
||||
ON
|
||||
msg.file_metadata_id = fm.id
|
||||
WHERE
|
||||
fm_path IS NOT NULL
|
||||
AND NOT EXISTS (SELECT id FROM $stickersTable WHERE file_metadata_id = fm.id);
|
||||
''',
|
||||
[
|
||||
if (jid != null) 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.
|
||||
@@ -39,186 +336,222 @@ class MessageService {
|
||||
int timestamp,
|
||||
String sender,
|
||||
String conversationJid,
|
||||
bool isMedia,
|
||||
String sid,
|
||||
bool isFileUploadNotification,
|
||||
bool encrypted,
|
||||
bool containsNoStore,
|
||||
{
|
||||
String? srcUrl,
|
||||
String? key,
|
||||
String? iv,
|
||||
String? encryptionScheme,
|
||||
String? mediaUrl,
|
||||
String? mediaType,
|
||||
String? thumbnailData,
|
||||
int? mediaWidth,
|
||||
int? mediaHeight,
|
||||
bool containsNoStore, {
|
||||
String? originId,
|
||||
String? quoteId,
|
||||
String? filename,
|
||||
int? errorType,
|
||||
FileMetadata? fileMetadata,
|
||||
MessageErrorType? errorType,
|
||||
int? warningType,
|
||||
Map<String, String>? plaintextHashes,
|
||||
Map<String, String>? ciphertextHashes,
|
||||
bool isDownloading = false,
|
||||
bool isUploading = false,
|
||||
int? mediaSize,
|
||||
String? stickerPackId,
|
||||
String? stickerHashKey,
|
||||
int? pseudoMessageType,
|
||||
PseudoMessageType? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}
|
||||
) async {
|
||||
final msg = await GetIt.I.get<DatabaseService>().addMessageFromData(
|
||||
bool received = false,
|
||||
bool displayed = false,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
var m = Message(
|
||||
sender,
|
||||
body,
|
||||
timestamp,
|
||||
sender,
|
||||
conversationJid,
|
||||
isMedia,
|
||||
sid,
|
||||
-1,
|
||||
conversationJid,
|
||||
isFileUploadNotification,
|
||||
encrypted,
|
||||
containsNoStore,
|
||||
srcUrl: srcUrl,
|
||||
key: key,
|
||||
iv: iv,
|
||||
encryptionScheme: encryptionScheme,
|
||||
mediaUrl: mediaUrl,
|
||||
mediaType: mediaType,
|
||||
thumbnailData: thumbnailData,
|
||||
mediaWidth: mediaWidth,
|
||||
mediaHeight: mediaHeight,
|
||||
originId: originId,
|
||||
quoteId: quoteId,
|
||||
filename: filename,
|
||||
errorType: errorType,
|
||||
warningType: warningType,
|
||||
plaintextHashes: plaintextHashes,
|
||||
ciphertextHashes: ciphertextHashes,
|
||||
fileMetadata: fileMetadata,
|
||||
received: received,
|
||||
displayed: displayed,
|
||||
acked: false,
|
||||
originId: originId,
|
||||
isUploading: isUploading,
|
||||
isDownloading: isDownloading,
|
||||
mediaSize: mediaSize,
|
||||
stickerPackId: stickerPackId,
|
||||
stickerHashKey: stickerHashKey,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
);
|
||||
|
||||
// Only update the cache if the conversation already has been loaded. This prevents
|
||||
// us from accidentally not loading the conversation afterwards.
|
||||
if (_messageCache.containsKey(conversationJid)) {
|
||||
_messageCache[conversationJid] = _messageCache[conversationJid]!..add(msg);
|
||||
if (quoteId != null) {
|
||||
final quotes = await getMessageByXmppId(quoteId, conversationJid);
|
||||
if (quotes == null) {
|
||||
_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 {
|
||||
if (!_messageCache.containsKey(conversationJid)) {
|
||||
await getMessagesForJid(conversationJid);
|
||||
}
|
||||
|
||||
return firstWhereOrNull(
|
||||
_messageCache[conversationJid]!,
|
||||
(message) => message.sid == stanzaId,
|
||||
Future<Message?> getMessageByStanzaId(
|
||||
String conversationJid,
|
||||
String stanzaId,
|
||||
) async {
|
||||
return getMessageByXmppId(
|
||||
stanzaId,
|
||||
conversationJid,
|
||||
includeOriginId: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageByStanzaOrOriginId(String conversationJid, String id) async {
|
||||
if (!_messageCache.containsKey(conversationJid)) {
|
||||
await getMessagesForJid(conversationJid);
|
||||
}
|
||||
|
||||
return firstWhereOrNull(
|
||||
_messageCache[conversationJid]!,
|
||||
(message) => message.sid == id || message.originId == id,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageById(String conversationJid, int id) async {
|
||||
if (!_messageCache.containsKey(conversationJid)) {
|
||||
await getMessagesForJid(conversationJid);
|
||||
}
|
||||
|
||||
return firstWhereOrNull(
|
||||
_messageCache[conversationJid]!,
|
||||
(message) => message.id == id,
|
||||
Future<Message?> getMessageByStanzaOrOriginId(
|
||||
String conversationJid,
|
||||
String id,
|
||||
) async {
|
||||
return getMessageByXmppId(
|
||||
id,
|
||||
conversationJid,
|
||||
);
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
||||
Future<Message> updateMessage(int id, {
|
||||
Future<Message> updateMessage(
|
||||
int id, {
|
||||
Object? body = notSpecified,
|
||||
Object? mediaUrl = notSpecified,
|
||||
Object? mediaType = notSpecified,
|
||||
bool? isMedia,
|
||||
bool? received,
|
||||
bool? displayed,
|
||||
bool? acked,
|
||||
Object? fileMetadata = notSpecified,
|
||||
Object? errorType = notSpecified,
|
||||
Object? warningType = notSpecified,
|
||||
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? isDownloading,
|
||||
Object? originId = notSpecified,
|
||||
Object? sid = notSpecified,
|
||||
Object? thumbnailData = notSpecified,
|
||||
bool? isRetracted,
|
||||
bool? isEdited,
|
||||
Object? reactions = notSpecified,
|
||||
}) async {
|
||||
final newMessage = await GetIt.I.get<DatabaseService>().updateMessage(
|
||||
id,
|
||||
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,
|
||||
isEdited: isEdited,
|
||||
reactions: reactions,
|
||||
);
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final m = <String, dynamic>{};
|
||||
|
||||
if (_messageCache.containsKey(newMessage.conversationJid)) {
|
||||
_messageCache[newMessage.conversationJid] = _messageCache[newMessage.conversationJid]!.map((m) {
|
||||
if (m.id == newMessage.id) return newMessage;
|
||||
|
||||
return m;
|
||||
}).toList();
|
||||
if (body != notSpecified) {
|
||||
m['body'] = body as String?;
|
||||
}
|
||||
if (received != null) {
|
||||
m['received'] = boolToInt(received);
|
||||
}
|
||||
if (displayed != null) {
|
||||
m['displayed'] = boolToInt(displayed);
|
||||
}
|
||||
if (acked != null) {
|
||||
m['acked'] = boolToInt(acked);
|
||||
}
|
||||
if (errorType != notSpecified) {
|
||||
m['errorType'] = (errorType as MessageErrorType?)?.value;
|
||||
}
|
||||
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
|
||||
/// - 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
|
||||
/// - Remove the SharedMedium from the database, if one referenced the retracted message
|
||||
/// - Update the UI
|
||||
///
|
||||
/// [conversationJid] is the bare JID of the conversation this message belongs to.
|
||||
@@ -227,44 +560,42 @@ class MessageService {
|
||||
/// [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
|
||||
/// the UI already verifies it.
|
||||
Future<void> retractMessage(String conversationJid, String originId, String bareSender, bool selfRetract) async {
|
||||
final msg = await GetIt.I.get<DatabaseService>().getMessageByOriginId(
|
||||
Future<void> retractMessage(
|
||||
String conversationJid,
|
||||
String originId,
|
||||
String bareSender,
|
||||
bool selfRetract,
|
||||
) async {
|
||||
final msg = await getMessageByXmppId(
|
||||
originId,
|
||||
conversationJid,
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Check if the retraction was sent by the original sender
|
||||
if (!selfRetract) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
final isMedia = msg.isMedia;
|
||||
final mediaUrl = msg.mediaUrl;
|
||||
final retractedMessage = await updateMessage(
|
||||
msg.id,
|
||||
isMedia: false,
|
||||
mediaUrl: null,
|
||||
mediaType: null,
|
||||
warningType: null,
|
||||
errorType: null,
|
||||
srcUrl: null,
|
||||
key: null,
|
||||
iv: null,
|
||||
encryptionScheme: null,
|
||||
mediaWidth: null,
|
||||
mediaHeight: null,
|
||||
mediaSize: null,
|
||||
isRetracted: true,
|
||||
thumbnailData: null,
|
||||
body: '',
|
||||
fileMetadata: null,
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
||||
|
||||
@@ -272,37 +603,85 @@ class MessageService {
|
||||
final conversation = await cs.getConversationByJid(conversationJid);
|
||||
if (conversation != null) {
|
||||
if (conversation.lastMessage?.id == msg.id) {
|
||||
var newConversation = conversation.copyWith(
|
||||
final newConversation = conversation.copyWith(
|
||||
lastMessage: retractedMessage,
|
||||
);
|
||||
|
||||
if (isMedia) {
|
||||
await GetIt.I.get<DatabaseService>().removeSharedMediumByMessageId(msg.id);
|
||||
|
||||
newConversation = newConversation.copyWith(
|
||||
sharedMedia: newConversation.sharedMedia.where((SharedMedium medium) {
|
||||
return medium.messageId != msg.id;
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
// Delete the file if we downloaded it
|
||||
if (mediaUrl != null) {
|
||||
final file = File(mediaUrl);
|
||||
if (file.existsSync()) {
|
||||
unawaited(file.delete());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cs.setConversation(newConversation);
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConversation,
|
||||
),
|
||||
);
|
||||
|
||||
if (isMedia) {
|
||||
// Remove the file
|
||||
await GetIt.I.get<FilesService>().removeFileIfNotReferenced(
|
||||
msg.fileMetadata!,
|
||||
);
|
||||
}
|
||||
}
|
||||
} 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(),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Marks the message with the database id [id] as displayed and sends an
|
||||
/// [MessageUpdatedEvent] to the UI. if [sendChatMarker] is true, then
|
||||
/// a Chat Marker with <displayed /> is sent to the message's
|
||||
/// conversationJid attribute.
|
||||
Future<Message> markMessageAsRead(int id, bool sendChatMarker) async {
|
||||
final newMessage = await updateMessage(
|
||||
id,
|
||||
displayed: true,
|
||||
);
|
||||
|
||||
// Tell the UI
|
||||
sendEvent(MessageUpdatedEvent(message: newMessage));
|
||||
|
||||
if (sendChatMarker) {
|
||||
await GetIt.I.get<XmppService>().sendReadMarker(
|
||||
// TODO(Unknown): This is wrong once groupchats are implemented
|
||||
newMessage.conversationJid,
|
||||
newMessage.originId ?? newMessage.sid,
|
||||
);
|
||||
}
|
||||
|
||||
return newMessage;
|
||||
}
|
||||
|
||||
/// Evicts all cached message pages for [jid], if any were cached, from the
|
||||
/// cache.
|
||||
Future<void> evictFromCache(String jid) async {
|
||||
return _cacheLock.synchronized(() => _messageCache.remove(jid));
|
||||
}
|
||||
|
||||
/// Like [evictFromCache], but for multiple JIDs [jids].
|
||||
Future<void> evictMultipleFromCache(List<String> jids) async {
|
||||
return _cacheLock.synchronized(() {
|
||||
for (final jid in jids) {
|
||||
_messageCache.remove(jid);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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') ];
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
class MoxxyOmemoManager extends BaseOmemoManager {
|
||||
MoxxyOmemoManager() : super();
|
||||
|
||||
@override
|
||||
Future<OmemoManager> getOmemoManager() async {
|
||||
final os = GetIt.I.get<OmemoService>();
|
||||
await os.ensureInitialized();
|
||||
return os.omemoManager;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza) async {
|
||||
// Never encrypt stanzas that contain PubSub elements
|
||||
if (stanza.firstTag('pubsub', xmlns: pubsubXmlns) != null ||
|
||||
stanza.firstTag('pubsub', xmlns: pubsubOwnerXmlns) != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encrypt when the conversation is set to use OMEMO.
|
||||
return GetIt.I.get<ConversationService>().shouldEncryptForConversation(toJid);
|
||||
}
|
||||
}
|
||||
|
||||
class MoxxyBTBVTrustManager extends BlindTrustBeforeVerificationTrustManager {
|
||||
MoxxyBTBVTrustManager(
|
||||
Map<RatchetMapKey, BTBVTrustState> trustCache,
|
||||
Map<RatchetMapKey, bool> enablementCache,
|
||||
Map<String, List<int>> devices,
|
||||
) : super(trustCache: trustCache, enablementCache: enablementCache, devices: devices);
|
||||
|
||||
@override
|
||||
Future<void> commitState() async {
|
||||
await GetIt.I.get<OmemoService>().commitTrustManager(await toJson());
|
||||
}
|
||||
}
|
||||
@@ -1,125 +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);
|
||||
await 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,46 +1,81 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
/// Update the "showAddToRoster" state of the conversation with jid [jid] to
|
||||
/// [showAddToRoster], if the conversation exists.
|
||||
Future<void> updateConversation(String jid, bool showAddToRoster) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final newConversation = await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
update: (conversation) async {
|
||||
final c = conversation.copyWith(
|
||||
showAddToRoster: showAddToRoster,
|
||||
);
|
||||
cs.setConversation(c);
|
||||
return c;
|
||||
},
|
||||
);
|
||||
if (newConversation != null) {
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||
}
|
||||
}
|
||||
|
||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
@override
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
return RosterCacheLoadResult(
|
||||
(await GetIt.I.get<XmppService>().getXmppState()).lastRosterVersion,
|
||||
(await rs.getRoster()).map((item) => XmppRosterItem(
|
||||
(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(),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> commitRoster(String? version, List<String> removed, List<XmppRosterItem> modified, List<XmppRosterItem> added) async {
|
||||
Future<void> commitRoster(
|
||||
String? version,
|
||||
List<String> removed,
|
||||
List<XmppRosterItem> modified,
|
||||
List<XmppRosterItem> added,
|
||||
) async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final xs = GetIt.I.get<XmppService>();
|
||||
await xs.modifyXmppState((state) => state.copyWith(
|
||||
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);
|
||||
await updateConversation(jid, true);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
final rosterAdded = List<RosterItem>.empty(growable: true);
|
||||
for (final item in added) {
|
||||
rosterAdded.add(
|
||||
await rs.addRosterItemFromData(
|
||||
final exists = await rs.getRosterItemByJid(item.jid) != null;
|
||||
// Skip adding items twice
|
||||
if (exists) continue;
|
||||
|
||||
final newRosterItem = await rs.addRosterItemFromData(
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
@@ -52,10 +87,11 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
rosterAdded.add(newRosterItem);
|
||||
|
||||
// TODO(PapaTutuWawa): Fetch the avatar
|
||||
// Update the cached conversation item
|
||||
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||
}
|
||||
|
||||
// Update modified items
|
||||
@@ -67,15 +103,17 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterModified.add(
|
||||
await rs.updateRosterItem(
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
rosterModified.add(newRosterItem);
|
||||
|
||||
// Update the cached conversation item
|
||||
await updateConversation(item.jid, newRosterItem.showAddToRosterButton);
|
||||
}
|
||||
|
||||
// Tell the UI
|
||||
|
||||
@@ -2,18 +2,20 @@ import 'package:moxdns/moxdns.dart';
|
||||
import 'package:moxxmpp_socket_tcp/moxxmpp_socket_tcp.dart';
|
||||
|
||||
class MoxxyTCPSocketWrapper extends TCPSocketWrapper {
|
||||
MoxxyTCPSocketWrapper() : super(false);
|
||||
MoxxyTCPSocketWrapper() : super();
|
||||
|
||||
@override
|
||||
Future<List<MoxSrvRecord>> srvQuery(String domain, bool dnssec) async {
|
||||
final records = await MoxdnsPlugin.srvQuery(domain, dnssec);
|
||||
return records
|
||||
.map((record) => MoxSrvRecord(
|
||||
.map(
|
||||
(record) => MoxSrvRecord(
|
||||
record.priority,
|
||||
record.weight,
|
||||
record.target,
|
||||
record.port,
|
||||
),)
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
|
||||
class MoxxyStreamManagementManager extends StreamManagementManager {
|
||||
@override
|
||||
bool shouldTriggerAckedEvent(Stanza stanza) {
|
||||
return stanza.tag == 'message' &&
|
||||
stanza.id != null && (
|
||||
stanza.firstTag('body') != null ||
|
||||
stanza.id != null &&
|
||||
(stanza.firstTag('body') != null ||
|
||||
stanza.firstTag('x', xmlns: oobDataXmlns) != null ||
|
||||
stanza.firstTag('file-sharing', xmlns: sfsXmlns) != null ||
|
||||
stanza.firstTag('file-upload', xmlns: fileUploadNotificationXmlns) != null ||
|
||||
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null
|
||||
);
|
||||
stanza.firstTag(
|
||||
'file-upload',
|
||||
xmlns: fileUploadNotificationXmlns,
|
||||
) !=
|
||||
null ||
|
||||
stanza.firstTag('encrypted', xmlns: omemoXmlns) != null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> commitState() async {
|
||||
await GetIt.I.get<XmppService>().modifyXmppState((s) => s.copyWith(
|
||||
await GetIt.I.get<XmppStateService>().modifyXmppState(
|
||||
(s) => s.copyWith(
|
||||
smState: state,
|
||||
),);
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadState() async {
|
||||
final state = await GetIt.I.get<XmppService>().getXmppState();
|
||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||
if (state.smState != null) {
|
||||
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.
|
||||
const notSpecified = _NotSpecifiedValue();
|
||||
|
||||
@@ -5,10 +5,9 @@ import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/events.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
|
||||
@@ -36,25 +35,24 @@ class NotificationsService {
|
||||
MessageNotificationTappedEvent(
|
||||
conversationJid: action.payload!['conversationJid']!,
|
||||
title: action.payload!['title']!,
|
||||
avatarUrl: action.payload!['avatarUrl']!,
|
||||
avatarPath: action.payload!['avatarPath']!,
|
||||
),
|
||||
);
|
||||
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
|
||||
// TODO(Unknown): Maybe refactor this call such that we don't have to use
|
||||
// a command.
|
||||
await performMarkMessageAsRead(
|
||||
MarkMessageAsReadCommand(
|
||||
conversationJid: action.payload!['conversationJid']!,
|
||||
sid: action.payload!['sid']!,
|
||||
newUnreadCounter: 0,
|
||||
),
|
||||
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||
int.parse(action.payload!['id']!),
|
||||
// [XmppService.sendReadMarker] will check whether the *SHOULD* send
|
||||
// the marker, i.e. if the privacy settings allow it.
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
logger.warning('Received unknown notification action key ${action.buttonKeyPressed}');
|
||||
logger.warning(
|
||||
'Received unknown notification action key ${action.buttonKeyPressed}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
Future<void> initialize() async {
|
||||
final an = AwesomeNotifications();
|
||||
await an.initialize(
|
||||
'resource://drawable/ic_service_icon',
|
||||
@@ -62,12 +60,14 @@ class NotificationsService {
|
||||
NotificationChannel(
|
||||
channelKey: _messageChannelKey,
|
||||
channelName: t.notifications.channels.messagesChannelName,
|
||||
channelDescription: t.notifications.channels.messagesChannelDescription,
|
||||
channelDescription:
|
||||
t.notifications.channels.messagesChannelDescription,
|
||||
),
|
||||
NotificationChannel(
|
||||
channelKey: _warningChannelKey,
|
||||
channelName: t.notifications.channels.warningChannelName,
|
||||
channelDescription: t.notifications.channels.warningChannelDescription,
|
||||
channelDescription:
|
||||
t.notifications.channels.warningChannelDescription,
|
||||
),
|
||||
],
|
||||
debug: kDebugMode,
|
||||
@@ -85,25 +85,29 @@ class NotificationsService {
|
||||
/// 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,
|
||||
/// then Android's BigPicture will be used.
|
||||
Future<void> showNotification(modelc.Conversation c, modelm.Message m, String title, { String? body }) async {
|
||||
Future<void> showNotification(
|
||||
modelc.Conversation c,
|
||||
modelm.Message m,
|
||||
String title, {
|
||||
String? body,
|
||||
}) async {
|
||||
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
||||
String body;
|
||||
if (m.stickerPackId != null) {
|
||||
body = t.messages.sticker;
|
||||
} else if (m.isMedia) {
|
||||
body = mimeTypeToEmoji(m.mediaType);
|
||||
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;
|
||||
final title =
|
||||
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
|
||||
final avatarPath = contactIntegrationEnabled
|
||||
? c.contactAvatarPath ?? c.avatarPath
|
||||
: c.avatarPath;
|
||||
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
@@ -113,19 +117,18 @@ class NotificationsService {
|
||||
summary: title,
|
||||
title: title,
|
||||
body: body,
|
||||
largeIcon: avatarPath.isNotEmpty ?
|
||||
'file://$avatarPath' :
|
||||
null,
|
||||
notificationLayout: m.isThumbnailable ?
|
||||
NotificationLayout.BigPicture :
|
||||
NotificationLayout.Messaging,
|
||||
largeIcon: avatarPath.isNotEmpty ? 'file://$avatarPath' : null,
|
||||
notificationLayout: m.isThumbnailable
|
||||
? NotificationLayout.BigPicture
|
||||
: NotificationLayout.Messaging,
|
||||
category: NotificationCategory.Message,
|
||||
bigPicture: m.isThumbnailable ? 'file://${m.mediaUrl}' : null,
|
||||
bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
|
||||
payload: <String, String>{
|
||||
'conversationJid': c.jid,
|
||||
'sid': m.sid,
|
||||
'title': title,
|
||||
'avatarUrl': avatarPath,
|
||||
'avatarPath': avatarPath,
|
||||
'messageId': m.id.toString(),
|
||||
},
|
||||
),
|
||||
actionButtons: [
|
||||
|
||||
@@ -2,202 +2,42 @@ import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||
import 'package:moxxyv2/service/omemo/types.dart';
|
||||
import 'package:moxxyv2/service/omemo/persistence.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class OmemoDoubleRatchetWrapper {
|
||||
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
||||
final OmemoDoubleRatchet ratchet;
|
||||
final int id;
|
||||
final String jid;
|
||||
}
|
||||
|
||||
class OmemoService {
|
||||
/// Logger.
|
||||
final Logger _log = Logger('OmemoService');
|
||||
|
||||
/// Flag indicating whether we are initialized.
|
||||
bool _initialized = false;
|
||||
|
||||
/// Flag indicating whether the initialization is currently running.
|
||||
bool _running = false;
|
||||
|
||||
/// Lock guarding access to [_waitingForInitialization], [_running], and [_initialized].
|
||||
final Lock _lock = Lock();
|
||||
final Queue<Completer<void>> _waitingForInitialization = Queue<Completer<void>>();
|
||||
final Map<String, Map<int, String>> _fingerprintCache = {};
|
||||
|
||||
late OmemoManager omemoManager;
|
||||
/// Queue for code that is waiting on the service initialization.
|
||||
final Queue<Completer<void>> _waitingForInitialization =
|
||||
Queue<Completer<void>>();
|
||||
|
||||
Future<void> initializeIfNeeded(String jid) async {
|
||||
final done = await _lock.synchronized(() => _initialized);
|
||||
if (done) return;
|
||||
/// The manager to use for OMEMO.
|
||||
late OmemoManager _omemoManager;
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
final device = await db.loadOmemoDevice(jid);
|
||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
final deviceList = <String, List<int>>{};
|
||||
if (device == null) {
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
} else {
|
||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||
for (final ratchet in await GetIt.I.get<DatabaseService>().loadRatchets()) {
|
||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||
ratchetMap[key] = ratchet.ratchet;
|
||||
}
|
||||
|
||||
deviceList.addAll(await db.loadOmemoDeviceList());
|
||||
}
|
||||
|
||||
final om = GetIt.I.get<moxxmpp.XmppConnection>().
|
||||
getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
omemoManager = OmemoManager(
|
||||
device ?? await compute(generateNewIdentityImpl, jid),
|
||||
await loadTrustManager(),
|
||||
om.sendEmptyMessageImpl,
|
||||
om.fetchDeviceList,
|
||||
om.fetchDeviceBundle,
|
||||
om.subscribeToDeviceListImpl,
|
||||
);
|
||||
|
||||
if (device == null) {
|
||||
await commitDevice(await omemoManager.getDevice());
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
}
|
||||
|
||||
omemoManager.initialize(
|
||||
ratchetMap,
|
||||
deviceList,
|
||||
);
|
||||
|
||||
omemoManager.eventStream.listen((event) async {
|
||||
if (event is RatchetModifiedEvent) {
|
||||
await GetIt.I.get<DatabaseService>().saveRatchet(
|
||||
OmemoDoubleRatchetWrapper(event.ratchet, event.deviceId, event.jid),
|
||||
);
|
||||
|
||||
if (event.added) {
|
||||
// Cache the fingerprint
|
||||
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
||||
await GetIt.I.get<DatabaseService>().addFingerprintsToCache([
|
||||
OmemoCacheTriple(
|
||||
event.jid,
|
||||
event.deviceId,
|
||||
fingerprint,
|
||||
),
|
||||
]);
|
||||
|
||||
if (_fingerprintCache.containsKey(event.jid)) {
|
||||
_fingerprintCache[event.jid]![event.deviceId] = fingerprint;
|
||||
}
|
||||
|
||||
await addNewDeviceMessage(event.jid, event.deviceId);
|
||||
}
|
||||
} else if (event is DeviceListModifiedEvent) {
|
||||
await commitDeviceMap(event.list);
|
||||
} else if (event is DeviceModifiedEvent) {
|
||||
await commitDevice(event.device);
|
||||
|
||||
// Publish it
|
||||
await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.publishBundle(await event.device.toBundle());
|
||||
}
|
||||
});
|
||||
|
||||
await _lock.synchronized(() {
|
||||
_initialized = true;
|
||||
|
||||
for (final c in _waitingForInitialization) {
|
||||
c.complete();
|
||||
}
|
||||
_waitingForInitialization.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a pseudo message saying that [jid] added a new device with id [deviceId].
|
||||
/// If, however, [jid] is our own JID, then nothing is done.
|
||||
Future<void> addNewDeviceMessage(String jid, int deviceId) async {
|
||||
// Add a pseudo message if it is not about our own devices
|
||||
final xmppState = await GetIt.I.get<XmppService>().getXmppState();
|
||||
if (jid == xmppState.jid) return;
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final message = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
'',
|
||||
jid,
|
||||
false,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
pseudoMessageType: pseudoMessageTypeNewDevice,
|
||||
pseudoMessageData: <String, dynamic>{
|
||||
'deviceId': deviceId,
|
||||
'jid': jid,
|
||||
},
|
||||
);
|
||||
sendEvent(
|
||||
MessageAddedEvent(
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<model.OmemoDevice> regenerateDevice(String jid) async {
|
||||
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
|
||||
await _lock.synchronized(() {
|
||||
_initialized = false;
|
||||
});
|
||||
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
final oldId = await omemoManager.getDeviceId();
|
||||
|
||||
// Clear the database
|
||||
await GetIt.I.get<DatabaseService>().emptyOmemoSessionTables();
|
||||
|
||||
// Regenerate the identity in the background
|
||||
final device = await compute(generateNewIdentityImpl, jid);
|
||||
await omemoManager.replaceDevice(device);
|
||||
await commitDevice(device);
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
|
||||
// Remove the old device
|
||||
final omemo = GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
await omemo.deleteDevice(oldId);
|
||||
|
||||
// Publish the new one
|
||||
await omemo.publishBundle(await omemoManager.getDeviceBundle());
|
||||
|
||||
// Allow access again
|
||||
await _lock.synchronized(() {
|
||||
_initialized = true;
|
||||
|
||||
for (final c in _waitingForInitialization) {
|
||||
c.complete();
|
||||
}
|
||||
_waitingForInitialization.clear();
|
||||
});
|
||||
|
||||
// Return the OmemoDevice
|
||||
return model.OmemoDevice(
|
||||
await getDeviceFingerprint(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
await getDeviceId(),
|
||||
);
|
||||
/// Access the underlying [OmemoManager].
|
||||
Future<OmemoManager> getOmemoManager() async {
|
||||
await ensureInitialized();
|
||||
return _omemoManager;
|
||||
}
|
||||
|
||||
/// Ensures that the code following this *AWAITED* call can access every method
|
||||
@@ -218,34 +58,87 @@ class OmemoService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
|
||||
await GetIt.I.get<DatabaseService>().saveOmemoDeviceList(deviceMap);
|
||||
/// Creates or loads the [OmemoManager] for the JID [jid].
|
||||
Future<void> initializeIfNeeded(String jid) async {
|
||||
final done = await _lock.synchronized(() {
|
||||
// Do nothing if we're already initialized
|
||||
if (_initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> commitDevice(OmemoDevice device) async {
|
||||
await GetIt.I.get<DatabaseService>().saveOmemoDevice(device);
|
||||
// Lock the execution if we're not yet running.
|
||||
if (_running) {
|
||||
return true;
|
||||
}
|
||||
_running = true;
|
||||
return false;
|
||||
});
|
||||
if (done) return;
|
||||
|
||||
final device = await loadOmemoDevice(jid);
|
||||
if (device == null) {
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
} else {
|
||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||
}
|
||||
|
||||
/// Requests our device list and checks if the current device is in it. If not, then
|
||||
/// it will be published.
|
||||
Future<Object?> publishDeviceIfNeeded() async {
|
||||
final om = GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
|
||||
|
||||
_omemoManager = OmemoManager(
|
||||
device ?? await compute(generateNewIdentityImpl, jid),
|
||||
BlindTrustBeforeVerificationTrustManager(
|
||||
commit: commitTrust,
|
||||
loadData: loadTrust,
|
||||
removeTrust: removeTrust,
|
||||
),
|
||||
om.sendEmptyMessageImpl,
|
||||
om.fetchDeviceList,
|
||||
om.fetchDeviceBundle,
|
||||
om.subscribeToDeviceListImpl,
|
||||
om.publishDeviceImpl,
|
||||
commitDevice: commitDevice,
|
||||
commitRatchets: commitRatchets,
|
||||
commitDeviceList: commitDeviceList,
|
||||
removeRatchets: removeRatchets,
|
||||
loadRatchets: loadRatchets,
|
||||
);
|
||||
|
||||
if (device == null) {
|
||||
await commitDevice(await _omemoManager.getDevice());
|
||||
}
|
||||
|
||||
await _lock.synchronized(() {
|
||||
_running = false;
|
||||
_initialized = true;
|
||||
|
||||
for (final c in _waitingForInitialization) {
|
||||
c.complete();
|
||||
}
|
||||
_waitingForInitialization.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<moxxmpp.OmemoError?> publishDeviceIfNeeded() async {
|
||||
_log.finest('publishDeviceIfNeeded: Waiting for initialization...');
|
||||
await ensureInitialized();
|
||||
_log.finest('publishDeviceIfNeeded: Done');
|
||||
|
||||
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||
final omemo = conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
final omemo =
|
||||
conn.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
|
||||
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||
final bareJid = conn.getConnectionSettings().jid.toBare();
|
||||
final device = await omemoManager.getDevice();
|
||||
final bareJid = conn.connectionSettings.jid.toBare();
|
||||
final device = await _omemoManager.getDevice();
|
||||
|
||||
final bundlesRaw = await dm.discoItemsQuery(
|
||||
bareJid.toString(),
|
||||
bareJid,
|
||||
node: moxxmpp.omemoBundlesXmlns,
|
||||
);
|
||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||
await omemo.publishBundle(await device.toBundle());
|
||||
return bundlesRaw.get<moxxmpp.DiscoError>();
|
||||
return null;
|
||||
}
|
||||
|
||||
final bundleIds = bundlesRaw
|
||||
@@ -254,181 +147,134 @@ class OmemoService {
|
||||
.map((item) => int.parse(item.name!));
|
||||
if (!bundleIds.contains(device.id)) {
|
||||
final result = await omemo.publishBundle(await device.toBundle());
|
||||
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
|
||||
if (result.isType<moxxmpp.OmemoError>()) {
|
||||
return result.get<moxxmpp.OmemoError>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final idsRaw = await omemo.getDeviceList(bareJid);
|
||||
final ids = idsRaw.isType<moxxmpp.OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
||||
final ids =
|
||||
idsRaw.isType<moxxmpp.OmemoError>() ? <int>[] : idsRaw.get<List<int>>();
|
||||
if (!ids.contains(device.id)) {
|
||||
final result = await omemo.publishBundle(await device.toBundle());
|
||||
if (result.isType<moxxmpp.OmemoError>()) return result.get<moxxmpp.OmemoError>();
|
||||
if (result.isType<moxxmpp.OmemoError>()) {
|
||||
return result.get<moxxmpp.OmemoError>();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
|
||||
final bareJid = jid.toBare().toString();
|
||||
final allDevicesRaw = await GetIt.I.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.retrieveDeviceBundles(jid);
|
||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
||||
final map = <int, String>{};
|
||||
final items = List<OmemoCacheTriple>.empty(growable: true);
|
||||
for (final device in allDevices) {
|
||||
final curveIk = await device.ik.toCurve25519();
|
||||
final fingerprint = HEX.encode(await curveIk.getBytes());
|
||||
map[device.id] = fingerprint;
|
||||
items.add(OmemoCacheTriple(bareJid, device.id, fingerprint));
|
||||
}
|
||||
|
||||
// Cache them in memory
|
||||
_fingerprintCache[bareJid] = map;
|
||||
|
||||
// Cache them in the database
|
||||
await GetIt.I.get<DatabaseService>().addFingerprintsToCache(items);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadOrFetchFingerprints(moxxmpp.JID jid) async {
|
||||
final bareJid = jid.toBare().toString();
|
||||
if (!_fingerprintCache.containsKey(bareJid)) {
|
||||
// First try to load it from the database
|
||||
final triples = await GetIt.I.get<DatabaseService>()
|
||||
.getFingerprintsFromCache(bareJid);
|
||||
if (triples.isEmpty) {
|
||||
// We found no fingerprints in the database, so try to fetch them
|
||||
await _fetchFingerprintsAndCache(jid);
|
||||
} else {
|
||||
// We have fetched fingerprints from the database
|
||||
_fingerprintCache[bareJid] = Map<int, String>.fromEntries(
|
||||
triples.map((triple) {
|
||||
return MapEntry<int, String>(
|
||||
triple.deviceId,
|
||||
triple.fingerprint,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<model.OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
||||
Future<void> onNewConnection() async {
|
||||
await ensureInitialized();
|
||||
|
||||
// Get finger prints if we have to
|
||||
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
|
||||
|
||||
final keys = List<model.OmemoDevice>.empty(growable: true);
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(jid);
|
||||
|
||||
if (!_fingerprintCache.containsKey(jid)) return [];
|
||||
for (final deviceId in _fingerprintCache[jid]!.keys) {
|
||||
keys.add(
|
||||
model.OmemoDevice(
|
||||
_fingerprintCache[jid]![deviceId]!,
|
||||
await tm.isTrusted(jid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
await tm.isEnabled(jid, deviceId),
|
||||
deviceId,
|
||||
),
|
||||
);
|
||||
await _omemoManager.onNewConnection();
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||
await GetIt.I.get<DatabaseService>().saveTrustCache(
|
||||
json['trust']! as Map<String, int>,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().saveTrustEnablementList(
|
||||
json['enable']! as Map<String, bool>,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().saveTrustDeviceList(
|
||||
json['devices']! as Map<String, List<int>>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
||||
final db = GetIt.I.get<DatabaseService>();
|
||||
return MoxxyBTBVTrustManager(
|
||||
await db.loadTrustCache(),
|
||||
await db.loadTrustEnablementList(),
|
||||
await db.loadTrustDeviceList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setOmemoKeyEnabled(String jid, int deviceId, bool enabled) async {
|
||||
Future<List<model.OmemoDevice>> getFingerprintsForJid(String jid) async {
|
||||
await ensureInitialized();
|
||||
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
||||
}
|
||||
final fingerprints = await _omemoManager.getFingerprintsForJid(jid) ?? [];
|
||||
var trust = <int, BTBVTrustData>{};
|
||||
|
||||
Future<void> removeAllSessions(String jid) async {
|
||||
await ensureInitialized();
|
||||
await omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
Future<int> getDeviceId() async {
|
||||
await ensureInitialized();
|
||||
return omemoManager.getDeviceId();
|
||||
}
|
||||
|
||||
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
|
||||
|
||||
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
||||
/// published on [ownJid]'s devices PubSub node.
|
||||
/// Note that the list is made so that the current device is excluded.
|
||||
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
|
||||
final ownId = await getDeviceId();
|
||||
final keys = List<model.OmemoDevice>.from(
|
||||
await getOmemoKeysForJid(ownJid.toString()),
|
||||
);
|
||||
final bareJid = ownJid.toBare().toString();
|
||||
|
||||
// Get fingerprints if we have to
|
||||
await _loadOrFetchFingerprints(ownJid);
|
||||
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(bareJid);
|
||||
|
||||
for (final deviceId in _fingerprintCache[bareJid]!.keys) {
|
||||
if (deviceId == ownId) continue;
|
||||
if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
|
||||
|
||||
final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
|
||||
keys.add(
|
||||
model.OmemoDevice(
|
||||
fingerprint,
|
||||
await tm.isTrusted(bareJid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
await tm.isEnabled(bareJid, deviceId),
|
||||
deviceId,
|
||||
hasSessionWith: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
Future<void> verifyDevice(int deviceId, String jid) async {
|
||||
final tm = omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
await tm.setDeviceTrust(
|
||||
await _omemoManager.withTrustManager(
|
||||
jid,
|
||||
deviceId,
|
||||
BTBVTrustState.verified,
|
||||
(tm) async {
|
||||
trust = await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||
.getDevicesTrust(jid);
|
||||
},
|
||||
);
|
||||
|
||||
return fingerprints.map((fp) {
|
||||
return model.OmemoDevice(
|
||||
fp.fingerprint,
|
||||
trust[fp.deviceId]?.trusted ?? false,
|
||||
trust[fp.deviceId]?.state == BTBVTrustState.verified,
|
||||
trust[fp.deviceId]?.enabled ?? false,
|
||||
fp.deviceId,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> setDeviceEnablement(String jid, int device, bool state) async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.withTrustManager(jid, (tm) async {
|
||||
await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||
.setEnabled(jid, device, state);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setDeviceVerified(String jid, int device) async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.withTrustManager(jid, (tm) async {
|
||||
await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||
.setDeviceTrust(jid, device, BTBVTrustState.verified);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeAllRatchets(String jid) async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
Future<OmemoDevice> getDevice() async {
|
||||
await ensureInitialized();
|
||||
return _omemoManager.getDevice();
|
||||
}
|
||||
|
||||
Future<model.OmemoDevice> regenerateDevice() async {
|
||||
await ensureInitialized();
|
||||
|
||||
final oldDeviceId = (await getDevice()).id;
|
||||
|
||||
// Generate the new device
|
||||
final newDevice = await _omemoManager.regenerateDevice();
|
||||
|
||||
// Remove the old device
|
||||
unawaited(
|
||||
GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!
|
||||
.deleteDevice(oldDeviceId),
|
||||
);
|
||||
|
||||
return model.OmemoDevice(
|
||||
await newDevice.getFingerprint(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
newDevice.id,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
|
||||
void onNewConnection() {
|
||||
if (_initialized) {
|
||||
omemoManager.onNewConnection();
|
||||
}
|
||||
/// Adds a pseudo-message of type [type] to the chat with [conversationJid].
|
||||
/// Also sends an event to the UI.
|
||||
Future<void> addPseudoMessage(
|
||||
String conversationJid,
|
||||
PseudoMessageType type,
|
||||
int ratchetsAdded,
|
||||
int ratchetsReplaced,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final message = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
'',
|
||||
conversationJid,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
pseudoMessageType: type,
|
||||
pseudoMessageData: {
|
||||
'ratchetsAdded': ratchetsAdded,
|
||||
'ratchetsReplaced': ratchetsReplaced,
|
||||
},
|
||||
);
|
||||
sendEvent(
|
||||
MessageAddedEvent(
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
308
lib/service/omemo/persistence.dart
Normal file
308
lib/service/omemo/persistence.dart
Normal file
@@ -0,0 +1,308 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:sqflite_common/sql.dart';
|
||||
|
||||
extension ByteListHelpers on List<int> {
|
||||
String toBase64() {
|
||||
return base64Encode(this);
|
||||
}
|
||||
|
||||
OmemoPublicKey toPublicKey(KeyPairType type) {
|
||||
return OmemoPublicKey.fromBytes(this, type);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> commitDevice(OmemoDevice device) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final serializedOpks = <String, Map<String, String>>{};
|
||||
for (final entry in device.opks.entries) {
|
||||
serializedOpks[entry.key.toString()] = {
|
||||
'public': base64Encode(await entry.value.pk.getBytes()),
|
||||
'private': base64Encode(await entry.value.sk.getBytes()),
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(
|
||||
omemoDevicesTable,
|
||||
{
|
||||
'jid': device.jid,
|
||||
'id': device.id,
|
||||
'ikPub': base64Encode(await device.ik.pk.getBytes()),
|
||||
'ik': base64Encode(await device.ik.sk.getBytes()),
|
||||
'spkPub': base64Encode(await device.spk.pk.getBytes()),
|
||||
'spk': base64Encode(await device.spk.sk.getBytes()),
|
||||
'spkId': device.spkId,
|
||||
'spkSig': base64Encode(device.spkSignature),
|
||||
'oldSpkPub': (await device.oldSpk?.pk.getBytes())?.toBase64(),
|
||||
'oldSpk': (await device.oldSpk?.sk.getBytes())?.toBase64(),
|
||||
'oldSpkId': device.oldSpkId,
|
||||
'opks': jsonEncode(serializedOpks),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<OmemoDevice?> loadOmemoDevice(String jid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final rawDevice = await db.query(
|
||||
omemoDevicesTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
limit: 1,
|
||||
);
|
||||
if (rawDevice.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final deviceJson = rawDevice.first;
|
||||
|
||||
// Deserialize the OPKs first
|
||||
final deserializedOpks = <int, OmemoKeyPair>{};
|
||||
final opks =
|
||||
(jsonDecode(rawDevice.first['opks']! as String) as Map<dynamic, dynamic>)
|
||||
.cast<String, dynamic>();
|
||||
for (final opk in opks.entries) {
|
||||
final opkValue = (opk.value as Map<String, dynamic>).cast<String, String>();
|
||||
deserializedOpks[int.parse(opk.key)] = OmemoKeyPair.fromBytes(
|
||||
base64Decode(opkValue['public']!),
|
||||
base64Decode(opkValue['private']!),
|
||||
KeyPairType.x25519,
|
||||
);
|
||||
}
|
||||
|
||||
OmemoKeyPair? oldSpk;
|
||||
if (deviceJson['oldSpkPub'] != null && deviceJson['oldSpk'] != null) {
|
||||
oldSpk = OmemoKeyPair.fromBytes(
|
||||
base64Decode(deviceJson['oldSpkPub']! as String),
|
||||
base64Decode(deviceJson['oldSpk']! as String),
|
||||
KeyPairType.x25519,
|
||||
);
|
||||
}
|
||||
|
||||
return OmemoDevice(
|
||||
jid,
|
||||
deviceJson['id']! as int,
|
||||
OmemoKeyPair.fromBytes(
|
||||
base64Decode(deviceJson['ikPub']! as String),
|
||||
base64Decode(deviceJson['ik']! as String),
|
||||
KeyPairType.ed25519,
|
||||
),
|
||||
OmemoKeyPair.fromBytes(
|
||||
base64Decode(deviceJson['spkPub']! as String),
|
||||
base64Decode(deviceJson['spk']! as String),
|
||||
KeyPairType.x25519,
|
||||
),
|
||||
deviceJson['spkId']! as int,
|
||||
base64Decode(deviceJson['spkSig']! as String),
|
||||
oldSpk,
|
||||
deviceJson['oldSpkId'] as int?,
|
||||
deserializedOpks,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> commitRatchets(List<OmemoRatchetData> ratchets) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final batch = db.batch();
|
||||
for (final ratchet in ratchets) {
|
||||
// Serialize the skipped keys
|
||||
final serializedSkippedKeys = <Map<String, Object>>[];
|
||||
for (final sk in ratchet.ratchet.mkSkipped.entries) {
|
||||
serializedSkippedKeys.add({
|
||||
'dhPub': (await sk.key.dh.getBytes()).toBase64(),
|
||||
'n': sk.key.n,
|
||||
'mk': sk.value.toBase64(),
|
||||
});
|
||||
}
|
||||
|
||||
// Serialize the KEX
|
||||
final kex = {
|
||||
'pkId': ratchet.ratchet.kex.pkId,
|
||||
'spkId': ratchet.ratchet.kex.spkId,
|
||||
'ek': (await ratchet.ratchet.kex.ek.getBytes()).toBase64(),
|
||||
'ik': (await ratchet.ratchet.kex.ik.getBytes()).toBase64(),
|
||||
};
|
||||
|
||||
batch.insert(
|
||||
omemoRatchetsTable,
|
||||
{
|
||||
'jid': ratchet.jid,
|
||||
'device': ratchet.id,
|
||||
'dhsPub': base64Encode(await ratchet.ratchet.dhs.pk.getBytes()),
|
||||
'dhs': base64Encode(await ratchet.ratchet.dhs.sk.getBytes()),
|
||||
'dhrPub': (await ratchet.ratchet.dhr?.getBytes())?.toBase64(),
|
||||
'rk': base64Encode(ratchet.ratchet.rk),
|
||||
'cks': ratchet.ratchet.cks?.toBase64(),
|
||||
'ckr': ratchet.ratchet.ckr?.toBase64(),
|
||||
'ns': ratchet.ratchet.ns,
|
||||
'nr': ratchet.ratchet.nr,
|
||||
'pn': ratchet.ratchet.pn,
|
||||
'ik': (await ratchet.ratchet.ik.getBytes()).toBase64(),
|
||||
'ad': ratchet.ratchet.sessionAd.toBase64(),
|
||||
'skipped': jsonEncode(serializedSkippedKeys),
|
||||
'kex': jsonEncode(kex),
|
||||
'acked': boolToInt(ratchet.ratchet.acknowledged),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> commitDeviceList(String jid, List<int> devices) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
await db.insert(
|
||||
omemoDeviceListTable,
|
||||
{
|
||||
'jid': jid,
|
||||
'devices': jsonEncode(devices),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeRatchets(List<RatchetMapKey> ratchets) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final key in ratchets) {
|
||||
batch.delete(
|
||||
omemoRatchetsTable,
|
||||
where: 'jid = ? AND device = ?',
|
||||
whereArgs: [key.jid, key.deviceId],
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<OmemoDataPackage?> loadRatchets(String jid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final ratchetsRaw = await db.query(
|
||||
omemoRatchetsTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
final deviceListRaw = await db.query(
|
||||
omemoDeviceListTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
limit: 1,
|
||||
);
|
||||
if (ratchetsRaw.isEmpty || deviceListRaw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deserialize the ratchets
|
||||
final ratchets = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
for (final ratchetRaw in ratchetsRaw) {
|
||||
final key = RatchetMapKey(
|
||||
jid,
|
||||
ratchetRaw['device']! as int,
|
||||
);
|
||||
|
||||
// Deserialize skipped keys
|
||||
final mkSkipped = <SkippedKey, List<int>>{};
|
||||
final skippedKeysRaw =
|
||||
(jsonDecode(ratchetRaw['skipped']! as String) as List<dynamic>)
|
||||
.cast<Map<dynamic, dynamic>>();
|
||||
for (final skippedRaw in skippedKeysRaw) {
|
||||
final key = SkippedKey(
|
||||
(skippedRaw['dhPub']! as String)
|
||||
.fromBase64()
|
||||
.toPublicKey(KeyPairType.x25519),
|
||||
skippedRaw['n']! as int,
|
||||
);
|
||||
mkSkipped[key] = (skippedRaw['mk']! as String).fromBase64();
|
||||
}
|
||||
|
||||
// Deserialize the KEX
|
||||
final kexRaw =
|
||||
(jsonDecode(ratchetRaw['kex']! as String) as Map<dynamic, dynamic>)
|
||||
.cast<String, Object>();
|
||||
final kex = KeyExchangeData(
|
||||
kexRaw['pkId']! as int,
|
||||
kexRaw['spkId']! as int,
|
||||
(kexRaw['ek']! as String).fromBase64().toPublicKey(KeyPairType.x25519),
|
||||
(kexRaw['ik']! as String).fromBase64().toPublicKey(KeyPairType.ed25519),
|
||||
);
|
||||
|
||||
// Deserialize the entire ratchet
|
||||
ratchets[key] = OmemoDoubleRatchet(
|
||||
OmemoKeyPair.fromBytes(
|
||||
base64Decode(ratchetRaw['dhsPub']! as String),
|
||||
base64Decode(ratchetRaw['dhs']! as String),
|
||||
KeyPairType.x25519,
|
||||
),
|
||||
(ratchetRaw['dhrPub'] as String?)
|
||||
?.fromBase64()
|
||||
.toPublicKey(KeyPairType.x25519),
|
||||
base64Decode(ratchetRaw['rk']! as String),
|
||||
(ratchetRaw['cks'] as String?)?.fromBase64(),
|
||||
(ratchetRaw['ckr'] as String?)?.fromBase64(),
|
||||
ratchetRaw['ns']! as int,
|
||||
ratchetRaw['nr']! as int,
|
||||
ratchetRaw['pn']! as int,
|
||||
(ratchetRaw['ik']! as String)
|
||||
.fromBase64()
|
||||
.toPublicKey(KeyPairType.ed25519),
|
||||
(ratchetRaw['ad']! as String).fromBase64(),
|
||||
mkSkipped,
|
||||
intToBool(ratchetRaw['acked']! as int),
|
||||
kex,
|
||||
);
|
||||
}
|
||||
|
||||
return OmemoDataPackage(
|
||||
(jsonDecode(deviceListRaw.first['devices']! as String) as List<dynamic>)
|
||||
.cast<int>(),
|
||||
ratchets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> commitTrust(BTBVTrustData trust) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
await db.insert(
|
||||
omemoTrustTable,
|
||||
{
|
||||
'jid': trust.jid,
|
||||
'device': trust.device,
|
||||
'trust': trust.state.value,
|
||||
'enabled': boolToInt(trust.enabled),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<BTBVTrustData>> loadTrust(String jid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final rawTrust = await db.query(
|
||||
omemoTrustTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
|
||||
return rawTrust.map((trust) {
|
||||
return BTBVTrustData(
|
||||
jid,
|
||||
trust['device']! as int,
|
||||
BTBVTrustState.fromInt(trust['trust']! as int),
|
||||
intToBool(trust['enabled']! as int),
|
||||
false,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> removeTrust(String jid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
await db.delete(
|
||||
omemoTrustTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,37 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
|
||||
class PreferencesService {
|
||||
PreferencesState? _preferences;
|
||||
|
||||
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 {
|
||||
@@ -15,10 +40,44 @@ class PreferencesService {
|
||||
return _preferences!;
|
||||
}
|
||||
|
||||
Future<void> modifyPreferences(PreferencesState Function(PreferencesState) func) async {
|
||||
Future<void> modifyPreferences(
|
||||
PreferencesState Function(PreferencesState) func,
|
||||
) async {
|
||||
if (_preferences == null) await _loadPreferences();
|
||||
|
||||
_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));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user