Compare commits
568 Commits
6f1493808f
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 45a460a11f | |||
| 7655befcc3 | |||
| c96880d437 | |||
| 21c8079f49 | |||
| 175b2d5f2a | |||
|
|
38ef7efac9 | ||
|
|
aca226b764 | ||
| 2c5fbd8d5d | |||
| 17295320c3 | |||
| 37390eea97 | |||
| ab71754faf | |||
| 17396c3db2 | |||
| 742f8b6d8d | |||
| 161dd5cca8 | |||
| 4dfacc759b | |||
| da57b113f8 | |||
| 4a1d30e8c4 | |||
| 4e4a5c1b52 | |||
| 4e097d8286 | |||
| bfe2a0f58a | |||
|
|
13ece41a8f | ||
|
|
f35cb04359 | ||
|
|
08d11f3e9f | ||
| 63253e9cae | |||
| 26dafb4e9e | |||
| 111c66aa6a | |||
| 5b03fc9b47 | |||
| 672ae736d3 | |||
| e37db3d00c | |||
| 919ed6f0a1 | |||
| 79867e4eaa | |||
| 99600bafb0 | |||
| 59aad79aa0 | |||
| 6fc4672a6e | |||
| 478c639ae7 | |||
| 7f2c978736 | |||
| f472239102 | |||
| f2844122c0 | |||
| 7781b12dac | |||
| 1e795b8b10 | |||
| 7d0896d84f | |||
| fc0aade0ae | |||
| d969622675 | |||
| 23839b6ec6 | |||
| 0b120c1e9c | |||
| e84d8f9455 | |||
| c4973910f4 | |||
| 4ed57bc1d4 | |||
| e8125b661b | |||
| c6b7bba5ee | |||
| cf5ee6c703 | |||
| 63038b77bc | |||
| 7be67a9551 | |||
| 0dc44572b1 | |||
| 1de6bab86e | |||
| 0852e0efbf | |||
|
|
8fe11f30f2 | ||
| 8b0a863949 | |||
| bde689b1b3 | |||
| 2ff2402078 | |||
| cb47fa210a | |||
|
|
599e56875d | ||
|
|
74569ccfd6 | ||
| a009a68941 | |||
| 37455c54e6 | |||
| 16dded8dee | |||
| fa92ba1974 | |||
| 3011af4f0e | |||
| 8a98815fd7 | |||
| 018f40d6db | |||
| 1d7e55c086 | |||
| d57e133d0d | |||
| 191996c5d4 | |||
| bcef2dd818 | |||
| 0358bf15a0 | |||
| 301ff664a8 | |||
| 865846af9a | |||
| 96b2c5f3c4 | |||
| db5e4e3c1f | |||
| e3cd4aa3dd | |||
| fbca84ae2f | |||
| 6034ce5ba4 | |||
| 2e71ffebd4 | |||
| 3ca7d63e8c | |||
| ff2ca7397f | |||
| 843a1171b7 | |||
|
|
f0057e5487 | ||
| d3914e3269 | |||
| 431641e248 | |||
| e259b3ee63 | |||
| e34bbde27e | |||
| f84593ec19 | |||
| 49425440a5 | |||
| ee9332f6f2 | |||
| d06de37924 | |||
| 0872b2a134 | |||
| 95a6a458db | |||
| 112048df36 | |||
| 24a26fe454 | |||
| fe846468ee | |||
| be299ac90f | |||
|
|
549e61a168 | ||
| f7e7c17598 | |||
|
|
26bcaccd81 | ||
|
|
df5810a347 | ||
| 071ea6db5d | |||
| 01f46d4cdc | |||
| 2baf852a9a | |||
| 7f5d8c353a | |||
| f3a5683e13 | |||
| 4323561774 | |||
| 6225ac65d9 | |||
| 178648a5ee | |||
|
|
ef931c566f | ||
| 78dbf5473e | |||
| edcb95ac6f | |||
| 2fc23876e6 | |||
| 293908c40c | |||
|
|
a3d4883406 | ||
|
|
532f0b1bb2 | ||
|
|
a98fe0d9f3 | ||
| 7919d11b00 | |||
| ac5a2c9dd2 | |||
|
|
a7c3bd507f | ||
| 06d132b0b0 | |||
| 837787fdaf | |||
| 9545b3d9cc | |||
|
|
fba2cf86ae | ||
| d17a656d64 | |||
| a5082762b5 | |||
| 4160143f6e | |||
| 9fd6f847fe | |||
| f65f8ec541 | |||
|
|
af481bf465 | ||
|
|
e6ae8182c2 | ||
| 31d0c9c9ec | |||
| de3bf12c18 | |||
| 9d2f6b29f7 | |||
| ee764d2213 | |||
|
|
de7b9adfa6 | ||
| 8c53055341 | |||
| f0225eed4c | |||
| b57bae878f | |||
|
1a83ff37ff
|
|||
| e205785246 | |||
| 3edda978fb | |||
|
|
ebabb0e445 | ||
|
|
e4f98bb82f | ||
|
|
56d6f97168 | ||
|
|
b0067f055f | ||
| 117d263e25 | |||
| 63e66e5dce | |||
| 140a16ec0c | |||
| 44b95bbb5b | |||
| 4eeaa8c37b | |||
| c95f2efd65 | |||
| 9dbf4b5467 | |||
|
|
bd094dfc9a | ||
|
|
7e9d7d6281 | ||
|
|
2cf781459d | ||
|
|
4ff9e3c81e | ||
|
|
e337f1c579 | ||
|
|
7c840334e1 | ||
| 0a120f1073 | |||
|
|
269738e618 | ||
|
|
06eab1d6f5 | ||
|
|
008e816d70 | ||
|
|
2bbbc164b5 | ||
|
|
11f4fd9932 | ||
|
|
a1451c6fbf | ||
| 1e94910ebd | |||
|
|
993da40297 | ||
|
|
09684b1268 | ||
|
|
0abb89cf38 | ||
| 63d251a7f1 | |||
| 799af75bcc | |||
| 8966c490fe | |||
| 4c09dbab60 | |||
| 6fd5c33b0a | |||
|
|
7880f51b76 | ||
| 30e8a885bb | |||
| 42c695a2a1 | |||
|
|
f0a79ca0e0 | ||
| 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 | |||
|
|
06dcd5491b | ||
|
|
3641be4f56 | ||
|
|
18e28c3bbf | ||
|
|
62e39bf066 | ||
| 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 | |||
| 241a8b4d53 | |||
| 25d193e930 | |||
| e6924cc02d | |||
| 60985c6b37 | |||
| a015399b57 | |||
| 4b6c7998f3 | |||
| 26312e313f | |||
| b63b5d7fd2 | |||
| ca2943a94d | |||
| 32a4cd9361 | |||
| 2320e4ed17 | |||
| dee479a918 | |||
| 6895ef1e32 | |||
| 5c51eefa3e | |||
| 0d7ae321a7 | |||
| b4063a64e0 | |||
| 65154f2f5c | |||
| 19a22bd0d1 | |||
| a7da7baf5a | |||
| a344a94112 | |||
| f44861fead | |||
| 1c4a30ebb4 | |||
| 70e2ca3d3e | |||
| 0d4aee1625 | |||
| ad6aa33b7c | |||
| 284b5fa4df | |||
| b9aac0c3d7 | |||
| 6ce90e08ef | |||
| 5ac80d8d60 | |||
| 56e1fa52d8 | |||
| 3ae1b7d168 | |||
| d8f654c81c | |||
| cbcbd4d6dc | |||
| be899b5611 | |||
| 361bbe8d85 | |||
| 1e017af277 | |||
| c4c22a36bb | |||
| 84924b480b | |||
| 358074f4ee | |||
| 084314fbcf | |||
| c42f301ae0 | |||
| c8cd37e451 | |||
| 9f8f3a5407 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -60,3 +60,6 @@ lib/i18n/*.dart
|
||||
|
||||
# Android artifacts
|
||||
.android
|
||||
|
||||
# Build scripts
|
||||
release-*/
|
||||
|
||||
2
.gitlint
2
.gitlint
@@ -7,7 +7,7 @@ line-length=72
|
||||
[title-trailing-punctuation]
|
||||
[title-hard-tab]
|
||||
[title-match-regex]
|
||||
regex=^(feat|fix|chore|refactor|docs|release|test)\((xmpp|service|ui|shared|meta|tests|i18n)+(,(xmpp|service|ui|shared|meta|tests|i18n))*\): .*$
|
||||
regex=^((feat|fix|chore|refactor)\((service|ui|shared|all|tests|i18n|docs|flake|android|ios|linux|windows|macos)+(,(service|ui|shared|all|tests|i18n|docs|flake|android|ios|linux|windows|macos))*\)|release): [A-Z0-9].*$
|
||||
|
||||
|
||||
[body-trailing-whitespace]
|
||||
|
||||
108
CONTRIBUTING.md
Normal file
108
CONTRIBUTING.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# 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. Next, run `dart run pigeon --input pigeon/quirks.dart` to generate the communication
|
||||
channels with the native code.
|
||||
|
||||
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.
|
||||
|
||||
### Android
|
||||
|
||||
In case you modified the Android-native code, please also make sure that you checked every item on the following checklist:
|
||||
|
||||
- [ ] I checked that [ktlint](https://github.com/pinterest/ktlint) is not showing any linting issues (`ktlint android/app/src/main/kotlin/org/moxxy/moxxyv2/ '!android/app/src/main/kotlin/org/moxxy/moxxyv2/quirks'`)
|
||||
|
||||
### 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,12 @@ 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"
|
||||
- "pigeon/quirks.dart"
|
||||
|
||||
@@ -26,12 +26,11 @@ apply plugin: 'kotlin-android'
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
android {
|
||||
//compileSdkVersion flutter.compileSdkVersion
|
||||
compileSdkVersion 33
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
@@ -45,22 +44,22 @@ android {
|
||||
defaultConfig {
|
||||
applicationId "org.moxxy.moxxyv2"
|
||||
|
||||
// TODO: Remove once https://github.com/fluttercommunity/flutter_launcher_icons/pull/313 is merged
|
||||
minSdkVersion 23
|
||||
targetSdkVersion 31
|
||||
//minSdkVersion flutter.minSdkVersion
|
||||
//targetSdkVersion flutter.targetSdkVersion
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig signingConfigs.debug
|
||||
// Externally signed using a security key
|
||||
signingConfig null
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly rootProject.findProject(":moxxy_native")
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
@@ -69,4 +68,5 @@ flutter {
|
||||
|
||||
dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "androidx.activity:activity-ktx:1.7.2"
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="org.moxxy.moxxyv2">
|
||||
|
||||
<application
|
||||
android:label="Moxxy"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Moxxy">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
@@ -22,9 +23,10 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Allow receiving share intents for all kinds of things -->
|
||||
@@ -38,17 +40,30 @@
|
||||
<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 -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -1,6 +1,57 @@
|
||||
package org.moxxy.moxxyv2
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import org.moxxy.moxxyv2.quirks.MoxxyQuirkApi
|
||||
import org.moxxy.moxxyv2.quirks.QuirkNotificationEvent
|
||||
import org.moxxy.moxxyv2.quirks.QuirkNotificationEventType
|
||||
|
||||
class MainActivity: FlutterActivity() {
|
||||
class MainActivity : FlutterActivity(), MoxxyQuirkApi {
|
||||
private var lastEvent: QuirkNotificationEvent? = null
|
||||
|
||||
private fun handleIntent(intent: Intent?): Boolean {
|
||||
if (intent == null) return false
|
||||
|
||||
when (intent.action) {
|
||||
org.moxxy.moxxy_native.TAP_ACTION -> {
|
||||
Log.d("Moxxy", "Handling tap data")
|
||||
lastEvent = QuirkNotificationEvent(
|
||||
intent.getLongExtra(org.moxxy.moxxy_native.NOTIFICATION_EXTRA_ID_KEY, -1),
|
||||
intent.getStringExtra(org.moxxy.moxxy_native.NOTIFICATION_EXTRA_JID_KEY)!!,
|
||||
QuirkNotificationEventType.OPEN,
|
||||
null,
|
||||
org.moxxy.moxxy_native.notifications.extractPayloadMapFromIntent(intent),
|
||||
)
|
||||
return true
|
||||
}
|
||||
else -> {
|
||||
Log.d("Moxxy", "Unknown intent action: ${intent.action}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
MoxxyQuirkApi.setUp(flutterEngine.dartExecutor.binaryMessenger, this)
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
override fun earlyNotificationEventQuirk(): QuirkNotificationEvent? {
|
||||
val event = lastEvent
|
||||
lastEvent = null
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// Autogenerated from Pigeon (v11.0.1), do not edit directly.
|
||||
// See also: https://pub.dev/packages/pigeon
|
||||
|
||||
package org.moxxy.moxxyv2.quirks
|
||||
|
||||
import android.util.Log
|
||||
import io.flutter.plugin.common.BasicMessageChannel
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MessageCodec
|
||||
import io.flutter.plugin.common.StandardMessageCodec
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
private fun wrapResult(result: Any?): List<Any?> {
|
||||
return listOf(result)
|
||||
}
|
||||
|
||||
private fun wrapError(exception: Throwable): List<Any?> {
|
||||
if (exception is FlutterError) {
|
||||
return listOf(
|
||||
exception.code,
|
||||
exception.message,
|
||||
exception.details
|
||||
)
|
||||
} else {
|
||||
return listOf(
|
||||
exception.javaClass.simpleName,
|
||||
exception.toString(),
|
||||
"Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error class for passing custom error details to Flutter via a thrown PlatformException.
|
||||
* @property code The error code.
|
||||
* @property message The error message.
|
||||
* @property details The error details. Must be a datatype supported by the api codec.
|
||||
*/
|
||||
class FlutterError (
|
||||
val code: String,
|
||||
override val message: String? = null,
|
||||
val details: Any? = null
|
||||
) : Throwable()
|
||||
|
||||
enum class QuirkNotificationEventType(val raw: Int) {
|
||||
MARKASREAD(0),
|
||||
REPLY(1),
|
||||
OPEN(2);
|
||||
|
||||
companion object {
|
||||
fun ofRaw(raw: Int): QuirkNotificationEventType? {
|
||||
return values().firstOrNull { it.raw == raw }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated class from Pigeon that represents data sent in messages. */
|
||||
data class QuirkNotificationEvent (
|
||||
/** The notification id. */
|
||||
val id: Long,
|
||||
/** The JID the notification was for. */
|
||||
val jid: String,
|
||||
/** The type of event. */
|
||||
val type: QuirkNotificationEventType,
|
||||
/**
|
||||
* An optional payload.
|
||||
* - type == NotificationType.reply: The reply message text.
|
||||
* Otherwise: undefined.
|
||||
*/
|
||||
val payload: String? = null,
|
||||
/** Extra data. Only set when type == NotificationType.reply. */
|
||||
val extra: Map<String?, String?>? = null
|
||||
|
||||
) {
|
||||
companion object {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun fromList(list: List<Any?>): QuirkNotificationEvent {
|
||||
val id = list[0].let { if (it is Int) it.toLong() else it as Long }
|
||||
val jid = list[1] as String
|
||||
val type = QuirkNotificationEventType.ofRaw(list[2] as Int)!!
|
||||
val payload = list[3] as String?
|
||||
val extra = list[4] as Map<String?, String?>?
|
||||
return QuirkNotificationEvent(id, jid, type, payload, extra)
|
||||
}
|
||||
}
|
||||
fun toList(): List<Any?> {
|
||||
return listOf<Any?>(
|
||||
id,
|
||||
jid,
|
||||
type.raw,
|
||||
payload,
|
||||
extra,
|
||||
)
|
||||
}
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private object MoxxyQuirkApiCodec : StandardMessageCodec() {
|
||||
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
|
||||
return when (type) {
|
||||
128.toByte() -> {
|
||||
return (readValue(buffer) as? List<Any?>)?.let {
|
||||
QuirkNotificationEvent.fromList(it)
|
||||
}
|
||||
}
|
||||
else -> super.readValueOfType(type, buffer)
|
||||
}
|
||||
}
|
||||
override fun writeValue(stream: ByteArrayOutputStream, value: Any?) {
|
||||
when (value) {
|
||||
is QuirkNotificationEvent -> {
|
||||
stream.write(128)
|
||||
writeValue(stream, value.toList())
|
||||
}
|
||||
else -> super.writeValue(stream, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
|
||||
interface MoxxyQuirkApi {
|
||||
fun earlyNotificationEventQuirk(): QuirkNotificationEvent?
|
||||
|
||||
companion object {
|
||||
/** The codec used by MoxxyQuirkApi. */
|
||||
val codec: MessageCodec<Any?> by lazy {
|
||||
MoxxyQuirkApiCodec
|
||||
}
|
||||
/** Sets up an instance of `MoxxyQuirkApi` to handle messages through the `binaryMessenger`. */
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun setUp(binaryMessenger: BinaryMessenger, api: MoxxyQuirkApi?) {
|
||||
run {
|
||||
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.moxxyv2.MoxxyQuirkApi.earlyNotificationEventQuirk", codec)
|
||||
if (api != null) {
|
||||
channel.setMessageHandler { _, reply ->
|
||||
var wrapped: List<Any?>
|
||||
try {
|
||||
wrapped = listOf<Any?>(api.earlyNotificationEventQuirk())
|
||||
} catch (exception: Throwable) {
|
||||
wrapped = wrapError(exception)
|
||||
}
|
||||
reply.reply(wrapped)
|
||||
}
|
||||
} else {
|
||||
channel.setMessageHandler(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,336 +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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"about": {
|
||||
"title": "About",
|
||||
"licensed": "Licensed under GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "View source code"
|
||||
},
|
||||
"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": {
|
||||
@@ -59,16 +62,22 @@
|
||||
"file": "Datei",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "Die Nachricht wurde zurückgezogen",
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht"
|
||||
"retractedFallback": "Eine vorherige Nachricht wurde zurückgezogen. Dein Client unterstüzt dies jedoch nicht",
|
||||
"you": "Du"
|
||||
},
|
||||
"errors": {
|
||||
"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",
|
||||
@@ -76,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",
|
||||
@@ -100,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": {
|
||||
@@ -113,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"
|
||||
},
|
||||
@@ -122,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",
|
||||
@@ -149,26 +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"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Neuen Kontakt hinzufügen",
|
||||
"replacedDeviceMessage": {
|
||||
"one": "Ein Gerät hat sich verändert",
|
||||
"other": "Mehrere Geräte haben sich verändert"
|
||||
},
|
||||
"messageHint": "Nachricht senden...",
|
||||
"sendImages": "Bilder senden",
|
||||
"sendFiles": "Dateien senden",
|
||||
"takePhotos": "Bilder aufnehmen"
|
||||
},
|
||||
"startchat": {
|
||||
"title": "Neuer Chat",
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"subtitle": "Du kannst einen 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": {
|
||||
@@ -181,7 +213,9 @@
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Sicherheit"
|
||||
"omemo": "Sicherheit",
|
||||
"profile": "Profil",
|
||||
"media": "Medien"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Benachrichtigungen",
|
||||
@@ -203,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": {
|
||||
@@ -234,18 +269,22 @@
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"accountSection": "Account",
|
||||
"accountSection": "Konto",
|
||||
"signOut": "Abmelden",
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
"miscellaneousSection": "Unterschiedlich",
|
||||
"debuggingSection": "Debugging"
|
||||
"debuggingSection": "Debugging",
|
||||
"general": "Generell"
|
||||
},
|
||||
"about": {
|
||||
"title": "Über",
|
||||
"licensed": "Lizensiert unter GPL3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "Quellcode anschauen"
|
||||
"viewSourceCode": "Quellcode anschauen",
|
||||
"nMoreToGo": "Noch ${n}...",
|
||||
"debugMenuShown": "Du bist jetzt ein Entwickler!",
|
||||
"debugMenuAlreadyShown": "Du bist bereits ein Entwickler!"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aussehen",
|
||||
@@ -291,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": {
|
||||
@@ -301,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",
|
||||
@@ -329,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
443
assets/i18n/strings_en.i18n.json
Normal file
443
assets/i18n/strings_en.i18n.json
Normal file
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"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",
|
||||
"serviceChannelName": "Foreground Service",
|
||||
"serviceChannelDescription": "Holds the persistent foreground service notification"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Error"
|
||||
},
|
||||
"warnings": {
|
||||
"blockingError": {
|
||||
"title": "Error while blocking user",
|
||||
"body": "Could not block user ${jid} because your server does not support blocking."
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"messageError": {
|
||||
"title": "Message delivery failure",
|
||||
"body": "Failed to deliver message to ${conversationTitle}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"requests": {
|
||||
"notification": {
|
||||
"reason": "In order to notify of incoming messages, Moxxy needs permission to show notifications."
|
||||
},
|
||||
"batterySaving": {
|
||||
"reason": "In order to receive messages in the background, Moxxy should be excempt from Android's battery saving."
|
||||
}
|
||||
},
|
||||
"allow": "Allow",
|
||||
"skip": "Skip"
|
||||
},
|
||||
"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",
|
||||
"joinGroupChat": "Join groupchat",
|
||||
"nullNickname": "Nickname cannot be null!",
|
||||
"nick": "Nickname",
|
||||
"enterNickname": "Enter Nickname",
|
||||
"nicknameSubtitle": "You need to enter a unique nickname in order to join a MUC."
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
435
assets/i18n/strings_fr.i18n.json
Normal file
435
assets/i18n/strings_fr.i18n.json
Normal file
@@ -0,0 +1,435 @@
|
||||
{
|
||||
"language": "Français",
|
||||
"global": {
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"title": "Moxxy",
|
||||
"dialogAccept": "OK",
|
||||
"dialogCancel": "Annuler",
|
||||
"moxxySubtitle": "Une expérience de construire un facile, moderne et belle client XMPP."
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"ready": "Prêt pour recevoir des messages",
|
||||
"disconnect": "Déconnecté",
|
||||
"error": "Erreur",
|
||||
"connecting": "Connexion…",
|
||||
"idle": "Inactif"
|
||||
},
|
||||
"message": {
|
||||
"markAsRead": "Marquer comme lu",
|
||||
"reply": "Répondre"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Messages",
|
||||
"warningChannelName": "Alertes",
|
||||
"warningChannelDescription": "Alertes liées à Moxxy",
|
||||
"messagesChannelDescription": "La voie des notifications pour des messages reçu"
|
||||
},
|
||||
"errors": {
|
||||
"messageError": {
|
||||
"body": "N'est pas parvenu à livrée la message au ${conversationTitle}",
|
||||
"title": "Échec au livraison du message"
|
||||
}
|
||||
},
|
||||
"titles": {
|
||||
"error": "Erreur"
|
||||
}
|
||||
},
|
||||
"messages": {
|
||||
"file": "Fichier",
|
||||
"image": "Image",
|
||||
"video": "Vidéo",
|
||||
"audio": "Audio",
|
||||
"sticker": "Autocollant",
|
||||
"retracted": "La message a été retirée",
|
||||
"you": "Toi",
|
||||
"retractedFallback": "Un précédent message a été retiré, mais votre client ne le prend pas en charge"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": "Autoriser",
|
||||
"skip": "Sauter",
|
||||
"requests": {
|
||||
"notification": {
|
||||
"reason": "Pour notifier des nouvelles messages, Moxxy à besoin de permission pour afficher les notifications."
|
||||
},
|
||||
"batterySaving": {
|
||||
"reason": "Pour recevoir des messages dans l'arrière-plan, Moxxy devrait être exempté de la fonction d'économie de la batterie d'Android."
|
||||
}
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "A l'instant",
|
||||
"nMinutesAgo": "Il y a ${min}min",
|
||||
"mondayAbbrev": "Lun",
|
||||
"tuesdayAbbrev": "Mar",
|
||||
"wednessdayAbbrev": "Mer",
|
||||
"thursdayAbbrev": "Jeu",
|
||||
"fridayAbbrev": "Ven",
|
||||
"saturdayAbbrev": "Sam",
|
||||
"sundayAbbrev": "Dim",
|
||||
"january": "Janvier",
|
||||
"february": "Février",
|
||||
"march": "Mars",
|
||||
"april": "Avril",
|
||||
"may": "Mai",
|
||||
"june": "Juin",
|
||||
"july": "Juillet",
|
||||
"august": "Août",
|
||||
"september": "Septembre",
|
||||
"october": "Octobre",
|
||||
"november": "Novembre",
|
||||
"december": "Décembre",
|
||||
"today": "Aujourd’hui",
|
||||
"yesterday": "Hier"
|
||||
},
|
||||
"errors": {
|
||||
"general": {
|
||||
"noInternet": "N'est pas connecté au l'Internet."
|
||||
},
|
||||
"omemo": {
|
||||
"notEncryptedForDevice": "Cet message n'était pas chiffré pour cette appareil",
|
||||
"invalidHmac": "Ne pouvait pas déchiffrer la message",
|
||||
"noDecryptionKey": "Aucune clé de déchiffrer disponible",
|
||||
"messageInvalidAfixElement": "Message chiffré invalide",
|
||||
"verificationWrongJid": "Mauvais adresse XMPP",
|
||||
"verificationWrongDevice": "Mauvais appareil OMEMO:2",
|
||||
"verificationNotInList": "Mauvais appareil OMEMO:2",
|
||||
"verificationWrongFingerprint": "Empreinte OMEMO:2 invalide",
|
||||
"verificationInvalidOmemoUrl": "Empreinte OMEMO:2 invalide",
|
||||
"couldNotPublish": "Ne pouvait pas publier l'identité cryptographique au la serveur. Cela signifie que chiffrement de bout en bout risque de ne pas fonctionner."
|
||||
},
|
||||
"filePicker": {
|
||||
"permissionDenied": "L'autorisation de permission de storage a été déclinée."
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Identifiants non-valides",
|
||||
"startTlsFailed": "Impossible d'établir une connexion sécurisée",
|
||||
"unspecified": "Erreur non-connue",
|
||||
"noConnection": "Impossible d'établir une connexion"
|
||||
},
|
||||
"message": {
|
||||
"unspecified": "Erreur inconnue",
|
||||
"fileUploadFailed": "Le téléversement du fichier a échoué",
|
||||
"contactDoesntSupportOmemo": "Le chiffrement OMEMO:2 n'est pas pris en charge par l'autre personne",
|
||||
"serviceUnavailable": "Le message n'a pas pu être délivré",
|
||||
"failedToEncrypt": "Le message n'a pas pu être chiffré",
|
||||
"failedToEncryptFile": "Le fichier n'a pas pu être chiffré",
|
||||
"failedToDecryptFile": "Le fichier n'a pas pu être déchiffré",
|
||||
"fileNotEncrypted": "La conversation est chiffrée, mais pas le fichier",
|
||||
"fileDownloadFailed": "Le téléchargement du fichier a échoué",
|
||||
"remoteServerTimeout": "Le message n'a pas pu être délivré au serveur de destination",
|
||||
"remoteServerNotFound": "Serveur introuvable ; Le message n'a pas pu être délivré"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Impossible de joindre le serveur",
|
||||
"saslAccountDisabled": "Votre compte est désactivé",
|
||||
"saslInvalidCredentials": "Vos identifiants ne sont pas valides",
|
||||
"unrecoverable": "Connexion perdue due à une erreur"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Erreur d'enregistrement audio",
|
||||
"openFileNoAppError": "Pas d'application pour ce type de fichier",
|
||||
"messageErrorDialogTitle": "Erreur",
|
||||
"openFileGenericError": "Impossible d'ouvrir le fichier"
|
||||
},
|
||||
"newChat": {
|
||||
"remoteServerError": "Impossible de contacter le serveur distant.",
|
||||
"groupchatUnsupported": "Les conversations de groupes ne sont pas encore prises en charge.",
|
||||
"unknown": "Erreur inconnue."
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"settings": {
|
||||
"stickers": {
|
||||
"title": "Autocollants",
|
||||
"stickerSection": "Autocollant",
|
||||
"stickerPacksSection": "Paquets d'autocollants",
|
||||
"stickerPackSize": "(${size})",
|
||||
"displayStickers": "Afficher les autocollants dans les conversations",
|
||||
"autoDownload": "Téléchargement automatique des autocollants",
|
||||
"importStickerPack": "Importer série d'autocollants",
|
||||
"importSuccess": "Série d'autocollants importée avec succès",
|
||||
"importFailure": "Erreur lors de l'import de la série d'autocollants",
|
||||
"autoDownloadBody": "Si activée, les autocollants seront téléchargés automatiquement avec les personnes de votre liste de contact."
|
||||
},
|
||||
"stickerPacks": {
|
||||
"title": "Paquets d'autocollants"
|
||||
},
|
||||
"storage": {
|
||||
"types": {
|
||||
"stickers": "Autocollants",
|
||||
"media": "Médias"
|
||||
},
|
||||
"title": "Stockage",
|
||||
"storageUsed": "Espace utilisé : ${size}",
|
||||
"storageManagement": "Gestion de stockage",
|
||||
"removeOldMedia": {
|
||||
"title": "Supprimer les anciens médias",
|
||||
"description": "Supprimer les anciens fichiers médias de votre appareil"
|
||||
},
|
||||
"removeOldMediaDialog": {
|
||||
"title": "Supprimer les fichiers médias",
|
||||
"options": {
|
||||
"oneWeek": "Plus anciens qu'une semaine",
|
||||
"oneMonth": "Plus anciens qu'un mois",
|
||||
"all": "Tous les fichiers médias"
|
||||
},
|
||||
"delete": "Supprimer",
|
||||
"confirmation": {
|
||||
"body": "Confirmer la suppresion des anciens fichiers médias ?"
|
||||
}
|
||||
},
|
||||
"viewMediaFiles": "Voir les fichiers médias",
|
||||
"mediaFiles": "Fichiers médias",
|
||||
"manageStickers": "Gestion des autocollants",
|
||||
"sizePlaceholder": "Calcul…"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"conversationsSection": "Conversations",
|
||||
"accountSection": "Compte",
|
||||
"signOut": "Déconnexion",
|
||||
"signOutConfirmBody": "Confirmer la déconnexion ?",
|
||||
"miscellaneousSection": "Autres",
|
||||
"debuggingSection": "Développement",
|
||||
"general": "Général",
|
||||
"signOutConfirmTitle": "Déconnexion"
|
||||
},
|
||||
"about": {
|
||||
"title": "À propos",
|
||||
"licensed": "Sous licence GPLv3",
|
||||
"version": "Version ${version}",
|
||||
"viewSourceCode": "Voir le code source",
|
||||
"debugMenuShown": "Mode développement disponible !",
|
||||
"debugMenuAlreadyShown": "Le mode développement est déjà disponible !",
|
||||
"nMoreToGo": "Plus que ${n}…"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence",
|
||||
"languageSection": "Langue",
|
||||
"language": "Langue de l'application",
|
||||
"systemLanguage": "Langue par défaut",
|
||||
"languageSubtext": "Langue actuelle : $selectedLanguage"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Licences Open-Source",
|
||||
"licensedUnder": "Sous licence $license"
|
||||
},
|
||||
"conversation": {
|
||||
"title": "Conversation",
|
||||
"appearance": "Apparence",
|
||||
"selectBackgroundImage": "Sélectionner l'image d'arrière-plan",
|
||||
"removeBackgroundImage": "Supprimer l'image d'arrière-plan",
|
||||
"removeBackgroundImageConfirmTitle": "Suppression de l'image d'arrière-plan",
|
||||
"removeBackgroundImageConfirmBody": "Confirmer la suppression de l'image d'arrière-plan de conversation ?",
|
||||
"newChatsSection": "Nouvelles conversations",
|
||||
"newChatsMuteByDefault": "Mettre les nouvelles conversation en sourdine par défaut",
|
||||
"behaviourSection": "Comportement",
|
||||
"contactsIntegration": "Intégration de contacts",
|
||||
"selectBackgroundImageDescription": "Cette image sera en arrière-plan de toutes vos conversations",
|
||||
"newChatsE2EE": "Activer le chiffrement de bout en bout par défaut. ATTENTION : Expérimental",
|
||||
"contactsIntegrationBody": "Lorsque activé, les données de votre appareil fourniront les titres de conversation, et images de profils. Aucune donnée n'est envoyée au serveur."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Option de développement",
|
||||
"generalSection": "Général",
|
||||
"generalEnableDebugging": "Activer le mode développement",
|
||||
"generalEncryptionPassword": "Mot de passe de chiffrement",
|
||||
"generalEncryptionPasswordSubtext": "Les journaux contiennent des informations sensibles, choisissez un mot de passe fort",
|
||||
"generalLoggingIp": "IP de journaux",
|
||||
"generalLoggingIpSubtext": "L'IP à laquelle envoyer les journaux",
|
||||
"generalLoggingPort": "Port de journaux",
|
||||
"generalLoggingPortSubtext": "L'IP où envoyer les journaux"
|
||||
},
|
||||
"network": {
|
||||
"title": "Réseau",
|
||||
"automaticDownloadsSection": "Téléchargements automatiques",
|
||||
"automaticDownloadsText": "Moxxy téléchargera automatiquement les fichiers sur…",
|
||||
"automaticDownloadsMaximumSize": "Taille maximale de téléchargement",
|
||||
"automaticDownloadAlways": "Toujours",
|
||||
"wifi": "WiFi",
|
||||
"mobileData": "Données mobiles",
|
||||
"automaticDownloadsMaximumSizeSubtext": "La taille maximale des fichiers à télécharger automatiquement"
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Vie privée",
|
||||
"generalSection": "Général",
|
||||
"showContactRequests": "Afficher les requêtes de contact",
|
||||
"showContactRequestsSubtext": "Cela affiche les personnes qui vous ont ajouté à leurs listes de contacts mais ne vous ont pas encore envoyé de message",
|
||||
"profilePictureVisibility": "Rendre publique l'image de profil",
|
||||
"profilePictureVisibilitSubtext": "Lorsque activée, tout le monde peut voir votre image de profil. Désactivée, seulement vos contacts peuvent la voir.",
|
||||
"conversationsSection": "Conversation",
|
||||
"sendChatMarkers": "Envoyer des marqueurs de conversation",
|
||||
"sendChatStates": "Envoyer l'état de conversation",
|
||||
"sendChatStatesSubtext": "Informe votre destinataire si vous être en train d'écrire, ou lire la conversation",
|
||||
"sendChatMarkersSubtext": "Informe votre destinataire lorsqu'un message est reçu, ou lu",
|
||||
"redirectsSection": "Redirections",
|
||||
"redirectText": "Redirige les liens $serviceName vers un service de proxy, comme $exampleProxy",
|
||||
"currentlySelected": "Service sélectionné : $proxy",
|
||||
"redirectsTitle": "$serviceName Redirection",
|
||||
"cannotEnableRedirect": "Impossible d'activer les redirections $serviceName",
|
||||
"urlEmpty": "URL vide",
|
||||
"urlInvalid": "URL non-valide",
|
||||
"redirectDialogTitle": "$serviceName Redirect",
|
||||
"stickersPrivacy": "Rendre publique la liste d'autoocollants",
|
||||
"stickersPrivacySubtext": "Si activée, tout le monde pourra voir vos autocollants installés.",
|
||||
"cannotEnableRedirectSubtext": "Service de redirection manquant, appuyer sur le champ à côté de l'interrupteur"
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"stickerSettings": "Paramètres des autocollants",
|
||||
"unencrypted": "Non-chiffré",
|
||||
"encrypted": "Chiffré",
|
||||
"closeChat": "Fermer la conversation",
|
||||
"closeChatConfirmTitle": "Fermeture de conversation",
|
||||
"blockShort": "Bloquer",
|
||||
"blockUser": "Bloquer cette personne",
|
||||
"online": "En ligne",
|
||||
"retract": "Supprimer le message",
|
||||
"forward": "Transférer",
|
||||
"edit": "Modifier",
|
||||
"quote": "Citer",
|
||||
"copy": "Copier le contenu",
|
||||
"messageCopied": "Message copié vers le presse-papier",
|
||||
"addReaction": "Ajouter une réaction",
|
||||
"showError": "Afficher l'erreur",
|
||||
"showWarning": "Afficher l'avertissement",
|
||||
"warning": "Avertissement",
|
||||
"addToContacts": "Ajouter aux contacts",
|
||||
"addToContactsBody": "Confirmer l'ajout de ${jid} aux contacts ?",
|
||||
"stickerPickerNoStickersLine1": "Pas d'autocollants installés.",
|
||||
"newDeviceMessage": {
|
||||
"one": "Un nouvel appareil a été ajouté",
|
||||
"other": "De nouveaux appareils ont été ajoutés"
|
||||
},
|
||||
"replacedDeviceMessage": {
|
||||
"one": "Un appareil a été modifié",
|
||||
"other": "Plusieurs appareils ont été ajoutés"
|
||||
},
|
||||
"sendImages": "Envoyer des images",
|
||||
"sendFiles": "Envoyer des fichiers",
|
||||
"takePhotos": "Prendre des photos",
|
||||
"messageHint": "Envoyer un message…",
|
||||
"closeChatConfirmSubtext": "Confirmer la fermeture de cette conversation ?",
|
||||
"retractBody": "Confirmer la demande de suppression du message ? Cela n'est qu'une demande que le client n'a pas a honorer.",
|
||||
"addToContactsTitle": "Ajouter ${jid} aux contacts",
|
||||
"stickerPickerNoStickersLine2": "Des autocollants sont disponibles à l'installation dans les paramètres."
|
||||
},
|
||||
"intro": {
|
||||
"noAccount": "Pas de compte XMPP ? Aucun soucis, en créer un est très simple.",
|
||||
"loginButton": "Connexion",
|
||||
"registerButton": "Inscription"
|
||||
},
|
||||
"login": {
|
||||
"title": "Connexion",
|
||||
"xmppAddress": "Adresse XMPP",
|
||||
"password": "Mot de passe",
|
||||
"advancedOptions": "Paramètres avancés",
|
||||
"createAccount": "Créer un compte sur le serveur"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Nouvelle conversation",
|
||||
"speeddialJoinGroupchat": "Rejoindre une conversation de groupe",
|
||||
"speeddialAddNoteToSelf": "Note personnelle",
|
||||
"overlaySettings": "Paramètres",
|
||||
"noOpenChats": "Aucune conversation d'ouverte",
|
||||
"startChat": "Démarrer une conversation",
|
||||
"closeChatBody": "Souhaitez-vous fermer la conversation avec ${conversationTitle} ?",
|
||||
"markAsRead": "Marquer comme lue",
|
||||
"closeChat": "Fermer une conversation"
|
||||
},
|
||||
"startchat": {
|
||||
"title": "Nouvelle conversation",
|
||||
"xmppAddress": "Adresse XMPP",
|
||||
"buttonAddToContact": "Démarrer une nouvelle conversation",
|
||||
"subtitle": "Vous pouvez démarrer une conversation à l'aide d'une adresse XMPP ou en scannant un code QR."
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Nouvelle conversation",
|
||||
"startChat": "Démarrer une nouvelle conversation",
|
||||
"createGroupchat": "Nouvelle conversation de groupe",
|
||||
"joinGroupChat": "Rejoindre une conversation de groupe",
|
||||
"nullNickname": "Le pseudonyme ne peut pas être vide !",
|
||||
"nick": "Pseudonyme",
|
||||
"enterNickname": "Entrer un pseudonyme",
|
||||
"nicknameSubtitle": "Un pseudonyme unique est requis pour rejoindre une CUM."
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Utiliser comme image de profil"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Partager avec…",
|
||||
"confirmTitle": "Envoyer le fichier",
|
||||
"confirmBody": "Une ou plusieurs conversations ne sont pas chiffrées. Le fichier sera accessible au serveur. Confirmer ?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Sécurité",
|
||||
"profile": "Profil",
|
||||
"media": "Média"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Notifications",
|
||||
"notificationsMuted": "En sourdine",
|
||||
"notificationsEnabled": "Activées",
|
||||
"sharedMedia": "Média"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Appareils disponibles",
|
||||
"otherDevices": "Autres appareils",
|
||||
"thisDevice": "Cet appareil",
|
||||
"deleteDeviceConfirmTitle": "Supprimer appareil",
|
||||
"recreateOwnSessions": "Recréer sessions",
|
||||
"recreateOwnSessionsConfirmTitle": "Recréer ses propres sessions ?",
|
||||
"recreateOwnDevice": "Recréer appareil",
|
||||
"recreateOwnDeviceConfirmTitle": "Récréer cet appareil ?",
|
||||
"deleteDeviceConfirmBody": "Vos contact ne pourront pas chiffrer pour cet appareil, continuer ?",
|
||||
"recreateOwnSessionsConfirmBody": "Cela recréera les sessions cryptographiques de vos appareils. À n'utiliser qu'en cas d'erreur de déchiffrement.",
|
||||
"recreateOwnDeviceConfirmBody": "Cela récréera l'identité cryptographique de cet appareil. Cette opération peut prendre du temps. Les contact ayant vérifié cet appareils auront à le faire de nouveau, continuer ?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Sécurité",
|
||||
"recreateSessions": "Recréer sessions",
|
||||
"recreateSessionsConfirmTitle": "Recréer les sessions ?",
|
||||
"noSessions": "Il n'y a pas de sessions cryptographiques utilisées pour du chiffrement de bout en bout.",
|
||||
"recreateSessionsConfirmBody": "Cela recréera les sessions cryptographiques de vos appareils. À n'utiliser qu'en cas d'erreur de déchiffrement sur ceux-ci."
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Liste de blocage",
|
||||
"noUsersBlocked": "Aucune personne bloquée",
|
||||
"unblockAll": "Tout débloquer",
|
||||
"unblockAllConfirmTitle": "Confirmer ?",
|
||||
"unblockAllConfirmBody": "Confirmer le débloquage de toutes les personnes ?",
|
||||
"unblockJidConfirmTitle": "Débloquer ${jid} ?",
|
||||
"unblockJidConfirmBody": "Confirmer le débloquage de ${jid} ? Vous recevrez de nouveau les messages de sa part."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Flouter l'arrière-plan",
|
||||
"setAsBackground": "Utiliser comme arrière-plan"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Supprimer autocollant",
|
||||
"removeConfirmBody": "Confirmer la suppression de cette série d'autocollants ?",
|
||||
"installConfirmTitle": "Installer autocollant",
|
||||
"restricted": "Cette série est limitée. Les autocollants s'afficheront mais ne pourront être envoyés.",
|
||||
"fetchingFailure": "Autocollants introuvables",
|
||||
"installConfirmBody": "Confirmer l'installation de cette série d'autocollants ?"
|
||||
},
|
||||
"sharedMedia": {
|
||||
"empty": {
|
||||
"chat": "Pas de média partagé dans cette conversation",
|
||||
"general": "Pas de fichier média disponible"
|
||||
}
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"conversation": {
|
||||
"holdForLonger": "Maintenez le bouton pressé pour enregistrer un message vocal"
|
||||
},
|
||||
"message": {
|
||||
"integrityCheckFailed": "Impossible de vérifier l'intégrité du fichier"
|
||||
}
|
||||
}
|
||||
}
|
||||
443
assets/i18n/strings_gl.i18n.json
Normal file
443
assets/i18n/strings_gl.i18n.json
Normal file
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"dateTime": {
|
||||
"justNow": "Neste momento",
|
||||
"nMinutesAgo": "fai ${min}min",
|
||||
"mondayAbbrev": "Lun",
|
||||
"tuesdayAbbrev": "Mar",
|
||||
"wednessdayAbbrev": "Mér",
|
||||
"thursdayAbbrev": "Xov",
|
||||
"fridayAbbrev": "Ven",
|
||||
"saturdayAbbrev": "Sáb",
|
||||
"sundayAbbrev": "Dom",
|
||||
"january": "Xaneiro",
|
||||
"february": "Febreiro",
|
||||
"march": "Marzo",
|
||||
"april": "Abril",
|
||||
"may": "Maio",
|
||||
"june": "Xuño",
|
||||
"july": "Xullo",
|
||||
"august": "Agosto",
|
||||
"september": "Setembro",
|
||||
"october": "Outubro",
|
||||
"november": "Novembro",
|
||||
"december": "Decembro",
|
||||
"today": "Hoxe",
|
||||
"yesterday": "Onte"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Imaxe",
|
||||
"video": "Vídeo",
|
||||
"audio": "Audio",
|
||||
"file": "Ficheiro",
|
||||
"sticker": "Adhesivo",
|
||||
"retracted": "A mensaxe foi editada",
|
||||
"you": "Ti",
|
||||
"retractedFallback": "Unha mensaxe anterior foi editada pero o teu cliente non soporta esa función"
|
||||
},
|
||||
"errors": {
|
||||
"general": {
|
||||
"noInternet": "Sen conexión a Internet."
|
||||
},
|
||||
"filePicker": {
|
||||
"permissionDenied": "Non se concedeu permiso de acceso á almacenaxe."
|
||||
},
|
||||
"omemo": {
|
||||
"couldNotPublish": "Non se puido publicar a identidade criptográfica no servidor. Debido a isto a cifraxe de extremo-a-extremo podería non funcionar.",
|
||||
"invalidHmac": "Non se descifrou a mensaxe",
|
||||
"noDecryptionKey": "Non se dispón de chave de descifrado",
|
||||
"messageInvalidAfixElement": "Mensaxe cifrada non válida",
|
||||
"verificationInvalidOmemoUrl": "Impresión dixital OMEMO:2 non válida",
|
||||
"verificationWrongJid": "Enderezo XMPP incorrecto",
|
||||
"verificationWrongDevice": "Dispositivo OMEMO:2 incorrecto",
|
||||
"verificationNotInList": "Dispositivo OMEMO:2 incorrecto",
|
||||
"verificationWrongFingerprint": "Impresión dixital OMEMO:2 incorrecta",
|
||||
"notEncryptedForDevice": "Non se cifrou a mensaxe para este dispositivo"
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Non hai conexión co servidor",
|
||||
"saslAccountDisabled": "A conta está desactivada",
|
||||
"saslInvalidCredentials": "As credenciais da conta non son válidas",
|
||||
"unrecoverable": "Perdeuse a conexión debido a un erro irremediable"
|
||||
},
|
||||
"login": {
|
||||
"saslFailed": "Credenciais de acceso incorrectas",
|
||||
"startTlsFailed": "Non se puido establecer unha conexión segura",
|
||||
"noConnection": "Fallou o establecemento da conexión",
|
||||
"unspecified": "Erro inconcreto"
|
||||
},
|
||||
"message": {
|
||||
"unspecified": "Erro descoñecido",
|
||||
"fileUploadFailed": "Fallou a subida do ficheiro",
|
||||
"contactDoesntSupportOmemo": "O contacto non ten soporte para a cifraxe usando OMEMO:2",
|
||||
"fileDownloadFailed": "Fallou a descarga do ficheiro",
|
||||
"serviceUnavailable": "Non se puido entregar a mensaxe ao contacto",
|
||||
"remoteServerTimeout": "A mensaxe non se puido entregar ao servidor do contacto",
|
||||
"remoteServerNotFound": "A mensaxe non se puido entregar ao servidor do contacto e non sabemos onde está",
|
||||
"failedToEncrypt": "Non se puido cifrar a mensaxe",
|
||||
"failedToEncryptFile": "Non se puido cifrar o ficheiro",
|
||||
"failedToDecryptFile": "Non se puido descifrar o ficheiro",
|
||||
"fileNotEncrypted": "A conversa está cifrada pero o ficheiro non está cifrado"
|
||||
},
|
||||
"conversation": {
|
||||
"audioRecordingError": "Fallou a finalización da gravación de audio",
|
||||
"openFileNoAppError": "Non se atopa unha app para abrir o ficheiro",
|
||||
"openFileGenericError": "Non se puido abrir o ficheiro",
|
||||
"messageErrorDialogTitle": "Erro"
|
||||
},
|
||||
"newChat": {
|
||||
"remoteServerError": "Non se puido contactar co servidor remoto.",
|
||||
"groupchatUnsupported": "Por agora non hai soporte para unirse a conversas en grupo.",
|
||||
"unknown": "Erro descoñecido."
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Non se puido verificar a integridade do ficheiro"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Mantén premido o botón para gravar a mensaxe de voz"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"noAccount": "Non tes unha conta XMPP? Sen problema, crear unha é doado.",
|
||||
"loginButton": "Acceder",
|
||||
"registerButton": "Crear conta"
|
||||
},
|
||||
"login": {
|
||||
"title": "Acceder",
|
||||
"xmppAddress": "Enderezo-XMPP",
|
||||
"password": "Contrasinal",
|
||||
"advancedOptions": "Opcións avanzadas",
|
||||
"createAccount": "Crear conta no servidor"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Nova conversa",
|
||||
"speeddialJoinGroupchat": "Entrar na conversa en grupo",
|
||||
"speeddialAddNoteToSelf": "Notas propias",
|
||||
"overlaySettings": "Axustes",
|
||||
"noOpenChats": "Non tes conversas abertas",
|
||||
"startChat": "Inicia unha conversa",
|
||||
"closeChat": "Pecha a conversa",
|
||||
"markAsRead": "Marcar como lido",
|
||||
"closeChatBody": "Tes a certeza de querer pechar a conversa con ${conversationTitle}?"
|
||||
},
|
||||
"conversation": {
|
||||
"unencrypted": "Sen cifrar",
|
||||
"encrypted": "Cifrada",
|
||||
"closeChat": "Pechar conversa",
|
||||
"closeChatConfirmTitle": "Pechar conversa",
|
||||
"closeChatConfirmSubtext": "Tes a certeza de querer pechar esta conversa?",
|
||||
"blockShort": "Bloquear",
|
||||
"blockUser": "Bloquear conta",
|
||||
"online": "En liña",
|
||||
"retract": "Editar mensaxe",
|
||||
"retractBody": "Tes a certeza de querer editar a mensaxe? Ten en conta que esta só é unha solicitude e que o cliente non ten porque facerlle caso.",
|
||||
"forward": "Reenviar",
|
||||
"edit": "Editar",
|
||||
"quote": "Citar",
|
||||
"showWarning": "Mostrar aviso",
|
||||
"warning": "Aviso",
|
||||
"addToContacts": "Engadir aos contactos",
|
||||
"addToContactsTitle": "Engadir a ${jid} aos contactos",
|
||||
"addToContactsBody": "Tes a certeza de querer engadir a ${jid} aos teus contactos?",
|
||||
"stickerPickerNoStickersLine1": "Non tes paquetes de adhesivos instalados.",
|
||||
"stickerPickerNoStickersLine2": "Pódelos instalar a través dos axustes dos adhesivos.",
|
||||
"stickerSettings": "Axustes dos adhesivos",
|
||||
"newDeviceMessage": {
|
||||
"one": "Engadiuse un novo dispositivo",
|
||||
"other": "Engadíronse varios dispositivos novos"
|
||||
},
|
||||
"replacedDeviceMessage": {
|
||||
"one": "Un dos dispositivos cambiou",
|
||||
"other": "Engadíronse varios dispositivos"
|
||||
},
|
||||
"messageHint": "Enviar unha mensaxe…",
|
||||
"sendImages": "Enviar imaxes",
|
||||
"sendFiles": "Enviar ficheiros",
|
||||
"takePhotos": "Facer fotos",
|
||||
"copy": "Copiar contido",
|
||||
"messageCopied": "Copiouse o contido da mensaxe no portapapeis",
|
||||
"addReaction": "Engadir reacción",
|
||||
"showError": "Mostar erro"
|
||||
},
|
||||
"startchat": {
|
||||
"title": "Nova Conversa",
|
||||
"xmppAddress": "Enderezo XMPP",
|
||||
"subtitle": "Podes iniciar unha nova conversa escribindo un enderezo XMPP ou escaneando o seu código QR.",
|
||||
"buttonAddToContact": "Iniciar nova conversa"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Nova conversa",
|
||||
"startChat": "Iniciar nova conversa",
|
||||
"createGroupchat": "Nova conversa en grupo",
|
||||
"nullNickname": "O alcume non pode estar baleiro!",
|
||||
"nick": "Alcume",
|
||||
"nicknameSubtitle": "Tes que escribir un alcume único para poder unirte á MUC.",
|
||||
"joinGroupChat": "Unirse a conversa en grupo",
|
||||
"enterNickname": "Escribe un alcume"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Establecer imaxe de perfil"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Compartir con…",
|
||||
"confirmTitle": "Enviar ficheiro",
|
||||
"confirmBody": "Unha ou varias conversas non están cifradas. Isto significa que o ficheiro podería ser visto no servidor. Desexas continuar?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Seguridade",
|
||||
"profile": "Perfil",
|
||||
"media": "Multimedia"
|
||||
},
|
||||
"conversation": {
|
||||
"notifications": "Notificacións",
|
||||
"notificationsMuted": "Acalada",
|
||||
"notificationsEnabled": "Activada",
|
||||
"sharedMedia": "Multimedia"
|
||||
},
|
||||
"owndevices": {
|
||||
"title": "Dispositivos propios",
|
||||
"thisDevice": "Este dispositivo",
|
||||
"otherDevices": "Outros dispositivos",
|
||||
"deleteDeviceConfirmTitle": "Eliminar dispositivo",
|
||||
"deleteDeviceConfirmBody": "Isto significa que os contactos non poderán cifrar as mensaxes para ese dispositivo. Continuar?",
|
||||
"recreateOwnSessions": "Reconstruír sesións",
|
||||
"recreateOwnSessionsConfirmTitle": "Recrear as sesións propias?",
|
||||
"recreateOwnSessionsConfirmBody": "Isto volverá a crear as sesións criptográficas dos teus dispositivos. Fai isto só se os teus dispositivos mostran erros ao descrifrar.",
|
||||
"recreateOwnDevice": "Recrear dispositivo",
|
||||
"recreateOwnDeviceConfirmTitle": "Recrear o dispositivo propio?",
|
||||
"recreateOwnDeviceConfirmBody": "Isto recreará a identidade criptográfica deste dispositivo? Podería levarlle un anaco. Se os contactos verificaron este dispositivo, terán que facelo outra vez. Continuar?"
|
||||
},
|
||||
"devices": {
|
||||
"title": "Seguridade",
|
||||
"recreateSessions": "Reconstruír sesións",
|
||||
"recreateSessionsConfirmTitle": "Reconstruír sesións?",
|
||||
"recreateSessionsConfirmBody": "Isto volverá a crear as sesións criptográficas dos teus dispositivos. Usa isto só se os teus dispositivos mostran erros ao descifrar.",
|
||||
"noSessions": "Non hai sesións criptográficas en uso para a cifraxe de extremo-a-extremo."
|
||||
}
|
||||
},
|
||||
"sharedMedia": {
|
||||
"empty": {
|
||||
"general": "Non hai dispoñibles ficheiros multimedia",
|
||||
"chat": "Non hai multimedia compartido nesta conversa"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"about": {
|
||||
"debugMenuAlreadyShown": "Xa es desenvolvedora!",
|
||||
"title": "Acerca de",
|
||||
"licensed": "Baixo licenza GPL3",
|
||||
"version": "Versión ${version}",
|
||||
"viewSourceCode": "Ver código fonte",
|
||||
"nMoreToGo": "${n} máis para rematar…",
|
||||
"debugMenuShown": "Agora xa es desenvolvedora!"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Axustes",
|
||||
"conversationsSection": "Conversas",
|
||||
"accountSection": "Conta",
|
||||
"signOut": "Pechar sesión",
|
||||
"signOutConfirmTitle": "Pechar Sesión",
|
||||
"signOutConfirmBody": "Vas pechar a sesión. Continuar?",
|
||||
"miscellaneousSection": "Varios",
|
||||
"debuggingSection": "Depuración",
|
||||
"general": "Xeral"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aparencia",
|
||||
"languageSection": "Idioma",
|
||||
"language": "Idioma da app",
|
||||
"languageSubtext": "Idioma actual: $selectedLanguage",
|
||||
"systemLanguage": "Idioma por defecto"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Licenzas Open-Source",
|
||||
"licensedUnder": "Baixo licenza $license"
|
||||
},
|
||||
"conversation": {
|
||||
"title": "Conversa",
|
||||
"appearance": "Aparencia",
|
||||
"selectBackgroundImage": "Elexir imaxe de fondo",
|
||||
"selectBackgroundImageDescription": "Esta será a imaxe de fondo en todas as túas conversas",
|
||||
"removeBackgroundImage": "Retirar imaxe de fondo",
|
||||
"removeBackgroundImageConfirmTitle": "Retirar imaxe de fondo",
|
||||
"removeBackgroundImageConfirmBody": "Tes a certeza de querer eliminar a imaxe de fondo da conversa?",
|
||||
"newChatsSection": "Novas Conversas",
|
||||
"newChatsMuteByDefault": "Por defecto acalar as novas conversas",
|
||||
"newChatsE2EE": "Por defecto activar a cifraxe de extremo-a-extremo. AVISO: Experimental",
|
||||
"behaviourSection": "Comportamento",
|
||||
"contactsIntegration": "Integración dos contactos",
|
||||
"contactsIntegrationBody": "Ao activala, os datos da libreta de enderezos usaranse para proporcionar título ás conversas e imaxes de perfil. Non se transmite información ao servidor."
|
||||
},
|
||||
"debugging": {
|
||||
"title": "Opción de depuración",
|
||||
"generalSection": "Xeral",
|
||||
"generalEnableDebugging": "Activar depuración",
|
||||
"generalEncryptionPassword": "Contrasinal de cifraxe",
|
||||
"generalLoggingIp": "IP de rexistro",
|
||||
"generalLoggingIpSubtext": "O IP ao que se deben enviar os rexistros",
|
||||
"generalLoggingPort": "Porto de rexistro",
|
||||
"generalLoggingPortSubtext": "O IP ao que se deben enviar os rexistros",
|
||||
"generalEncryptionPasswordSubtext": "Os rexistros poderían conter información sensible así que mellor elixe un contrasinal forte"
|
||||
},
|
||||
"network": {
|
||||
"title": "Rede",
|
||||
"automaticDownloadsSection": "Descargas automáticas",
|
||||
"automaticDownloadsText": "Moxxy descargará automáticamente os ficheiros en…",
|
||||
"automaticDownloadsMaximumSize": "Tamaño máximo de descarga",
|
||||
"automaticDownloadsMaximumSizeSubtext": "O tamaño máximo dos ficheiros a descargar de xeito automático",
|
||||
"automaticDownloadAlways": "Sempre",
|
||||
"wifi": "Wifi",
|
||||
"mobileData": "Datos móbiles"
|
||||
},
|
||||
"privacy": {
|
||||
"profilePictureVisibility": "Facer pública a foto de perfil",
|
||||
"profilePictureVisibilitSubtext": "Se o activas, calquera poderá ver a túa foto de perfil. Se o desactivas, só as persoas da túa lista de contactos poderán ver a foto de perfil.",
|
||||
"conversationsSection": "Conversa",
|
||||
"sendChatMarkers": "Enviar marcadores de conversa",
|
||||
"title": "Privacidade",
|
||||
"generalSection": "Xeral",
|
||||
"sendChatMarkersSubtext": "Vai indicar aos teus correspondentes se recibiches ou liches a mensaxe",
|
||||
"showContactRequests": "Mostrar solicitudes de contacto",
|
||||
"showContactRequestsSubtext": "Mostrarache as persoas que te engadiron á súa lista de contactos pero que aínda non che enviaron mensaxes",
|
||||
"sendChatStates": "Enviar estados da conversa",
|
||||
"sendChatStatesSubtext": "Vai indicar aos teus correspondentes se estás escribindo ou lendo na conversa",
|
||||
"redirectsSection": "Reenvíos",
|
||||
"redirectsTitle": "Redirixir $serviceName",
|
||||
"cannotEnableRedirect": "Non se pode activar a redirección $serviceName",
|
||||
"cannotEnableRedirectSubtext": "Primeiro tes que establecer o servizo proxy ao que queres redirixir. Para facelo toca no campo a carón do botón.",
|
||||
"urlEmpty": "O URL non pode estar baleiro",
|
||||
"urlInvalid": "URL non válido",
|
||||
"redirectDialogTitle": "Redirixir $serviceName",
|
||||
"stickersPrivacy": "Manter como pública a lista de adhesivos",
|
||||
"stickersPrivacySubtext": "Se o activas, calquera poderá ver a túa lista de paquetes de adhesivos instalados.",
|
||||
"redirectText": "Redireccionará as ligazóns a $serviceName nas que premas cara o servizo proxy, ex. $exampleProxy",
|
||||
"currentlySelected": "Seleccionado actualmente: $proxy"
|
||||
},
|
||||
"stickers": {
|
||||
"title": "Adhesivos",
|
||||
"stickerSection": "Adhesivo",
|
||||
"displayStickers": "Mostar adhesivos na conversa",
|
||||
"autoDownload": "Descargar automaticamente adhesivos",
|
||||
"autoDownloadBody": "Se o activas, descargarás automaticamente os adhesivos cando o remitente estea na túa lista de contactos.",
|
||||
"stickerPacksSection": "Paquetes de adhesivos",
|
||||
"importStickerPack": "Importar paquete de adhesivos",
|
||||
"importSuccess": "Paquete de adhesivos importado correctamente",
|
||||
"importFailure": "Fallou a importación do paquete de adhesivos",
|
||||
"stickerPackSize": "(${size})"
|
||||
},
|
||||
"stickerPacks": {
|
||||
"title": "Paquetes de Adhesivos"
|
||||
},
|
||||
"storage": {
|
||||
"title": "Almacenaxe",
|
||||
"storageUsed": "Almacenaxe utilizada: ${size}",
|
||||
"sizePlaceholder": "Calculando…",
|
||||
"storageManagement": "Xestión da almacenaxe",
|
||||
"removeOldMedia": {
|
||||
"title": "Eliminar multimedia antigo",
|
||||
"description": "Elimina ficheiros multimedia antigos do dispositivo"
|
||||
},
|
||||
"removeOldMediaDialog": {
|
||||
"title": "Eliminar ficheiros multimedia",
|
||||
"options": {
|
||||
"all": "Todos os ficheiros multimedia",
|
||||
"oneWeek": "Anteriores a 1 semana",
|
||||
"oneMonth": "Anteriores a 1 mes"
|
||||
},
|
||||
"delete": "Eliminar",
|
||||
"confirmation": {
|
||||
"body": "Tes a certeza de querer eliminar os ficheiros multimedia antigos?"
|
||||
}
|
||||
},
|
||||
"viewMediaFiles": "Ver ficheiros multimedia",
|
||||
"mediaFiles": "Ficheiros multimedia",
|
||||
"types": {
|
||||
"media": "Multimedia",
|
||||
"stickers": "Adhesivos"
|
||||
},
|
||||
"manageStickers": "Xestión dos paquetes de adhesivos"
|
||||
}
|
||||
},
|
||||
"blocklist": {
|
||||
"title": "Lista de bloqueo",
|
||||
"noUsersBlocked": "Non tes contas bloqueadas",
|
||||
"unblockAll": "Desbloquear todo",
|
||||
"unblockAllConfirmTitle": "Tes certeza?",
|
||||
"unblockAllConfirmBody": "Tes a certeza de querer desbloquear todas as contas?",
|
||||
"unblockJidConfirmTitle": "Desbloquear a ${jid}?",
|
||||
"unblockJidConfirmBody": "Tes a certeza de querer desbloquear a ${jid}? Volverás a recibir mensaxes desde esta conta."
|
||||
},
|
||||
"cropbackground": {
|
||||
"blur": "Desenfocar o fondo",
|
||||
"setAsBackground": "Establecer como imaxe de fondo"
|
||||
},
|
||||
"stickerPack": {
|
||||
"removeConfirmTitle": "Eliminar paquete de adhesivos",
|
||||
"removeConfirmBody": "Tes a certeza de querer eliminar este paquete de adhesivos?",
|
||||
"installConfirmTitle": "Instalar paquete de adhesivos",
|
||||
"installConfirmBody": "Tes a certeza de querer instalar este paquete de adhesivos?",
|
||||
"restricted": "Este paquete de adhesivos ten restricións. Significa que poden ser vistos pero non enviados.",
|
||||
"fetchingFailure": "Non se atopa o paquete de adhesivos"
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"dialogAccept": "Oká",
|
||||
"dialogCancel": "Cancelar",
|
||||
"yes": "Si",
|
||||
"no": "Non",
|
||||
"moxxySubtitle": "Un experimento para crear un cliente XMPP moderno, bonito e doado de usar."
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"idle": "Detido",
|
||||
"ready": "Preparado para recibir mensaxes",
|
||||
"connecting": "Conectando…",
|
||||
"disconnect": "Desconectado",
|
||||
"error": "Erro"
|
||||
},
|
||||
"message": {
|
||||
"reply": "Responder",
|
||||
"markAsRead": "Marcar como lido"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Mensaxes",
|
||||
"warningChannelName": "Avisos",
|
||||
"warningChannelDescription": "Avisos en relación a Moxxy",
|
||||
"messagesChannelDescription": "A canle de notificación para as mensaxes recibidas",
|
||||
"serviceChannelName": "Servizo en primeiro plano",
|
||||
"serviceChannelDescription": "Mantén activa a notificación do servizo en primeiro plano"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Erro"
|
||||
},
|
||||
"errors": {
|
||||
"messageError": {
|
||||
"title": "Fallou a entrega da mensaxe",
|
||||
"body": "Fallou a entrega da mensaxe a ${conversationTitle}"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"blockingError": {
|
||||
"title": "Erro ao bloquear a usuaria",
|
||||
"body": "Non se puido bloquear a ${jid} porque o teu servidor non ten soporte para bloqueos."
|
||||
}
|
||||
}
|
||||
},
|
||||
"language": "Galego",
|
||||
"permissions": {
|
||||
"requests": {
|
||||
"batterySaving": {
|
||||
"reason": "Para poder recibir mensaxes cando está en segundo plano, Moxxy ten que evitar que Android force o aforro de batería."
|
||||
},
|
||||
"notification": {
|
||||
"reason": "Para poder informar das mensaxes que chegan, Moxxy precisa permiso para mostrar notificacións."
|
||||
}
|
||||
},
|
||||
"allow": "Permitir",
|
||||
"skip": "Evitar"
|
||||
}
|
||||
}
|
||||
172
assets/i18n/strings_ja.i18n.json
Normal file
172
assets/i18n/strings_ja.i18n.json
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"language": "日本語",
|
||||
"global": {
|
||||
"yes": "はい",
|
||||
"no": "いいえ",
|
||||
"dialogCancel": "キャンセル",
|
||||
"title": "Moxxy",
|
||||
"dialogAccept": "OK"
|
||||
},
|
||||
"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": "メッセージ取り消された",
|
||||
"retractedFallback": "前のメッセージを取り消しましたが、このクライエントはサポートしていません"
|
||||
},
|
||||
"errors": {
|
||||
"connection": {
|
||||
"connectionTimeout": "サーバー接続中にタイムアウトが発生しました",
|
||||
"saslInvalidCredentials": "ユーザー名またはパスワードが無効",
|
||||
"saslAccountDisabled": "あなたのアカウントは停止されています",
|
||||
"unrecoverable": "回復不能のエラーで接続が途絶えました"
|
||||
},
|
||||
"login": {
|
||||
"noConnection": "接続できませんでした",
|
||||
"startTlsFailed": "接続できませんでした",
|
||||
"unspecified": "特定できないエラー",
|
||||
"saslFailed": "無効なログイン証明書です"
|
||||
},
|
||||
"message": {
|
||||
"fileUploadFailed": "アップロードに失敗しました",
|
||||
"fileDownloadFailed": "ダウンロードに失敗しました",
|
||||
"serviceUnavailable": "配信に失敗しました",
|
||||
"unspecified": "不明なエラー",
|
||||
"failedToDecryptFile": "そのファイルは復号化できません",
|
||||
"fileNotEncrypted": "チャットは暗号化されますが、ファイルは暗号化されません"
|
||||
},
|
||||
"omemo": {
|
||||
"notEncryptedForDevice": "このデバイス向けにメッセージは暗号化されませんでした",
|
||||
"couldNotPublish": "サーバに対して暗号化IDを発行できませんでした、端末間暗号通信はできません",
|
||||
"invalidHmac": "メッセージを復号できませんでした",
|
||||
"noDecryptionKey": "復号キーがありません",
|
||||
"messageInvalidAfixElement": "無効な暗号化メッセージです",
|
||||
"verificationInvalidOmemoUrl": "無効な OMEMO:2 フィンガープリントです",
|
||||
"verificationWrongJid": "正しくない XMPP アドレスです",
|
||||
"verificationWrongDevice": "正しくない OMEMO:2 機器です",
|
||||
"verificationNotInList": "正しくない OMEMO:2 機器です",
|
||||
"verificationWrongFingerprint": "正しくない OMEMO:2 フィンガープリントです"
|
||||
},
|
||||
"conversation": {
|
||||
"messageErrorDialogTitle": "エラー",
|
||||
"audioRecordingError": "音声録音の処理に失敗しました",
|
||||
"openFileNoAppError": "このファイルを開くアプリケーションを発見できませんでした",
|
||||
"openFileGenericError": "ファイルを開くのに失敗しました"
|
||||
},
|
||||
"filePicker": {
|
||||
"permissionDenied": "書き込み用メディアへの権限がありません"
|
||||
},
|
||||
"general": {
|
||||
"noInternet": "インターネットに接続されていません"
|
||||
},
|
||||
"newChat": {
|
||||
"remoteServerError": "リモートサーバーへの接続に失敗しました",
|
||||
"groupchatUnsupported": "グループチャットへの参加は現在サポートされていません",
|
||||
"unknown": "不明なエラー"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"conversation": {
|
||||
"holdForLonger": "長押しすると音声記録できます"
|
||||
},
|
||||
"message": {
|
||||
"integrityCheckFailed": "ファイルの整合性を確認できませんでした"
|
||||
}
|
||||
},
|
||||
"pages": {
|
||||
"intro": {
|
||||
"loginButton": "ログイン",
|
||||
"registerButton": "新規登録",
|
||||
"noAccount": "XMPPアドレスをお持ちですか?XMPPアドレスの作成は簡単です。"
|
||||
},
|
||||
"login": {
|
||||
"title": "ログイン",
|
||||
"xmppAddress": "XMPPアドレス",
|
||||
"password": "パスワード",
|
||||
"advancedOptions": "詳細設定",
|
||||
"createAccount": "サーバーにアカウントを作成する"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialJoinGroupchat": "グループチャットに参加",
|
||||
"speeddialAddNoteToSelf": "自分用メモ",
|
||||
"speeddialNewChat": "新しいチャット",
|
||||
"overlaySettings": "設定"
|
||||
},
|
||||
"conversation": {
|
||||
"blockShort": "ブロック",
|
||||
"blockUser": "ユーザをブロック",
|
||||
"retract": "メッセージを取り消す",
|
||||
"retractBody": "本当にメッセージを取り消しますか? これはただのリクエストであり面目や体裁に関わるものではありません",
|
||||
"forward": "送る",
|
||||
"edit": "編集する",
|
||||
"online": "オンライン",
|
||||
"quote": "引用する",
|
||||
"copy": "内容をコピー",
|
||||
"addReaction": "リアクションを追加する",
|
||||
"showError": "エラーを表示",
|
||||
"showWarning": "警告を表示",
|
||||
"warning": "警告",
|
||||
"messageCopied": "メッセージはクリップボードにコピーされました"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"connecting": "接続中…",
|
||||
"error": "エラー",
|
||||
"idle": "待機中",
|
||||
"ready": "メッセージを受信する準備ができました",
|
||||
"disconnect": "切断済み"
|
||||
},
|
||||
"message": {
|
||||
"reply": "返信",
|
||||
"markAsRead": "既読"
|
||||
},
|
||||
"channels": {
|
||||
"warningChannelName": "警告",
|
||||
"messagesChannelName": "メッセージ",
|
||||
"messagesChannelDescription": "受信メッセージのためのお知らせチャンネル",
|
||||
"warningChannelDescription": "Moxxy に関する警告"
|
||||
},
|
||||
"titles": {
|
||||
"error": "エラー"
|
||||
},
|
||||
"errors": {
|
||||
"messageError": {
|
||||
"title": "メッセージの送信に失敗しました",
|
||||
"body": "${conversationTitle} に送信したメッセージは届きませんでした"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"skip": "スキップ",
|
||||
"allow": "許可する"
|
||||
}
|
||||
}
|
||||
443
assets/i18n/strings_nl.i18n.json
Normal file
443
assets/i18n/strings_nl.i18n.json
Normal file
@@ -0,0 +1,443 @@
|
||||
{
|
||||
"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",
|
||||
"serviceChannelName": "Voorgronddienst",
|
||||
"serviceChannelDescription": "Toont de vaste voorgronddienstmelding"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Foutmelding"
|
||||
},
|
||||
"errors": {
|
||||
"messageError": {
|
||||
"title": "Bericht afleveren mislukt",
|
||||
"body": "Her bericht aan ‘${conversationTitle}’ kan niet worden afgeleverd"
|
||||
}
|
||||
},
|
||||
"warnings": {
|
||||
"blockingError": {
|
||||
"title": "Blokkeren mislukt",
|
||||
"body": "De gebruiker ‘${jid}’ kan niet worden geblokkeerd omdat je server dit niet ondersteunt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"nick": "Bijnaam",
|
||||
"enterNickname": "Voer een bijnaam in",
|
||||
"nicknameSubtitle": "Voer een unieke bijnaam in om deel te kunnen nemen aan een MUC.",
|
||||
"joinGroupChat": "Deelnemen aan groepsgesprek",
|
||||
"nullNickname": "Voer een bijnaam in!"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"allow": "Toestaan",
|
||||
"skip": "Overslaan",
|
||||
"requests": {
|
||||
"notification": {
|
||||
"reason": "Moxxy heeft het recht om meldingen te tonen nodig om meldingen van inkomende berichten te kunnen tonen."
|
||||
},
|
||||
"batterySaving": {
|
||||
"reason": "Sluit Moxxy uit van Androids accubesparing om berichten op de achtergrond te ontvangen."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
assets/i18n/strings_pl.i18n.json
Normal file
37
assets/i18n/strings_pl.i18n.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"language": "Polski",
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"dialogAccept": "OK",
|
||||
"yes": "Tak",
|
||||
"no": "Nie",
|
||||
"dialogCancel": "Anuluj"
|
||||
},
|
||||
"notifications": {
|
||||
"permanent": {
|
||||
"connecting": "Łączenie…",
|
||||
"disconnect": "Rozłączono",
|
||||
"error": "Błąd"
|
||||
},
|
||||
"channels": {
|
||||
"messagesChannelName": "Wiadomości",
|
||||
"warningChannelName": "Ostrzeżenia"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Błąd"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"allow": "Zezwól",
|
||||
"skip": "Pomiń"
|
||||
},
|
||||
"dateTime": {
|
||||
"mondayAbbrev": "Pon",
|
||||
"tuesdayAbbrev": "Wt",
|
||||
"wednessdayAbbrev": "Śr",
|
||||
"thursdayAbbrev": "Czw",
|
||||
"fridayAbbrev": "Pt",
|
||||
"saturdayAbbrev": "Sob",
|
||||
"sundayAbbrev": "Nd"
|
||||
}
|
||||
}
|
||||
422
assets/i18n/strings_ru.i18n.json
Normal file
422
assets/i18n/strings_ru.i18n.json
Normal file
@@ -0,0 +1,422 @@
|
||||
{
|
||||
"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": "Ошибка"
|
||||
},
|
||||
"errors": {
|
||||
"messageError": {
|
||||
"body": "Не удалось доставить сообщение для ${conversationTitle}",
|
||||
"title": "Сообщение не доставлено"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": "Ошибка при импорте стикерпаков",
|
||||
"stickerPackSize": "(${size})"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Настройки",
|
||||
"conversationsSection": "Чаты",
|
||||
"accountSection": "Учётная запись",
|
||||
"signOut": "Выйти",
|
||||
"signOutConfirmTitle": "Выйти",
|
||||
"signOutConfirmBody": "Вы хотите выйти, продолжить?",
|
||||
"miscellaneousSection": "Другое",
|
||||
"debuggingSection": "Отладка",
|
||||
"general": "Основные"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Внешний вид",
|
||||
"languageSection": "Язык",
|
||||
"language": "Язык в приложении",
|
||||
"systemLanguage": "Как в системе",
|
||||
"languageSubtext": "Выбранный язык: ${selectedLanguage}"
|
||||
},
|
||||
"licenses": {
|
||||
"title": "Открытые лицензии",
|
||||
"licensedUnder": "Лицензировано под ${license}"
|
||||
},
|
||||
"storage": {
|
||||
"removeOldMedia": {
|
||||
"description": "Удалит старые медиафайлы с устройства",
|
||||
"title": "Удалить старые медиафайлы"
|
||||
},
|
||||
"title": "Хранилище",
|
||||
"sizePlaceholder": "Вычисление...",
|
||||
"storageManagement": "Управление хранилищем",
|
||||
"removeOldMediaDialog": {
|
||||
"options": {
|
||||
"all": "Все медиафайлы",
|
||||
"oneWeek": "Старее 1 недели",
|
||||
"oneMonth": "Старее 1 месяца"
|
||||
},
|
||||
"delete": "Удалить",
|
||||
"title": "Удалить медиафайлы",
|
||||
"confirmation": {
|
||||
"body": "Вы точно хотите удалить старые медиафайлы?"
|
||||
}
|
||||
},
|
||||
"viewMediaFiles": "Просмотреть медиафайлы",
|
||||
"mediaFiles": "Медиафайлы",
|
||||
"types": {
|
||||
"media": "Медиа",
|
||||
"stickers": "Стикеры"
|
||||
},
|
||||
"storageUsed": "Использование хранилища: ${size}",
|
||||
"manageStickers": "Управлять наборами стикеров"
|
||||
},
|
||||
"stickerPacks": {
|
||||
"title": "Наборы стикеров"
|
||||
}
|
||||
},
|
||||
"sharedMedia": {
|
||||
"empty": {
|
||||
"chat": "Нет общих медиафайлов для этого чата",
|
||||
"general": "Нет доступных медиаустройств"
|
||||
}
|
||||
}
|
||||
},
|
||||
"language": "Русский",
|
||||
"warnings": {
|
||||
"message": {
|
||||
"integrityCheckFailed": "Не удалось проверить целостность файла"
|
||||
},
|
||||
"conversation": {
|
||||
"holdForLonger": "Удерживайте для записи"
|
||||
}
|
||||
},
|
||||
"permissions": {
|
||||
"allow": "Разрешить",
|
||||
"skip": "Пропустить"
|
||||
}
|
||||
}
|
||||
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)
|
||||
13
fastlane/metadata/android/en-US/changelogs/13.txt
Normal file
13
fastlane/metadata/android/en-US/changelogs/13.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
- (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, French, Galician, Japanese, Polish, 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.
|
||||
- Fix Moxxy's app icon having a badge because of the foreground service.
|
||||
- Make the notifications much prettier and compliant with Android 13.
|
||||
- Prevent Moxxy from crashing on startup on a fresh device.
|
||||
- Video thumbnails are now generated, if possible, after a video has been downloaded.
|
||||
- The Moxxy APKs will now be signed by a different key stored on my YubiKey. You will have to uninstall and reinstall Moxxy. This will remove all your data.
|
||||
- Moxxy now uses Android's direct share shortcuts.
|
||||
- Moxxy now uses Android 13's new photo picker, whenever possible. This should allow Moxxy to require fewer permissions to work.
|
||||
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
7
fastlane/metadata/android/en-US/changelogs/9.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
* Expose the debug menu by tapping the Moxxy icon on the about page 10 times
|
||||
* Maybe fix a connection race condition
|
||||
* Allow sharing media with the app when it was closed
|
||||
* Make quotes prettier
|
||||
* Make the bottom part of the conversation page prettier
|
||||
* Fix roster fetching
|
||||
* Fix OMEMO key generation
|
||||
@@ -10,12 +10,14 @@ Currently supported features include:
|
||||
<li>Typing indicators and message markers</li>
|
||||
<li>Chat backgrounds</li>
|
||||
<li>Runs in the background without Push Notifications</li>
|
||||
<li>OMEMO (Currently not compatible with most apps)</li>
|
||||
<li>Stickers</li>
|
||||
</ul>
|
||||
|
||||
For the best experience, I recommend a server that:
|
||||
<ul>
|
||||
<li>Supports direct TLS/StartTLS on the same domain as in the Jid</li>
|
||||
<li>Supports SCRAM-SHA-1 or SCRAM-SHA-256</li>
|
||||
<li>Supports SCRAM-SHA-1, SCRAM-SHA-256 or SCRAM-SHA-512</li>
|
||||
<li>Supports HTTP File Upload</li>
|
||||
<li>Supports Stream Management</li>
|
||||
<li>Supports Client State Indication</li>
|
||||
|
||||
1
fastlane/metadata/android/fr-FR/short_description.txt
Normal file
1
fastlane/metadata/android/fr-FR/short_description.txt
Normal file
@@ -0,0 +1 @@
|
||||
Moxxy est un client XMPP expérimental qui vise d’être moderne et facile.
|
||||
1
fastlane/metadata/android/fr-FR/title.txt
Normal file
1
fastlane/metadata/android/fr-FR/title.txt
Normal file
@@ -0,0 +1 @@
|
||||
Moxxy
|
||||
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
|
||||
186
flake.lock
generated
186
flake.lock
generated
@@ -1,6 +1,103 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"bab": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1694100130,
|
||||
"narHash": "sha256-3xQgPgFNVtuftoYsUxtUTFu/P5ZzcIaRIrLwNs4xrBg=",
|
||||
"ref": "refs/heads/master",
|
||||
"rev": "8e98e366f7de0d8636a387ee857ead7cc8c1b646",
|
||||
"revCount": 6,
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/PapaTutuWawa/bits-and-bytes.git"
|
||||
},
|
||||
"original": {
|
||||
"type": "git",
|
||||
"url": "https://codeberg.org/PapaTutuWawa/bits-and-bytes.git"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"inputs": {
|
||||
"systems": "systems_3"
|
||||
},
|
||||
"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_3": {
|
||||
"locked": {
|
||||
"lastModified": 1667395993,
|
||||
"narHash": "sha256-nuEHfE/LcWyuSWnS8t12N1wc105Qtau+/OdUAjtQ0rA=",
|
||||
@@ -17,11 +114,43 @@
|
||||
},
|
||||
"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": 1689935543,
|
||||
"narHash": "sha256-6GQ9ib4dA/r1leC5VUpsBo0BmDvNxLjKrX1iyL+h8mc=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e43e2448161c0a2c4928abec4e16eae1516571bc",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs_3": {
|
||||
"locked": {
|
||||
"lastModified": 1689752456,
|
||||
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -33,8 +162,55 @@
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"android-nixpkgs": "android-nixpkgs",
|
||||
"bab": "bab",
|
||||
"flake-utils": "flake-utils_3",
|
||||
"nixpkgs": "nixpkgs_3"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"systems_3": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
107
flake.nix
107
flake.nix
@@ -3,33 +3,50 @@
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
android-nixpkgs.url = "github:tadfisher/android-nixpkgs";
|
||||
bab.url = "git+https://codeberg.org/PapaTutuWawa/bits-and-bytes.git";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let
|
||||
outputs = { self, nixpkgs, flake-utils, android-nixpkgs, bab }: 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 ];
|
||||
}))
|
||||
]);
|
||||
lib = pkgs.lib;
|
||||
babPkgs = bab.packages."${system}";
|
||||
pinnedJDK = pkgs.jdk17;
|
||||
flutterVersion = pkgs.flutter37;
|
||||
|
||||
pythonEnv = pkgs.python3.withPackages (ps: with ps; [
|
||||
requests pyyaml # For the build scripts
|
||||
@@ -38,13 +55,59 @@
|
||||
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 ktlint
|
||||
scrcpy
|
||||
|
||||
# Flutter
|
||||
flutterVersion
|
||||
|
||||
# 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";
|
||||
};
|
||||
|
||||
apps = let
|
||||
providerArg = pkgs.writeText "provider-arg.cfg" ''
|
||||
name = OpenSC-PKCS11
|
||||
description = SunPKCS11 via OpenSC
|
||||
library = ${pkgs.opensc}/lib/opensc-pkcs11.so
|
||||
slotListIndex = 0
|
||||
'';
|
||||
mkBuildScript = skipBuild: pkgs.writeShellScript "build-moxxy.sh" ''
|
||||
${babPkgs.flutter-build}/bin/flutter-build \
|
||||
--name Moxxy \
|
||||
--not-signed \
|
||||
--zipalign ${sdk}/share/android-sdk/build-tools/34.0.0/zipalign \
|
||||
--apksigner ${sdk}/share/android-sdk/build-tools/34.0.0/apksigner \
|
||||
--pigeon ./pigeon/quirks.dart \
|
||||
--flutter ${flutterVersion}/bin/flutter \
|
||||
--dart ${flutterVersion}/bin/dart \
|
||||
--provider-config ${providerArg} ${lib.optionalString skipBuild "--skip-build"}
|
||||
'';
|
||||
in {
|
||||
# Skip the build and just sign
|
||||
onlySign = {
|
||||
type = "app";
|
||||
program = "${mkBuildScript true}";
|
||||
};
|
||||
|
||||
# Build everything and sign
|
||||
build = {
|
||||
type = "app";
|
||||
program = "${mkBuildScript false}";
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -22,7 +22,8 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
state: String
|
||||
permissionsToRequest: List<int>
|
||||
requestNotificationPermission: bool
|
||||
excludeFromBatteryOptimisation: bool
|
||||
preferences:
|
||||
type: PreferencesState
|
||||
deserialise: true
|
||||
@@ -36,18 +37,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 +63,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:
|
||||
@@ -119,7 +108,7 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
id: int
|
||||
id: String
|
||||
progress: double?
|
||||
# Triggered by [RosterService] if we receive a roster push.
|
||||
- name: RosterDiffEvent
|
||||
@@ -217,7 +206,7 @@ files:
|
||||
attributes:
|
||||
conversationJid: String
|
||||
title: String
|
||||
avatarUrl: String
|
||||
avatarPath: String
|
||||
- name: StickerPackImportSuccessEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
@@ -265,6 +254,107 @@ 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
|
||||
attributes:
|
||||
jid: String
|
||||
# Returned when an error occured
|
||||
- name: ErrorEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
errorId: int
|
||||
# Triggered by the service in response to an [JoinGroupchatCommand].
|
||||
- name: JoinGroupchatResult
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversation:
|
||||
type: Conversation
|
||||
deserialise: true
|
||||
# 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
|
||||
- name: FetchRecipientInformationResult
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
items:
|
||||
type: List<SendFilesRecipient>
|
||||
deserialise: true
|
||||
generate_builder: true
|
||||
builder_name: "Event"
|
||||
builder_baseclass: "BackgroundEvent"
|
||||
@@ -294,12 +384,7 @@ files:
|
||||
lastMessageBody: String
|
||||
avatarUrl: String
|
||||
jid: String
|
||||
- name: GetMessagesForJidCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
conversationType: String
|
||||
- name: SetOpenConversationCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -318,7 +403,7 @@ files:
|
||||
type: Message?
|
||||
deserialise: true
|
||||
editSid: String?
|
||||
editId: int?
|
||||
currentConversationJid: String?
|
||||
- name: SendFilesCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -362,6 +447,12 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
- name: RemoveContactCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
- name: RequestDownloadCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -390,6 +481,12 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
- name: ExitConversationCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationType: String
|
||||
- name: SendChatStateCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -397,6 +494,7 @@ files:
|
||||
attributes:
|
||||
state: String
|
||||
jid: String
|
||||
conversationType: String
|
||||
- name: GetFeaturesCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -467,30 +565,27 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationId: int
|
||||
conversationJid: String
|
||||
- name: MarkMessageAsReadCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationJid: String
|
||||
sid: String
|
||||
newUnreadCounter: int
|
||||
id: String
|
||||
sendMarker: bool
|
||||
- name: AddReactionToMessageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messageId: int
|
||||
conversationJid: String
|
||||
id: String
|
||||
emoji: String
|
||||
- name: RemoveReactionFromMessageCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messageId: int
|
||||
conversationJid: String
|
||||
id: String
|
||||
emoji: String
|
||||
- name: MarkOmemoDeviceAsVerifiedCommand
|
||||
extends: BackgroundCommand
|
||||
@@ -516,9 +611,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 +638,81 @@ 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:
|
||||
id: String
|
||||
- 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
|
||||
- name: JoinGroupchatCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
nick: String
|
||||
- name: FetchRecipientInformationCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jids: List<String>
|
||||
generate_builder: true
|
||||
# get${builder_Name}FromJson
|
||||
builder_name: "Command"
|
||||
|
||||
218
lib/main.dart
218
lib/main.dart
@@ -10,32 +10,33 @@ 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';
|
||||
import 'package:moxxyv2/ui/bloc/crop_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/cropbackground_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/devices_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/groupchat/joingroupchat_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/login_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/navigation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/newconversation_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/own_devices_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/profile_bloc.dart';
|
||||
import 'package:moxxyv2/ui/bloc/request_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';
|
||||
@@ -46,7 +47,7 @@ import 'package:moxxyv2/ui/pages/newconversation.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/devices.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/own_devices.dart';
|
||||
import 'package:moxxyv2/ui/pages/profile/profile.dart';
|
||||
import 'package:moxxyv2/ui/pages/sendfiles.dart';
|
||||
import 'package:moxxyv2/ui/pages/sendfiles/sendfiles.dart';
|
||||
import 'package:moxxyv2/ui/pages/server_info.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/about.dart';
|
||||
import 'package:moxxyv2/ui/pages/settings/appearance/appearance.dart';
|
||||
@@ -57,23 +58,33 @@ 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/startgroupchat.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';
|
||||
import 'package:share_handler/share_handler.dart';
|
||||
|
||||
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,17 +92,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<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());
|
||||
@@ -101,11 +120,10 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<OwnDevicesBloc>(OwnDevicesBloc());
|
||||
GetIt.I.registerSingleton<StickersBloc>(StickersBloc());
|
||||
GetIt.I.registerSingleton<StickerPackBloc>(StickerPackBloc());
|
||||
GetIt.I.registerSingleton<RequestBloc>(RequestBloc());
|
||||
GetIt.I.registerSingleton<JoinGroupchatBloc>(JoinGroupchatBloc());
|
||||
}
|
||||
|
||||
// TODO(Unknown): Replace all Column(children: [ Padding(), Padding, ...]) with a
|
||||
// Padding(padding: ..., child: Column(children: [ ... ]))
|
||||
// TODO(Unknown): Theme the switches
|
||||
void main() async {
|
||||
setupLogging();
|
||||
await setupUIServices();
|
||||
@@ -117,6 +135,8 @@ void main() async {
|
||||
|
||||
await initializeServiceIfNeeded();
|
||||
|
||||
imageCache.maximumSizeBytes = 500 * 1024 * 1024;
|
||||
|
||||
runApp(
|
||||
MultiBlocProvider(
|
||||
providers: [
|
||||
@@ -144,11 +164,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>(),
|
||||
@@ -177,6 +194,12 @@ void main() async {
|
||||
BlocProvider<StickerPackBloc>(
|
||||
create: (_) => GetIt.I.get<StickerPackBloc>(),
|
||||
),
|
||||
BlocProvider<RequestBloc>(
|
||||
create: (_) => GetIt.I.get<RequestBloc>(),
|
||||
),
|
||||
BlocProvider<JoinGroupchatBloc>(
|
||||
create: (_) => GetIt.I.get<JoinGroupchatBloc>(),
|
||||
),
|
||||
],
|
||||
child: TranslationProvider(
|
||||
child: MyApp(navKey),
|
||||
@@ -186,8 +209,7 @@ void main() async {
|
||||
}
|
||||
|
||||
class MyApp extends StatefulWidget {
|
||||
|
||||
const MyApp(this.navigationKey, { super.key });
|
||||
const MyApp(this.navigationKey, {super.key});
|
||||
final GlobalKey<NavigatorState> navigationKey;
|
||||
|
||||
@override
|
||||
@@ -200,46 +222,20 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initState();
|
||||
}
|
||||
|
||||
/// Async "version" of initState()
|
||||
Future<void> _initState() async {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_setupSharingHandler();
|
||||
// Set up receiving share intents
|
||||
await GetIt.I.get<UISharingService>().initialize();
|
||||
|
||||
// Lift the UI block
|
||||
GetIt.I.get<SynchronizedQueue<Map<String, dynamic>?>>().removeQueueLock();
|
||||
}
|
||||
|
||||
Future<void> _handleSharedMedia(SharedMedia media) async {
|
||||
final attachments = media.attachments ?? [];
|
||||
GetIt.I.get<ShareSelectionBloc>().add(
|
||||
ShareSelectionRequestedEvent(
|
||||
attachments.map((a) => a!.path).toList(),
|
||||
media.content,
|
||||
media.content != null ? ShareSelectionType.text : ShareSelectionType.media,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _setupSharingHandler() async {
|
||||
final handler = ShareHandlerPlatform.instance;
|
||||
final media = await handler.getInitialSharedMedia();
|
||||
|
||||
// Shared while the app was closed
|
||||
if (media != null) {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
}
|
||||
|
||||
// Shared while the app is stil running
|
||||
handler.sharedMediaStream.listen((SharedMedia media) async {
|
||||
if (GetIt.I.get<UIDataService>().isLoggedIn) {
|
||||
await _handleSharedMedia(media);
|
||||
}
|
||||
|
||||
await handler.resetInitialSharedMedia();
|
||||
});
|
||||
await GetIt.I
|
||||
.get<SynchronizedQueue<Map<String, dynamic>?>>()
|
||||
.removeQueueLock();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -258,13 +254,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:
|
||||
@@ -284,39 +282,85 @@ 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,
|
||||
conversationType: args.type,
|
||||
),
|
||||
);
|
||||
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 joinGroupchatRoute:
|
||||
return JoinGroupchatPage.getRoute(
|
||||
settings.arguments! as JoinGroupchatArguments,
|
||||
);
|
||||
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,148 @@
|
||||
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/notifications.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 accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final originalConversation =
|
||||
await cs.getConversationByJid(jid.toString(), accountJid!);
|
||||
final originalRoster = await rs.getRosterItemByJid(
|
||||
jid.toString(),
|
||||
accountJid,
|
||||
);
|
||||
|
||||
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(),
|
||||
accountJid,
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
jid.toString(),
|
||||
accountJid,
|
||||
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,
|
||||
originalRoster.jid,
|
||||
accountJid,
|
||||
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,14 +157,23 @@ 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)!;
|
||||
await am.publishUserAvatar(
|
||||
|
||||
_log.finest('Publishing avatar...');
|
||||
final dataResult = await am.publishUserAvatar(
|
||||
base64,
|
||||
hash,
|
||||
public,
|
||||
);
|
||||
await am.publishUserAvatarMetadata(
|
||||
if (dataResult.isType<AvatarError>()) {
|
||||
_log.finest('Avatar data publishing failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO(Unknown): Make sure that the image is not too large.
|
||||
final metadataResult = await am.publishUserAvatarMetadata(
|
||||
UserAvatarMetadata(
|
||||
hash,
|
||||
bytes.length,
|
||||
@@ -174,48 +181,69 @@ class AvatarService {
|
||||
imageSize.height.toInt(),
|
||||
// TODO(PapaTutuWawa): Maybe do a check here
|
||||
'image/png',
|
||||
null,
|
||||
),
|
||||
public,
|
||||
);
|
||||
if (metadataResult.isType<AvatarError>()) {
|
||||
_log.finest('Avatar metadata publishing failed');
|
||||
return false;
|
||||
}
|
||||
|
||||
_log.finest('Avatar publishing done');
|
||||
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 accountJid = await xss.getAccountJid();
|
||||
final state = await xss.state;
|
||||
final jid = JID.fromString(accountJid!);
|
||||
|
||||
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,
|
||||
),);
|
||||
),
|
||||
);
|
||||
|
||||
// Update our notification avatar
|
||||
await GetIt.I.get<NotificationsService>().maybeSetAvatarFromState();
|
||||
|
||||
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@ 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/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
|
||||
enum BlockPushType {
|
||||
block,
|
||||
unblock
|
||||
}
|
||||
enum BlockPushType { block, unblock }
|
||||
|
||||
class BlocklistService {
|
||||
BlocklistService();
|
||||
@@ -18,6 +17,24 @@ class BlocklistService {
|
||||
bool? _supported;
|
||||
final Logger _log = Logger('BlocklistService');
|
||||
|
||||
Future<void> _removeBlocklistEntry(String jid, String accountJid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
blocklistTable,
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBlocklistEntry(String jid, String accountJid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
blocklistTable,
|
||||
{
|
||||
'jid': jid,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onNewConnection() {
|
||||
// Invalidate the caches
|
||||
_blocklist = null;
|
||||
@@ -26,13 +43,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 +61,18 @@ class BlocklistService {
|
||||
return;
|
||||
}
|
||||
|
||||
final blocklist = await GetIt.I.get<XmppConnection>()
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
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, accountJid!);
|
||||
_blocklist!.add(item);
|
||||
newItems.add(item);
|
||||
}
|
||||
@@ -59,7 +81,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, accountJid!);
|
||||
_blocklist!.remove(item);
|
||||
removedItems.add(item);
|
||||
}
|
||||
@@ -79,9 +101,14 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
/// Returns the blocklist from the database
|
||||
Future<List<String>> getBlocklist() async {
|
||||
Future<List<String>> getBlocklist(String accountJid) async {
|
||||
if (_blocklist == null) {
|
||||
_blocklist = await GetIt.I.get<DatabaseService>().getBlocklistEntries();
|
||||
final blocklistRaw = await GetIt.I.get<DatabaseService>().database.query(
|
||||
blocklistTable,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [accountJid],
|
||||
);
|
||||
_blocklist = blocklistRaw.map((m) => m['jid']! as String).toList();
|
||||
|
||||
if (!_requested) {
|
||||
unawaited(_requestBlocklist());
|
||||
@@ -108,23 +135,26 @@ class BlocklistService {
|
||||
// We will fetch it later when getBlocklist is called
|
||||
if (!_requested) return;
|
||||
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final newBlocks = List<String>.empty(growable: true);
|
||||
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, accountJid!);
|
||||
}
|
||||
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, accountJid!);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -146,9 +176,12 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.add(jid);
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.addBlocklistEntry(jid);
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
await _addBlocklistEntry(
|
||||
jid,
|
||||
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
|
||||
);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.block([jid]);
|
||||
}
|
||||
@@ -161,9 +194,12 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.remove(jid);
|
||||
await GetIt.I.get<DatabaseService>()
|
||||
.removeBlocklistEntry(jid);
|
||||
return GetIt.I.get<XmppConnection>()
|
||||
await _removeBlocklistEntry(
|
||||
jid,
|
||||
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
|
||||
);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
.unblock([jid]);
|
||||
}
|
||||
@@ -171,14 +207,21 @@ 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,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [await GetIt.I.get<XmppStateService>().getAccountJid()],
|
||||
);
|
||||
|
||||
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,10 +5,12 @@ import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
@@ -28,28 +30,39 @@ 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(
|
||||
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onContactsDatabaseUpdate() async {
|
||||
@@ -67,8 +80,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 +110,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 +132,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 +149,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,31 +178,34 @@ 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);
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
|
||||
for (final id in List<String>.from(knownContactIds.values)) {
|
||||
final index = contacts.indexWhere((c) => c.id == id);
|
||||
if (index != -1) continue;
|
||||
|
||||
final jid = knownContactIdsReverse[id]!;
|
||||
await db.removeContactId(id);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
contactsTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
_contactIds!.remove(knownContactIdsReverse[id]);
|
||||
|
||||
// Remove the avatar file, if it existed
|
||||
@@ -190,31 +216,38 @@ 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,
|
||||
accountJid!,
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
jid,
|
||||
accountJid,
|
||||
contactId: null,
|
||||
contactAvatarPath: null,
|
||||
contactDisplayName: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (conversation != null) {
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConv,
|
||||
conversation: conversation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the contact attributes from the roster item, if it existed
|
||||
final r = await rs.getRosterItemByJid(jid);
|
||||
final r = await rs.getRosterItemByJid(jid, accountJid);
|
||||
if (r != null) {
|
||||
if (r.pseudoRosterItem) {
|
||||
_log.finest('Removing pseudo roster item $jid');
|
||||
await rs.removeRosterItem(r.id);
|
||||
await rs.removeRosterItem(r.jid, accountJid);
|
||||
removedRosterItems.add(jid);
|
||||
} else {
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
r.id,
|
||||
r.jid,
|
||||
accountJid,
|
||||
contactId: null,
|
||||
contactAvatarPath: null,
|
||||
contactDisplayName: null,
|
||||
@@ -227,7 +260,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,26 +282,34 @@ 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,
|
||||
accountJid!,
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
contact.jid,
|
||||
accountJid,
|
||||
contactId: contact.id,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactAvatarPath:
|
||||
contact.thumbnail != null ? contactAvatarPath : null,
|
||||
contactDisplayName: contact.displayName,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (conversation != null) {
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConv,
|
||||
conversation: conversation,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Update a possibly existing roster item
|
||||
final r = await rs.getRosterItemByJid(contact.jid);
|
||||
final r = await rs.getRosterItemByJid(contact.jid, accountJid);
|
||||
if (r != null) {
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
r.id,
|
||||
r.jid,
|
||||
accountJid,
|
||||
contactId: contact.id,
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contact.displayName,
|
||||
@@ -270,6 +317,7 @@ class ContactsService {
|
||||
modifiedRosterItems.add(newRosterItem);
|
||||
} else {
|
||||
final newRosterItem = await rs.addRosterItemFromData(
|
||||
accountJid,
|
||||
'',
|
||||
'',
|
||||
contact.jid,
|
||||
|
||||
@@ -1,104 +1,263 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/connectivity.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/groupchat.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/groupchat.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);
|
||||
final Logger _log = Logger('ConversationService');
|
||||
|
||||
String? _activeConversationJid;
|
||||
|
||||
String? get activeConversationJid => _activeConversationJid;
|
||||
|
||||
set activeConversationJid(String? jid) {
|
||||
_log.finest('Setting activeConversationJid to $jid');
|
||||
_activeConversationJid = jid;
|
||||
}
|
||||
|
||||
/// 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,
|
||||
String accountJid, {
|
||||
CreateConversationCallback? create,
|
||||
UpdateConversationCallback? update,
|
||||
PreRunConversationCallback? preRun,
|
||||
}) async {
|
||||
return _lock.synchronized(() async {
|
||||
final conversation = await _getConversationByJid(jid, accountJid);
|
||||
|
||||
// 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(String accountJid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final gs = GetIt.I.get<GroupchatService>();
|
||||
final conversationsRaw = await db.query(
|
||||
conversationsTable,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [accountJid],
|
||||
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, accountJid);
|
||||
|
||||
Message? lastMessage;
|
||||
if (c['lastMessageId'] != null) {
|
||||
lastMessage = await GetIt.I.get<MessageService>().getMessageById(
|
||||
c['lastMessageId']! as String,
|
||||
accountJid,
|
||||
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;
|
||||
GroupchatDetails? groupchatDetails;
|
||||
if (c['type'] == ConversationType.groupchat.value) {
|
||||
groupchatDetails = await gs.getGroupchatDetailsByJid(
|
||||
c['jid']! as String,
|
||||
accountJid,
|
||||
);
|
||||
}
|
||||
|
||||
return _conversationCache.getValue(id);
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
groupchatDetails,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return tmp;
|
||||
}
|
||||
|
||||
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
||||
/// to the cache.
|
||||
Future<void> _loadConversationsIfNeeded(String accountJid) async {
|
||||
if (_conversationCache != null) return;
|
||||
|
||||
final conversations = await loadConversations(accountJid);
|
||||
_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,
|
||||
String accountJid,
|
||||
) async {
|
||||
await _loadConversationsIfNeeded(accountJid);
|
||||
return _conversationCache![jid];
|
||||
}
|
||||
|
||||
/// Wrapper around [ConversationService._getConversationByJid] that aquires
|
||||
/// the lock for the cache.
|
||||
Future<Conversation?> getConversationByJid(
|
||||
String jid,
|
||||
String accountJid,
|
||||
) async {
|
||||
return _lock
|
||||
.synchronized(() async => _getConversationByJid(jid, accountJid));
|
||||
}
|
||||
|
||||
/// 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,
|
||||
String accountJid, {
|
||||
int? lastChangeTimestamp,
|
||||
Message? lastMessage,
|
||||
bool? open,
|
||||
int? unreadCounter,
|
||||
String? avatarUrl,
|
||||
String? avatarPath,
|
||||
Object? avatarHash = notSpecified,
|
||||
ChatState? chatState,
|
||||
bool? muted,
|
||||
bool? encrypted,
|
||||
Object? contactId = notSpecified,
|
||||
Object? contactAvatarPath = notSpecified,
|
||||
Object? contactDisplayName = notSpecified,
|
||||
GroupchatDetails? groupchatDetails,
|
||||
}) 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, accountJid))!;
|
||||
|
||||
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 = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid, accountJid);
|
||||
var newConversation = Conversation.fromDatabaseJson(
|
||||
result,
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
groupchatDetails,
|
||||
);
|
||||
|
||||
// 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 accountJid,
|
||||
String title,
|
||||
Message? lastMessage,
|
||||
String avatarUrl,
|
||||
ConversationType type,
|
||||
String avatarPath,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
int lastChangeTimestamp,
|
||||
@@ -108,23 +267,48 @@ class ConversationService {
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
GroupchatDetails? groupchatDetails,
|
||||
) async {
|
||||
final newConversation = await GetIt.I.get<DatabaseService>().addConversationFromData(
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid, accountJid);
|
||||
final gs = GetIt.I.get<GroupchatService>();
|
||||
final newConversation = Conversation(
|
||||
accountJid,
|
||||
title,
|
||||
lastMessage,
|
||||
avatarUrl,
|
||||
avatarPath,
|
||||
null,
|
||||
jid,
|
||||
groupchatDetails,
|
||||
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;
|
||||
}
|
||||
|
||||
if (type == ConversationType.groupchat && groupchatDetails != null) {
|
||||
await gs.addGroupchatDetailsFromData(
|
||||
jid,
|
||||
accountJid,
|
||||
groupchatDetails.nick,
|
||||
);
|
||||
}
|
||||
|
||||
return newConversation;
|
||||
}
|
||||
|
||||
@@ -133,9 +317,41 @@ class ConversationService {
|
||||
///
|
||||
/// If the conversation does not exist, then the value of the preference for
|
||||
/// enableOmemoByDefault is used.
|
||||
Future<bool> shouldEncryptForConversation(JID jid) async {
|
||||
Future<bool> shouldEncryptForConversation(JID jid, String accountJid) async {
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final conversation = await getConversationByJid(jid.toString());
|
||||
final conversation = await getConversationByJid(jid.toString(), accountJid);
|
||||
return conversation?.encrypted ?? prefs.enableOmemoByDefault;
|
||||
}
|
||||
|
||||
/// Send a chat state [state] to [jid], if certain pre-conditions are met:
|
||||
/// - We have a network connection
|
||||
/// - Sending chat markers/states are enabled
|
||||
/// - [jid] != '' (not the self-chat)
|
||||
/// [type] is the type of chat the chat state should be sent within.
|
||||
Future<void> sendChatState(
|
||||
ConversationType type,
|
||||
String jid,
|
||||
ChatState state,
|
||||
) async {
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
|
||||
// Only send chat states if the users wants to send them
|
||||
if (!prefs.sendChatMarkers) return;
|
||||
|
||||
// Only send chat states when we're connected
|
||||
// TODO(Unknown): Maybe queue it up intelligently
|
||||
if (!(await GetIt.I.get<ConnectivityService>().hasConnection())) return;
|
||||
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
|
||||
if (jid != '') {
|
||||
await conn
|
||||
.getManagerById<ChatStateManager>(chatStateManager)!
|
||||
.sendChatState(
|
||||
state,
|
||||
jid,
|
||||
messageType: type.toMessageType(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/cryptography/types.dart';
|
||||
|
||||
@@ -20,24 +19,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 +57,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 +75,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 +93,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 +104,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,21 @@ 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 notificationsTable = 'Notifications';
|
||||
const groupchatTable = 'Groupchat';
|
||||
|
||||
const typeString = 0;
|
||||
const typeInt = 1;
|
||||
|
||||
@@ -1,4 +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';
|
||||
|
||||
@@ -11,8 +12,10 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $xmppStateTable (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
key TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
value TEXT,
|
||||
PRIMARY KEY (key, accountJid)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -20,71 +23,152 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $messagesTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
accountJid TEXT NOT NULL,
|
||||
sender TEXT NOT NULL,
|
||||
body TEXT,
|
||||
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,
|
||||
quote_id 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,
|
||||
occupantId TEXT,
|
||||
pseudoMessageType INTEGER,
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id),
|
||||
CONSTRAINT fk_quote
|
||||
FOREIGN KEY (quote_id)
|
||||
REFERENCES $messagesTable (id)
|
||||
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 (
|
||||
accountJid TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
senderJid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
PRIMARY KEY (accountJid, 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, accountJid, senderJid)',
|
||||
);
|
||||
|
||||
// Notifications
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $notificationsTable (
|
||||
id INTEGER NOT NULL,
|
||||
conversationJid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
sender TEXT,
|
||||
senderJid TEXT,
|
||||
avatarPath TEXT,
|
||||
body TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
path TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
PRIMARY KEY (id, conversationJid, senderJid, timestamp, accountJid)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_notifications ON $notificationsTable (conversationJid, accountJid)',
|
||||
);
|
||||
|
||||
// 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,
|
||||
accountJid TEXT NOT NULL,
|
||||
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,
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId INTEGER,
|
||||
lastMessageId TEXT,
|
||||
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)
|
||||
PRIMARY KEY (jid, accountJid),
|
||||
CONSTRAINT fk_last_message
|
||||
FOREIGN KEY (lastMessageId)
|
||||
REFERENCES $messagesTable (id),
|
||||
CONSTRAINT fk_contact_id
|
||||
FOREIGN KEY (contactId)
|
||||
REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid, accountJid)',
|
||||
);
|
||||
|
||||
// Contacts
|
||||
await db.execute(
|
||||
@@ -92,21 +176,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)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -114,10 +183,10 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $rosterTable (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
jid TEXT NOT NULL,
|
||||
accountJid 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,
|
||||
@@ -125,7 +194,9 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
pseudoRosterItem INTEGER NOT NULL,
|
||||
CONSTRAINT fk_contact_id FOREIGN KEY (contactId) REFERENCES $contactsTable (id)
|
||||
CONSTRAINT fk_contact_id
|
||||
FOREIGN KEY (contactId)
|
||||
REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
@@ -134,19 +205,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(
|
||||
@@ -157,7 +223,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
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -165,7 +232,9 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $blocklistTable (
|
||||
jid TEXT PRIMARY KEY
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
PRIMARY KEY (accountJid, jid)
|
||||
);
|
||||
''',
|
||||
);
|
||||
@@ -173,72 +242,62 @@ 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,
|
||||
jid TEXT NOT NULL,
|
||||
dhs TEXT NOT NULL,
|
||||
dhs_pub TEXT NOT NULL,
|
||||
dhr TEXT,
|
||||
rk TEXT NOT NULL,
|
||||
cks TEXT,
|
||||
ckr TEXT,
|
||||
ns INTEGER NOT NULL,
|
||||
nr INTEGER NOT NULL,
|
||||
pn INTEGER NOT NULL,
|
||||
ik_pub TEXT NOT NULL,
|
||||
session_ad TEXT NOT NULL,
|
||||
acknowledged INTEGER NOT NULL,
|
||||
mkskipped TEXT NOT NULL,
|
||||
kex_timestamp INTEGER NOT NULL,
|
||||
kex TEXT,
|
||||
PRIMARY KEY (jid, id)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustCacheTable (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
trust INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustDeviceListTable (
|
||||
jid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustEnableListTable (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
enabled INTEGER NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceTable (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
ikPub TEXT NOT NULL,
|
||||
ik TEXT NOT NULL,
|
||||
spkPub TEXT NOT NULL,
|
||||
spk TEXT NOT NULL,
|
||||
spkId INTEGER NOT NULL,
|
||||
spkSig TEXT NOT NULL,
|
||||
oldSpkPub TEXT,
|
||||
oldSpk TEXT,
|
||||
oldSpkId INTEGER,
|
||||
opks TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoDeviceListTable (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
accountJid TEXT NOT NULL,
|
||||
devices TEXT NOT NULL,
|
||||
PRIMARY KEY (accountJid, jid)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoFingerprintCache (
|
||||
CREATE TABLE $omemoRatchetsTable (
|
||||
jid TEXT NOT NULL,
|
||||
id INTEGER NOT NULL,
|
||||
fingerprint TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, id)
|
||||
accountJid 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 (accountJid, jid, device)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $omemoTrustTable (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL,
|
||||
trust INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
PRIMARY KEY (accountJid, jid, device)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -251,6 +310,22 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
value TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
|
||||
// Groupchat
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $groupchatTable (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
nick TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, accountJid),
|
||||
CONSTRAINT fk_groupchat
|
||||
FOREIGN KEY (jid, accountJid)
|
||||
REFERENCES $conversationsTable (jid, accountJid)
|
||||
ON DELETE CASCADE
|
||||
)''',
|
||||
);
|
||||
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
@@ -315,14 +390,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(
|
||||
@@ -427,4 +494,12 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
'true',
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'showDebugMenu',
|
||||
typeBool,
|
||||
boolToString(false),
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,3 +7,17 @@ bool stringToBool(String s) => s == 'true' ? true : false;
|
||||
|
||||
String intToString(int i) => '$i';
|
||||
int stringToInt(String s) => int.parse(s);
|
||||
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
55
lib/service/database/migration.dart
Normal file
55
lib/service/database/migration.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A function to be called when a migration should be performed.
|
||||
typedef MigrationCallback<T> = Future<void> Function(T);
|
||||
|
||||
/// This class represents a single database migration.
|
||||
class Migration<T> {
|
||||
const Migration(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 MigrationCallback<T> migration;
|
||||
}
|
||||
|
||||
/// Given the migration [param], which is passed to every migration, 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 param,
|
||||
List<Migration<T>> migrations,
|
||||
int version,
|
||||
String typeName, {
|
||||
Future<void> Function(int)? commitVersion,
|
||||
}) async {
|
||||
final sortedMigrations = List<Migration<T>>.from(migrations)
|
||||
..sort(
|
||||
(a, b) => a.version.compareTo(b.version),
|
||||
);
|
||||
var currentVersion = version;
|
||||
var hasRunMigration = false;
|
||||
for (final migration in sortedMigrations) {
|
||||
if (version < migration.version) {
|
||||
log.info(
|
||||
'Running $typeName migration $currentVersion -> ${migration.version}',
|
||||
);
|
||||
await migration.migration(param);
|
||||
currentVersion = migration.version;
|
||||
hasRunMigration = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Commit the version, if specified.
|
||||
if (commitVersion != null && hasRunMigration) {
|
||||
log.info('Committing migration version $currentVersion');
|
||||
await commitVersion(currentVersion);
|
||||
}
|
||||
}
|
||||
@@ -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";',
|
||||
);
|
||||
}
|
||||
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
15
lib/service/database/migrations/0001_debug_menu.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/preference.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV25ToV26(Database db) async {
|
||||
await db.insert(
|
||||
preferenceTable,
|
||||
Preference(
|
||||
'showDebugMenu',
|
||||
typeBool,
|
||||
boolToString(false),
|
||||
).toDatabaseJson(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV28ToV29(Database db) async {
|
||||
await db.delete(
|
||||
preferenceTable,
|
||||
where: 'key = "autoAcceptSubscriptionRequests"',
|
||||
);
|
||||
}
|
||||
9
lib/service/database/migrations/0001_subscriptions.dart
Normal file
9
lib/service/database/migrations/0001_subscriptions.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV26ToV27(Database db) async {
|
||||
await db.execute('''
|
||||
CREATE TABLE $subscriptionsTable(
|
||||
jid TEXT PRIMARY KEY
|
||||
)''');
|
||||
}
|
||||
226
lib/service/database/migrations/0002_file_metadata_table.dart
Normal file
226
lib/service/database/migrations/0002_file_metadata_table.dart
Normal file
@@ -0,0 +1,226 @@
|
||||
import 'dart:convert';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV31ToV32(Database db) async {
|
||||
// Create the tracking table
|
||||
await db.execute('''
|
||||
CREATE TABLE $fileMetadataTable (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
path TEXT,
|
||||
sourceUrls TEXT,
|
||||
mimeType TEXT,
|
||||
thumbnailType TEXT,
|
||||
thumbnailData TEXT,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
plaintextHashes TEXT,
|
||||
encryptionKey TEXT,
|
||||
encryptionIv TEXT,
|
||||
encryptionScheme TEXT,
|
||||
cipherTextHashes TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER
|
||||
)''');
|
||||
await db.execute('''
|
||||
CREATE TABLE $fileMetadataHashesTable (
|
||||
algorithm TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
CONSTRAINT f_primarykey PRIMARY KEY (algorithm, value),
|
||||
CONSTRAINT fk_id FOREIGN KEY (id) REFERENCES $fileMetadataTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
|
||||
// Add the file_metadata_id column
|
||||
await db.execute(
|
||||
'ALTER TABLE $messagesTable ADD COLUMN file_metadata_id TEXT DEFAULT NULL;',
|
||||
);
|
||||
|
||||
// Migrate the media messages' attributes to new table
|
||||
final messages = await db.query(
|
||||
messagesTable,
|
||||
where: 'isMedia = ${boolToInt(true)}',
|
||||
);
|
||||
for (final message in messages) {
|
||||
// Do we know of a hash?
|
||||
String id;
|
||||
if (message['plaintextHashes'] != null) {
|
||||
// Plaintext hashes available (SFS)
|
||||
final plaintextHashes = deserializeHashMap(
|
||||
message['plaintextHashes']! as String,
|
||||
);
|
||||
final result = await db.query(
|
||||
fileMetadataHashesTable,
|
||||
where: 'algorithm = ? AND value = ?',
|
||||
whereArgs: [
|
||||
plaintextHashes.entries.first.key,
|
||||
plaintextHashes.entries.first.value,
|
||||
],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (result.isEmpty) {
|
||||
final metadata = FileMetadata(
|
||||
getStrongestHashFromMap(plaintextHashes) ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
message['mediaUrl'] as String?,
|
||||
message['srcUrl'] != null ? [message['srcUrl']! as String] : null,
|
||||
message['mediaType'] as String?,
|
||||
message['mediaSize'] as int?,
|
||||
message['thumbnailData'] != null ? 'blurhash' : null,
|
||||
message['thumbnailData'] as String?,
|
||||
message['mediaWidth'] as int?,
|
||||
message['mediaHeight'] as int?,
|
||||
plaintextHashes,
|
||||
message['key'] as String?,
|
||||
message['iv'] as String?,
|
||||
message['encryptionScheme'] as String?,
|
||||
message['plaintextHashes'] == null
|
||||
? null
|
||||
: deserializeHashMap(message['ciphertextHashes']! as String),
|
||||
message['filename']! as String,
|
||||
);
|
||||
|
||||
// Create the metadata
|
||||
await db.insert(
|
||||
fileMetadataTable,
|
||||
metadata.toDatabaseJson(),
|
||||
);
|
||||
id = metadata.id;
|
||||
} else {
|
||||
id = result[0]['id']! as String;
|
||||
}
|
||||
} else {
|
||||
// No plaintext hashes are available (OOB data)
|
||||
int? size;
|
||||
int? height;
|
||||
int? width;
|
||||
Map<HashFunction, String>? hashes;
|
||||
String? filePath;
|
||||
String? urlSource;
|
||||
String? mediaType;
|
||||
String? filename;
|
||||
if (message['filename'] == null) {
|
||||
// We are dealing with a sticker
|
||||
assert(
|
||||
message['stickerPackId'] != null,
|
||||
'The message must contain a sticker',
|
||||
);
|
||||
assert(
|
||||
message['stickerHashKey'] != null,
|
||||
'The message must contain a sticker',
|
||||
);
|
||||
final sticker = (await db.query(
|
||||
stickersTable,
|
||||
where: 'stickerPackId = ? AND hashKey = ?',
|
||||
whereArgs: [message['stickerPackId'], message['stickerHashKey']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
size = sticker['size']! as int;
|
||||
width = sticker['width'] as int?;
|
||||
height = sticker['height'] as int?;
|
||||
hashes = deserializeHashMap(sticker['hashes']! as String);
|
||||
filePath = sticker['path']! as String;
|
||||
urlSource =
|
||||
((jsonDecode(sticker['urlSources']! as String) as List<dynamic>)
|
||||
.cast<String>())
|
||||
.first;
|
||||
mediaType = sticker['mediaType']! as String;
|
||||
filename = path.basename(sticker['path']! as String);
|
||||
} else {
|
||||
size = message['mediaSize'] as int?;
|
||||
width = message['mediaWidth'] as int?;
|
||||
height = message['mediaHeight'] as int?;
|
||||
filePath = message['mediaUrl'] as String?;
|
||||
urlSource = message['srcUrl'] as String?;
|
||||
mediaType = message['mediaType'] as String?;
|
||||
filename = message['filename'] as String?;
|
||||
}
|
||||
|
||||
final metadata = FileMetadata(
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
filePath,
|
||||
urlSource != null ? [urlSource] : null,
|
||||
mediaType,
|
||||
size,
|
||||
message['thumbnailData'] != null ? 'blurhash' : null,
|
||||
message['thumbnailData'] as String?,
|
||||
width,
|
||||
height,
|
||||
hashes,
|
||||
message['key'] as String?,
|
||||
message['iv'] as String?,
|
||||
message['encryptionScheme'] as String?,
|
||||
null,
|
||||
filename!,
|
||||
);
|
||||
|
||||
// Create the metadata
|
||||
await db.insert(
|
||||
fileMetadataTable,
|
||||
metadata.toDatabaseJson(),
|
||||
);
|
||||
id = metadata.id;
|
||||
}
|
||||
|
||||
// Update the message
|
||||
await db.update(
|
||||
messagesTable,
|
||||
{
|
||||
'file_metadata_id': id,
|
||||
},
|
||||
where: 'id = ?',
|
||||
whereArgs: [message['id']],
|
||||
);
|
||||
}
|
||||
|
||||
// Remove columns and add foreign key
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${messagesTable}_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sender TEXT NOT NULL,
|
||||
body TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
sid TEXT NOT NULL,
|
||||
conversationJid TEXT NOT NULL,
|
||||
isFileUploadNotification INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
errorType INTEGER,
|
||||
warningType INTEGER,
|
||||
received INTEGER,
|
||||
displayed INTEGER,
|
||||
acked INTEGER,
|
||||
originId TEXT,
|
||||
quote_id INTEGER,
|
||||
file_metadata_id TEXT,
|
||||
isDownloading INTEGER NOT NULL,
|
||||
isUploading INTEGER NOT NULL,
|
||||
isRetracted INTEGER,
|
||||
isEdited INTEGER NOT NULL,
|
||||
reactions TEXT NOT NULL,
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
stickerPackId TEXT,
|
||||
stickerHashKey TEXT,
|
||||
pseudoMessageType INTEGER,
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote FOREIGN KEY (quote_id) REFERENCES $messagesTable (id)
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
await db.execute(
|
||||
'INSERT INTO ${messagesTable}_new SELECT id, sender, body, timestamp, sid, conversationJid, isFileUploadNotification, encrypted, errorType, warningType, received, displayed, acked, originId, quote_id, file_metadata_id, isDownloading, isUploading, isRetracted, isEdited, reactions, containsNoStore, stickerPackId, stickerHashKey, pseudoMessageType, pseudoMessageData FROM $messagesTable',
|
||||
);
|
||||
await db.execute('DROP TABLE $messagesTable');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${messagesTable}_new RENAME TO $messagesTable;',
|
||||
);
|
||||
}
|
||||
24
lib/service/database/migrations/0002_indices.dart
Normal file
24
lib/service/database/migrations/0002_indices.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV36ToV37(Database db) async {
|
||||
// Queries against messages by id (and sid/originId happen regularly)
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_messages_id ON $messagesTable (id, sid, originId)',
|
||||
);
|
||||
|
||||
// Conversations are often queried by their jid
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid)',
|
||||
);
|
||||
|
||||
// Reactions must be quickly queried
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_reactions_message_id ON $reactionsTable (message_id, senderJid)',
|
||||
);
|
||||
|
||||
// File metadata should also be quickly queriable by its id
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_file_metadata_message_id ON $fileMetadataTable (id)',
|
||||
);
|
||||
}
|
||||
60
lib/service/database/migrations/0002_reactions.dart
Normal file
60
lib/service/database/migrations/0002_reactions.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'dart:convert';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV34ToV35(Database db) async {
|
||||
// Create the table
|
||||
await db.execute('''
|
||||
CREATE TABLE $reactionsTable (
|
||||
senderJid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
|
||||
// Figure out our JID
|
||||
final rawJid = await db.query(
|
||||
xmppStateTable,
|
||||
where: "key = 'jid'",
|
||||
limit: 1,
|
||||
);
|
||||
String? jid;
|
||||
if (rawJid.isNotEmpty) {
|
||||
jid = rawJid.first['value']! as String;
|
||||
}
|
||||
|
||||
// Migrate messages
|
||||
final messages = await db.query(
|
||||
messagesTable,
|
||||
where: "reactions IS NOT '[]'",
|
||||
);
|
||||
for (final message in messages) {
|
||||
final reactions =
|
||||
(jsonDecode(message['reactions']! as String) as List<dynamic>)
|
||||
.cast<Map<String, Object?>>();
|
||||
|
||||
for (final reaction in reactions) {
|
||||
final senders = [
|
||||
...reaction['senders']! as List<String>,
|
||||
if (intToBool(reaction['reactedBySelf']! as int) && jid != null) jid,
|
||||
];
|
||||
|
||||
for (final sender in senders) {
|
||||
await db.insert(
|
||||
reactionsTable,
|
||||
{
|
||||
'senderJid': sender,
|
||||
'emoji': reaction['emoji']! as String,
|
||||
'message_id': message['id']! as int,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the column
|
||||
await db.execute('ALTER TABLE $messagesTable DROP COLUMN reactions');
|
||||
}
|
||||
15
lib/service/database/migrations/0002_reactions_2.dart
Normal file
15
lib/service/database/migrations/0002_reactions_2.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV35ToV36(Database db) async {
|
||||
await db.execute('DROP TABLE $reactionsTable');
|
||||
await db.execute('''
|
||||
CREATE TABLE $reactionsTable (
|
||||
senderJid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
message_id INTEGER NOT NULL,
|
||||
CONSTRAINT pk_sender PRIMARY KEY (senderJid, emoji, message_id),
|
||||
CONSTRAINT fk_message FOREIGN KEY (message_id) REFERENCES $messagesTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''');
|
||||
}
|
||||
14
lib/service/database/migrations/0002_shared_media.dart
Normal file
14
lib/service/database/migrations/0002_shared_media.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV33ToV34(Database db) async {
|
||||
// Remove the shared media counter...
|
||||
await db.execute(
|
||||
'ALTER TABLE $conversationsTable DROP COLUMN sharedMediaAmount',
|
||||
);
|
||||
|
||||
// ... and the entire table.
|
||||
await db.execute(
|
||||
'DROP TABLE $mediaTable',
|
||||
);
|
||||
}
|
||||
113
lib/service/database/migrations/0002_sticker_metadata.dart
Normal file
113
lib/service/database/migrations/0002_sticker_metadata.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
import 'dart:convert';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV32ToV33(Database db) async {
|
||||
final stickers = await db.query(stickersTable);
|
||||
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${stickersTable}_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
desc TEXT NOT NULL,
|
||||
suggests TEXT NOT NULL,
|
||||
file_metadata_id TEXT NOT NULL,
|
||||
stickerPackId TEXT NOT NULL,
|
||||
CONSTRAINT fk_sticker_pack FOREIGN KEY (stickerPackId) REFERENCES $stickerPacksTable (id)
|
||||
ON DELETE CASCADE,
|
||||
CONSTRAINT fk_file_metadata FOREIGN KEY (file_metadata_id) REFERENCES $fileMetadataTable (id)
|
||||
)''',
|
||||
);
|
||||
|
||||
// Mapping stickerHashKey -> fileMetadataId
|
||||
final stickerHashMap = <String, String>{};
|
||||
for (final sticker in stickers) {
|
||||
final hashes =
|
||||
(jsonDecode(sticker['hashes']! as String) as Map<String, dynamic>)
|
||||
.cast<String, String>();
|
||||
|
||||
final buffer = StringBuffer();
|
||||
for (var i = 0; i < hashes.length; i++) {
|
||||
buffer.write('(algorithm = ? AND value = ?) AND');
|
||||
}
|
||||
final query = buffer.toString();
|
||||
|
||||
final rawFm = await db.query(
|
||||
fileMetadataHashesTable,
|
||||
where: query.substring(0, query.length - 1 - 3),
|
||||
whereArgs: hashes.entries
|
||||
.map<List<String>>((entry) => [entry.key, entry.value])
|
||||
.flattened
|
||||
.toList(),
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
String fileMetadataId;
|
||||
if (rawFm.isEmpty) {
|
||||
// Create the metadata
|
||||
fileMetadataId = DateTime.now().toString();
|
||||
await db.insert(
|
||||
fileMetadataTable,
|
||||
{
|
||||
'id': fileMetadataId,
|
||||
'path': sticker['path']! as String,
|
||||
'size': sticker['size']! as int,
|
||||
'width': sticker['width'] as int?,
|
||||
'height': sticker['height'] as int?,
|
||||
'plaintextHashes': sticker['hashes']! as String,
|
||||
'mimeType': sticker['mediaType']! as String,
|
||||
'sourceUrls': sticker['urlSources'],
|
||||
'filename': path.basename(sticker['path']! as String),
|
||||
},
|
||||
);
|
||||
|
||||
// Create hash pointers
|
||||
for (final hashEntry in hashes.entries) {
|
||||
await db.insert(
|
||||
fileMetadataHashesTable,
|
||||
{
|
||||
'algorithm': hashEntry.key,
|
||||
'value': hashEntry.value,
|
||||
'id': fileMetadataId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
fileMetadataId = rawFm.first['id']! as String;
|
||||
}
|
||||
|
||||
final hashKey = sticker['hashKey']! as String;
|
||||
stickerHashMap[hashKey] = fileMetadataId;
|
||||
await db.insert(
|
||||
'${stickersTable}_new',
|
||||
{
|
||||
'id': hashKey,
|
||||
'desc': sticker['desc']! as String,
|
||||
'suggests': sticker['suggests']! as String,
|
||||
'file_metadata_id': fileMetadataId,
|
||||
'stickerPackId': sticker['stickerPackId']! as String,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Rename the table
|
||||
await db.execute('DROP TABLE $stickersTable');
|
||||
await db.execute('ALTER TABLE ${stickersTable}_new RENAME TO $stickersTable');
|
||||
|
||||
// Migrate messages
|
||||
for (final stickerEntry in stickerHashMap.entries) {
|
||||
await db.update(
|
||||
messagesTable,
|
||||
{
|
||||
'file_metadata_id': stickerEntry.value,
|
||||
},
|
||||
where: 'stickerHashKey = ?',
|
||||
whereArgs: [stickerEntry.key],
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the hash key from messages
|
||||
await db.execute('ALTER TABLE $messagesTable DROP COLUMN stickerHashKey');
|
||||
}
|
||||
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',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV44ToV45(Database db) async {
|
||||
await db.update(
|
||||
messagesTable,
|
||||
{
|
||||
'errorType': null,
|
||||
'warningType': MessageWarningType.chatEncryptedButFilePlaintext.value,
|
||||
},
|
||||
where: 'errorType = ?',
|
||||
// NOTE: 10 is the old id of this error
|
||||
whereArgs: [10],
|
||||
);
|
||||
}
|
||||
12
lib/service/database/migrations/0003_groupchat_table.dart
Normal file
12
lib/service/database/migrations/0003_groupchat_table.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV42ToV43(Database db) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $groupchatTable (
|
||||
jid TEXT PRIMARY KEY,
|
||||
nick TEXT NOT NULL
|
||||
)''',
|
||||
);
|
||||
}
|
||||
428
lib/service/database/migrations/0003_jid_attribute.dart
Normal file
428
lib/service/database/migrations/0003_jid_attribute.dart
Normal file
@@ -0,0 +1,428 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
extension MaybeGet<K, V> on Map<K, V> {
|
||||
V? maybeGet(K? key) {
|
||||
if (key == null) return null;
|
||||
|
||||
return this[key];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> upgradeFromV45ToV46(Database db) async {
|
||||
// Migrate everything to the tuple of (account JID, <old pk>)
|
||||
// Things we do not migrate to this scheme:
|
||||
// - Stickers: Technically, makes no sense
|
||||
// - File metadata: We want to aggresively cache, so we keep it
|
||||
|
||||
// Get the account JID
|
||||
final rawJid = await db.query(
|
||||
xmppStateTable,
|
||||
where: 'key = ?',
|
||||
whereArgs: ['jid'],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
// [migrateRows] indicates whether we can move the data to the new JID-annotated format.
|
||||
// It's false if we don't have a "logged in" JID. If we have one, it's true and we can
|
||||
// move data.
|
||||
final migrateRows = rawJid.isNotEmpty;
|
||||
final accountJid = migrateRows ? rawJid.first['value']! as String : null;
|
||||
|
||||
// Store the account JID in the secure storage.
|
||||
if (migrateRows) {
|
||||
await GetIt.I.get<XmppStateService>().setAccountJid(accountJid!);
|
||||
}
|
||||
|
||||
// Migrate the XMPP state
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${xmppStateTable}_new (
|
||||
key TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
value TEXT,
|
||||
PRIMARY KEY (key, accountJid)
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final statePair in await db.query(xmppStateTable)) {
|
||||
await db.insert(
|
||||
'${xmppStateTable}_new',
|
||||
{
|
||||
...statePair,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $xmppStateTable');
|
||||
await db
|
||||
.execute('ALTER TABLE ${xmppStateTable}_new RENAME TO $xmppStateTable');
|
||||
|
||||
// Migrate messages
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${messagesTable}_new (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
accountJid TEXT NOT NULL,
|
||||
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 TEXT,
|
||||
file_metadata_id TEXT,
|
||||
isDownloading INTEGER NOT NULL,
|
||||
isUploading INTEGER NOT NULL,
|
||||
isRetracted INTEGER,
|
||||
isEdited INTEGER NOT NULL,
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
stickerPackId TEXT,
|
||||
pseudoMessageType INTEGER,
|
||||
pseudoMessageData TEXT,
|
||||
CONSTRAINT fk_quote
|
||||
FOREIGN KEY (quote_id)
|
||||
REFERENCES $messagesTable (id)
|
||||
CONSTRAINT fk_file_metadata
|
||||
FOREIGN KEY (file_metadata_id)
|
||||
REFERENCES $fileMetadataTable (id)
|
||||
)''',
|
||||
);
|
||||
// Build up the message map
|
||||
/// Message's old id attribute -> Message's new UUID attribute.
|
||||
const uuid = Uuid();
|
||||
final messageMap = <int, String>{};
|
||||
|
||||
if (migrateRows) {
|
||||
final messages = await db.query(messagesTable);
|
||||
for (final message in messages) {
|
||||
messageMap[message['id']! as int] = uuid.v4();
|
||||
}
|
||||
// Then migrate messages
|
||||
for (final message in messages) {
|
||||
await db.insert('${messagesTable}_new', {
|
||||
...Map.from(message)
|
||||
..remove('id')
|
||||
..remove('quote_id'),
|
||||
'accountJid': accountJid,
|
||||
'quote_id': messageMap.maybeGet(message['quote_id'] as int?),
|
||||
'id': messageMap[message['id']! as int],
|
||||
});
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $messagesTable');
|
||||
await db.execute('ALTER TABLE ${messagesTable}_new RENAME TO $messagesTable');
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_messages_sid ON $messagesTable (accountJid, sid)',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_messages_origin_sid ON $messagesTable (accountJid, originId, sid)',
|
||||
);
|
||||
|
||||
// Migrate conversations
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${conversationsTable}_new (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
avatarPath TEXT NOT NULL,
|
||||
avatarHash TEXT,
|
||||
type TEXT NOT NULL,
|
||||
lastChangeTimestamp INTEGER NOT NULL,
|
||||
unreadCounter INTEGER NOT NULL,
|
||||
open INTEGER NOT NULL,
|
||||
muted INTEGER NOT NULL,
|
||||
encrypted INTEGER NOT NULL,
|
||||
lastMessageId TEXT,
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
PRIMARY KEY (jid, accountJid),
|
||||
CONSTRAINT fk_last_message
|
||||
FOREIGN KEY (lastMessageId)
|
||||
REFERENCES $messagesTable (id),
|
||||
CONSTRAINT fk_contact_id
|
||||
FOREIGN KEY (contactId)
|
||||
REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final conversation in await db.query(conversationsTable)) {
|
||||
await db.insert(
|
||||
'${conversationsTable}_new',
|
||||
{
|
||||
...Map.from(conversation)..remove('lastMessageId'),
|
||||
'lastMessageId':
|
||||
messageMap.maybeGet(conversation['lastMessageId'] as int?),
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $conversationsTable');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${conversationsTable}_new RENAME TO $conversationsTable',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_conversation_id ON $conversationsTable (accountJid, jid)',
|
||||
);
|
||||
|
||||
// Migrate groupchat details
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${groupchatTable}_new (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
nick TEXT NOT NULL,
|
||||
PRIMARY KEY (jid, accountJid),
|
||||
CONSTRAINT fk_groupchat
|
||||
FOREIGN KEY (jid, accountJid)
|
||||
REFERENCES $conversationsTable (jid, accountJid)
|
||||
ON DELETE CASCADE
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final g in await db.query(groupchatTable)) {
|
||||
await db.insert(
|
||||
'${groupchatTable}_new',
|
||||
{
|
||||
...g,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $groupchatTable');
|
||||
await db
|
||||
.execute('ALTER TABLE ${groupchatTable}_new RENAME TO $groupchatTable');
|
||||
|
||||
// Migrate reactions
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${reactionsTable}_new (
|
||||
accountJid TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
senderJid TEXT NOT NULL,
|
||||
emoji TEXT NOT NULL,
|
||||
PRIMARY KEY (accountJid, senderJid, emoji, message_id),
|
||||
CONSTRAINT fk_message
|
||||
FOREIGN KEY (message_id)
|
||||
REFERENCES $messagesTable (id)
|
||||
ON DELETE CASCADE
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final reaction in await db.query(reactionsTable)) {
|
||||
await db.insert(
|
||||
'${reactionsTable}_new',
|
||||
{
|
||||
...Map.from(reaction)..remove('message_id'),
|
||||
'message_id': messageMap.maybeGet(reaction['message_id']! as int),
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $reactionsTable');
|
||||
await db
|
||||
.execute('ALTER TABLE ${reactionsTable}_new RENAME TO $reactionsTable');
|
||||
|
||||
// Migrate the roster
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${rosterTable}_new (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
avatarPath TEXT NOT NULL,
|
||||
avatarHash TEXT NOT NULL,
|
||||
subscription TEXT NOT NULL,
|
||||
ask TEXT NOT NULL,
|
||||
contactId TEXT,
|
||||
contactAvatarPath TEXT,
|
||||
contactDisplayName TEXT,
|
||||
pseudoRosterItem INTEGER NOT NULL,
|
||||
CONSTRAINT fk_contact_id
|
||||
FOREIGN KEY (contactId)
|
||||
REFERENCES $contactsTable (id)
|
||||
ON DELETE SET NULL
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final rosterItem in await db.query(rosterTable)) {
|
||||
await db.insert(
|
||||
'${rosterTable}_new',
|
||||
{
|
||||
...Map.from(rosterItem)..remove('id'),
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $rosterTable');
|
||||
await db.execute('ALTER TABLE ${rosterTable}_new RENAME TO $rosterTable');
|
||||
|
||||
// Migrate the blocklist
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${blocklistTable}_new (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
PRIMARY KEY (accountJid, jid)
|
||||
);
|
||||
''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final blocklistItem in await db.query(blocklistTable)) {
|
||||
await db.insert(
|
||||
'${blocklistTable}_new',
|
||||
{
|
||||
...blocklistItem,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $blocklistTable');
|
||||
await db
|
||||
.execute('ALTER TABLE ${blocklistTable}_new RENAME TO $blocklistTable');
|
||||
|
||||
// Migrate the notifications list
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${notificationsTable}_new (
|
||||
id INTEGER NOT NULL,
|
||||
conversationJid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
sender TEXT,
|
||||
senderJid TEXT,
|
||||
avatarPath TEXT,
|
||||
body TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
path TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
PRIMARY KEY (id, conversationJid, senderJid, timestamp, accountJid)
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final notification in await db.query(notificationsTable)) {
|
||||
await db.insert(
|
||||
'${notificationsTable}_new',
|
||||
{
|
||||
...notification,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $notificationsTable');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${notificationsTable}_new RENAME TO $notificationsTable',
|
||||
);
|
||||
|
||||
// Migrate OMEMO device list
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${omemoDeviceListTable}_new (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
devices TEXT NOT NULL,
|
||||
PRIMARY KEY (accountJid, jid)
|
||||
)''',
|
||||
);
|
||||
{
|
||||
for (final deviceListEntry in await db.query(omemoDeviceListTable)) {
|
||||
await db.insert(
|
||||
'${omemoDeviceListTable}_new',
|
||||
{
|
||||
...deviceListEntry,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $omemoDeviceListTable');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${omemoDeviceListTable}_new RENAME TO $omemoDeviceListTable',
|
||||
);
|
||||
|
||||
// Migrate OMEMO trust
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${omemoTrustTable}_new (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid TEXT NOT NULL,
|
||||
device INTEGER NOT NULL,
|
||||
trust INTEGER NOT NULL,
|
||||
enabled INTEGER NOT NULL,
|
||||
PRIMARY KEY (accountJid, jid, device)
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final trustItem in await db.query(omemoTrustTable)) {
|
||||
await db.insert(
|
||||
'${omemoTrustTable}_new',
|
||||
{
|
||||
...trustItem,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $omemoTrustTable');
|
||||
await db
|
||||
.execute('ALTER TABLE ${omemoTrustTable}_new RENAME TO $omemoTrustTable');
|
||||
|
||||
// Migrate OMEMO ratchets
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE ${omemoRatchetsTable}_new (
|
||||
jid TEXT NOT NULL,
|
||||
accountJid 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 (accountJid, jid, device)
|
||||
)''',
|
||||
);
|
||||
if (migrateRows) {
|
||||
for (final ratchet in await db.query(omemoRatchetsTable)) {
|
||||
await db.insert(
|
||||
'${omemoRatchetsTable}_new',
|
||||
{
|
||||
...ratchet,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
await db.execute('DROP TABLE $omemoRatchetsTable');
|
||||
await db.execute(
|
||||
'ALTER TABLE ${omemoRatchetsTable}_new RENAME TO $omemoRatchetsTable',
|
||||
);
|
||||
}
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
23
lib/service/database/migrations/0003_notifications.dart
Normal file
23
lib/service/database/migrations/0003_notifications.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV43ToV44(Database db) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $notificationsTable (
|
||||
id INTEGER NOT NULL,
|
||||
conversationJid TEXT NOT NULL,
|
||||
sender TEXT,
|
||||
senderJid TEXT,
|
||||
avatarPath TEXT,
|
||||
body TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
path TEXT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
PRIMARY KEY (id, conversationJid, senderJid, timestamp)
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'CREATE INDEX idx_notifications ON $notificationsTable (conversationJid)',
|
||||
);
|
||||
}
|
||||
8
lib/service/database/migrations/0003_occupant_id.dart
Normal file
8
lib/service/database/migrations/0003_occupant_id.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
Future<void> upgradeFromV46ToV47(Database db) async {
|
||||
await db.execute(
|
||||
'ALTER TABLE $messagesTable ADD COLUMN occupantId TEXT',
|
||||
);
|
||||
}
|
||||
@@ -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
361
lib/service/files.dart
Normal file
361
lib/service/files.dart
Normal file
@@ -0,0 +1,361 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxplatform/moxplatform.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:moxxyv2/shared/thumbnails/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
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 MoxplatformPlugin.platform.getPersistentDataPath(),
|
||||
'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');
|
||||
}
|
||||
|
||||
if (metadata.mimeType?.startsWith('video/') ?? false) {
|
||||
final thumbnailPath = await getVideoThumbnailPath(metadata.path!);
|
||||
final thumbnailFile = File(thumbnailPath);
|
||||
if (thumbnailFile.existsSync()) {
|
||||
try {
|
||||
await thumbnailFile.delete();
|
||||
} catch (ex) {
|
||||
_log.warning(
|
||||
'Failed to remove thumbnail file $thumbnailPath: $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);
|
||||
}
|
||||
}
|
||||
104
lib/service/groupchat.dart
Normal file
104
lib/service/groupchat.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
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/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/models/groupchat.dart';
|
||||
|
||||
class GroupchatService {
|
||||
/// Retrieves the information about a group chat room specified by the given
|
||||
/// JID.
|
||||
/// Returns a [Future] that resolves to a [RoomInformation] object containing
|
||||
/// details about the room.
|
||||
Future<Result<RoomInformation, MUCError>> getRoomInformation(
|
||||
JID roomJID,
|
||||
) async {
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final mm = conn.getManagerById<MUCManager>(mucManager)!;
|
||||
final result = await mm.queryRoomInformation(roomJID);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Joins a group chat room specified by the given MUC JID and a nickname.
|
||||
/// Returns a [Future] that resolves to a [GroupchatDetails] object
|
||||
/// representing the details of the joined room.
|
||||
/// Throws an exception of type [GroupchatErrorType.roomPasswordProtected]
|
||||
/// if the room requires a password for entry.
|
||||
Future<Result<GroupchatDetails, GroupchatErrorType>> joinRoom(
|
||||
JID muc,
|
||||
String accountJid,
|
||||
String nick,
|
||||
) async {
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final mm = conn.getManagerById<MUCManager>(mucManager)!;
|
||||
final roomInformationResult = await getRoomInformation(muc);
|
||||
if (roomInformationResult.isType<RoomInformation>()) {
|
||||
final roomPasswordProtected = roomInformationResult
|
||||
.get<RoomInformation>()
|
||||
.features
|
||||
.contains('muc_passwordprotected');
|
||||
if (roomPasswordProtected) {
|
||||
return const Result(GroupchatErrorType.roomPasswordProtected);
|
||||
}
|
||||
final result = await mm.joinRoom(muc, nick);
|
||||
if (result.isType<MUCError>()) {
|
||||
return Result(
|
||||
GroupchatErrorType.fromException(
|
||||
result.get<MUCError>(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return Result(
|
||||
GroupchatDetails(
|
||||
muc.toBare().toString(),
|
||||
accountJid,
|
||||
nick,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Result(
|
||||
GroupchatErrorType.fromException(
|
||||
roomInformationResult.get<MUCError>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates and adds group chat details to the database based on the provided
|
||||
/// JID, nickname, and title.
|
||||
/// Returns a [Future] that resolves to a [GroupchatDetails] object
|
||||
/// representing the added group chat details.
|
||||
Future<GroupchatDetails> addGroupchatDetailsFromData(
|
||||
String jid,
|
||||
String accountJid,
|
||||
String nick,
|
||||
) async {
|
||||
final groupchatDetails = GroupchatDetails(jid, accountJid, nick);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
groupchatTable,
|
||||
groupchatDetails.toJson(),
|
||||
);
|
||||
|
||||
return groupchatDetails;
|
||||
}
|
||||
|
||||
/// Retrieves group chat details from the database based on the provided JID.
|
||||
///
|
||||
/// Returns a [Future] that resolves to a [GroupchatDetails] object if found,
|
||||
/// or `null` if no matching details are found.
|
||||
Future<GroupchatDetails?> getGroupchatDetailsByJid(
|
||||
String jid,
|
||||
String accountJid,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final groupchatDetailsRaw = await db.query(
|
||||
groupchatTable,
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
if (groupchatDetailsRaw.isEmpty) return null;
|
||||
return GroupchatDetails.fromDatabaseJson(groupchatDetailsRaw[0]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
146
lib/service/httpfiletransfer/client.dart
Normal file
146
lib/service/httpfiletransfer/client.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/helpers.dart';
|
||||
|
||||
typedef ProgressCallback = void Function(int total, int current);
|
||||
|
||||
@immutable
|
||||
class HttpPeekResult {
|
||||
const HttpPeekResult(this.contentType, this.contentLength);
|
||||
final String? contentType;
|
||||
final int? contentLength;
|
||||
}
|
||||
|
||||
/// Download the file found at [uri] into the file [destination]. [onProgress] is
|
||||
/// called whenever new data has been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> downloadFile(
|
||||
Uri uri,
|
||||
String destination,
|
||||
ProgressCallback onProgress,
|
||||
) async {
|
||||
// TODO(Unknown): How do we close fileSink? Do we have to?
|
||||
IOSink? fileSink;
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.getUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return resp.statusCode;
|
||||
}
|
||||
|
||||
// The size of the remote file
|
||||
final length = resp.contentLength;
|
||||
|
||||
fileSink = File(destination).openWrite(mode: FileMode.append);
|
||||
var bytes = 0;
|
||||
final downloadCompleter = Completer<void>();
|
||||
unawaited(
|
||||
resp
|
||||
.transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
downloadCompleter.complete();
|
||||
},
|
||||
),
|
||||
)
|
||||
.pipe(fileSink),
|
||||
);
|
||||
|
||||
// Wait for the download to complete
|
||||
await downloadCompleter.future;
|
||||
client.close(force: true);
|
||||
//await fileSink.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
//await fileSink?.close();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the file found at [filePath] to [destination]. [headers] are HTTP headers
|
||||
/// that are added to the PUT request. [onProgress] is called whenever new data has
|
||||
/// been downloaded.
|
||||
///
|
||||
/// Returns the status code if the server responded. If an error occurs, returns null.
|
||||
Future<int?> uploadFile(
|
||||
Uri destination,
|
||||
Map<String, String> headers,
|
||||
String filePath,
|
||||
ProgressCallback onProgress,
|
||||
) async {
|
||||
final client = HttpClient();
|
||||
try {
|
||||
final req = await client.putUrl(destination);
|
||||
final file = File(filePath);
|
||||
final length = await file.length();
|
||||
req.contentLength = length;
|
||||
|
||||
// Set all known headers
|
||||
headers.forEach((headerName, headerValue) {
|
||||
req.headers.set(headerName, headerValue);
|
||||
});
|
||||
|
||||
var bytes = 0;
|
||||
final stream = file.openRead().transform(
|
||||
StreamTransformer<List<int>, List<int>>.fromHandlers(
|
||||
handleData: (data, sink) {
|
||||
bytes += data.length;
|
||||
onProgress(length, bytes);
|
||||
|
||||
sink.add(data);
|
||||
},
|
||||
handleDone: (sink) {
|
||||
sink.close();
|
||||
},
|
||||
),
|
||||
);
|
||||
await req.addStream(stream);
|
||||
final resp = await req.close();
|
||||
|
||||
return resp.statusCode;
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends a HEAD request to [uri].
|
||||
///
|
||||
/// Returns the content type and content length if the server responded. If an error
|
||||
/// occurs, returns null.
|
||||
Future<HttpPeekResult?> peekUrl(Uri uri) async {
|
||||
final client = HttpClient();
|
||||
|
||||
try {
|
||||
final req = await client.headUrl(uri);
|
||||
final resp = await req.close();
|
||||
|
||||
if (!isRequestOkay(resp.statusCode)) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
|
||||
client.close(force: true);
|
||||
final contentType = resp.headers['Content-Type'];
|
||||
return HttpPeekResult(
|
||||
contentType != null && contentType.isNotEmpty ? contentType.first : null,
|
||||
resp.contentLength,
|
||||
);
|
||||
} catch (ex) {
|
||||
client.close(force: true);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
|
||||
/// Calculates the path for a given file to be saved to and, if neccessary, create it.
|
||||
Future<String> getDownloadPath(String filename, String conversationJid, String? mime) async {
|
||||
String type;
|
||||
var prependMoxxy = true;
|
||||
if (mime != null && ['image/', 'video/'].any((e) => mime.startsWith(e))) {
|
||||
type = ExternalPath.DIRECTORY_PICTURES;
|
||||
} else {
|
||||
type = ExternalPath.DIRECTORY_DOWNLOADS;
|
||||
prependMoxxy = false;
|
||||
}
|
||||
|
||||
final externalDir = await ExternalPath.getExternalStoragePublicDirectory(type);
|
||||
final fileDirectory = prependMoxxy ? path.join(externalDir, 'Moxxy', conversationJid) : externalDir;
|
||||
final dir = Directory(fileDirectory);
|
||||
if (!dir.existsSync()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
|
||||
var i = 0;
|
||||
while (true) {
|
||||
final filenameSuffix = i == 0 ? '' : '($i)';
|
||||
final suffixedFilename = filenameWithSuffix(filename, filenameSuffix);
|
||||
|
||||
final filePath = path.join(fileDirectory, suffixedFilename);
|
||||
if (!File(filePath).existsSync()) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
}
|
||||
import 'package:moxxyv2/service/httpfiletransfer/client.dart';
|
||||
|
||||
/// Returns true if the request was successful based on [statusCode].
|
||||
/// Based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Status
|
||||
@@ -42,9 +6,8 @@ bool isRequestOkay(int? statusCode) {
|
||||
return statusCode != null && statusCode >= 200 && statusCode <= 399;
|
||||
}
|
||||
|
||||
class FileMetadata {
|
||||
|
||||
const FileMetadata({ this.mime, this.size });
|
||||
class FileUploadMetadata {
|
||||
const FileUploadMetadata({this.mime, this.size});
|
||||
final String? mime;
|
||||
final int? size;
|
||||
}
|
||||
@@ -52,16 +15,11 @@ class FileMetadata {
|
||||
/// Returns the size of the file at [url] in octets. If an error occurs or the server
|
||||
/// does not specify the Content-Length header, null is returned.
|
||||
/// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Length
|
||||
Future<FileMetadata> peekFile(String url) async {
|
||||
final response = await Dio().headUri<dynamic>(Uri.parse(url));
|
||||
Future<FileUploadMetadata> peekFile(String url) async {
|
||||
final result = await peekUrl(Uri.parse(url));
|
||||
|
||||
if (!isRequestOkay(response.statusCode)) return const FileMetadata();
|
||||
|
||||
final contentLengthHeaders = response.headers['Content-Length'];
|
||||
final contentTypeHeaders = response.headers['Content-Type'];
|
||||
|
||||
return FileMetadata(
|
||||
mime: contentTypeHeaders?.first,
|
||||
size: contentLengthHeaders != null && contentLengthHeaders.isNotEmpty ? int.parse(contentLengthHeaders.first) : null,
|
||||
return FileUploadMetadata(
|
||||
mime: result?.contentType,
|
||||
size: result?.contentLength,
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user