Compare commits
303 Commits
| 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 |
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)\((service|ui|shared|all|tests|i18n|docs|flake)+(,(service|ui|shared|all|tests|i18n|docs|flake))*\)|release): [A-Z0-9].*$
|
||||
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]
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
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.
|
||||
@@ -30,7 +35,8 @@ 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.
|
||||
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`.
|
||||
@@ -51,7 +57,28 @@ Before creating a pull request, please make sure you checked every item on the f
|
||||
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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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/moxxy).
|
||||
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)
|
||||
|
||||
@@ -19,6 +19,12 @@ For build and contribution guidelines, please refer to [`CONTRIBUTING.md`](./CON
|
||||
|
||||
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
|
||||
|
||||
@@ -14,3 +14,4 @@ analyzer:
|
||||
- "**/*.freezed.dart"
|
||||
- "**/*.moxxy.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>
|
||||
@@ -26,6 +26,6 @@ subprojects {
|
||||
project.evaluationDependsOn(':app')
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
@@ -1,354 +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"
|
||||
},
|
||||
"titles": {
|
||||
"error": "Error"
|
||||
}
|
||||
},
|
||||
"dateTime": {
|
||||
"justNow": "Just now",
|
||||
"nMinutesAgo": "${min}min ago",
|
||||
"mondayAbbrev": "Mon",
|
||||
"tuesdayAbbrev": "Tue",
|
||||
"wednessdayAbbrev": "Wed",
|
||||
"thursdayAbbrev": "Thu",
|
||||
"fridayAbbrev": "Fri",
|
||||
"saturdayAbbrev": "Sat",
|
||||
"sundayAbbrev": "Sun",
|
||||
"january": "January",
|
||||
"february": "February",
|
||||
"march": "March",
|
||||
"april": "April",
|
||||
"may": "May",
|
||||
"june": "June",
|
||||
"july": "July",
|
||||
"august": "August",
|
||||
"september": "September",
|
||||
"october": "October",
|
||||
"november": "November",
|
||||
"december": "December",
|
||||
"today": "Today",
|
||||
"yesterday": "Yesterday"
|
||||
},
|
||||
"messages": {
|
||||
"image": "Image",
|
||||
"video": "Video",
|
||||
"audio": "Audio",
|
||||
"file": "File",
|
||||
"sticker": "Sticker",
|
||||
"retracted": "The message has been retracted",
|
||||
"retractedFallback": "A previous message has been retracted but your client does not support it",
|
||||
"you": "You"
|
||||
},
|
||||
"errors": {
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"addReaction": "Add reaction",
|
||||
"showError": "Show error",
|
||||
"showWarning": "Show warning",
|
||||
"addToContacts": "Add to contacts",
|
||||
"addToContactsTitle": "Add ${jid} to contacts",
|
||||
"addToContactsBody": "Are you sure you want to add ${jid} to your contacts?",
|
||||
"stickerPickerNoStickersLine1": "You have no sticker packs installed.",
|
||||
"stickerPickerNoStickersLine2": "They can be installed in the sticker settings.",
|
||||
"stickerSettings": "Sticker settings",
|
||||
"newDeviceMessage": "${title} added a new encryption device",
|
||||
"messageHint": "Send a message...",
|
||||
"sendImages": "Send images",
|
||||
"sendFiles": "Send files",
|
||||
"takePhotos": "Take photos"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Add new contact",
|
||||
"xmppAddress": "XMPP-Address",
|
||||
"subtitle": "You can add a contact either by typing in their XMPP address or by scanning their QR code",
|
||||
"buttonAddToContact": "Add to contacts"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Start new chat",
|
||||
"addContact": "Add contact",
|
||||
"createGroupchat": "Create groupchat"
|
||||
},
|
||||
"crop": {
|
||||
"setProfilePicture": "Set as profile picture"
|
||||
},
|
||||
"shareselection": {
|
||||
"shareWith": "Share with...",
|
||||
"confirmTitle": "Send file",
|
||||
"confirmBody": "One or more chats are unencrypted. This means that the file will be leaked to the server. Do you still want to continue?"
|
||||
},
|
||||
"profile": {
|
||||
"general": {
|
||||
"omemo": "Security",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"@@name": "Deutsch",
|
||||
"language": "Deutsch",
|
||||
"global": {
|
||||
"title": "Moxxy",
|
||||
"moxxySubtitle": "Ein Experiment im Entwickeln eines modernen, einfachen und schönen XMPP-Clients.",
|
||||
@@ -66,13 +66,18 @@
|
||||
"you": "Du"
|
||||
},
|
||||
"errors": {
|
||||
"general": {
|
||||
"noInternet": "Keine Internetverbindung."
|
||||
},
|
||||
"filePicker": {
|
||||
"permissionDenied": "Die Speicherberechtigung wurde nicht erteilt."
|
||||
},
|
||||
"omemo": {
|
||||
"couldNotPublish": "Konnte die kryptographische Identität nicht auf dem Server veröffentlichen. Ende-zu-Ende-Verschlüsselung funktioniert eventuell nicht.",
|
||||
"notEncryptedForDevice": "Die Nachricht wurde nicht für dieses Gerät verschlüsselt",
|
||||
"invalidHmac": "Die Nachricht konnte nicht entschlüsselt werden",
|
||||
"noDecryptionKey": "Kein Schlüssel zum Entschlüsseln vorhanden",
|
||||
"messageInvalidAfixElement": "Ungültige verschlüsselte Nachricht",
|
||||
|
||||
"verificationInvalidOmemoUrl": "Ungültiger OMEMO:2 Fingerabdruck",
|
||||
"verificationWrongJid": "Falsche XMPP-Addresse",
|
||||
"verificationWrongDevice": "Falsches OMEMO:2 Gerät",
|
||||
@@ -81,7 +86,7 @@
|
||||
},
|
||||
"connection": {
|
||||
"connectionTimeout": "Verbindung zum Server nicht möglich",
|
||||
"saslAccountDisabled": "Dein Account ist deaktiviert",
|
||||
"saslAccountDisabled": "Dein Konto ist deaktiviert",
|
||||
"saslInvalidCredentials": "Deine Anmeldedaten sind ungültig",
|
||||
"unrecoverable": "Verbindung zum Server durch nicht behebbaren Fehler verloren"
|
||||
},
|
||||
@@ -109,6 +114,11 @@
|
||||
"openFileNoAppError": "Keine App vorhanden, um die Datei zu öffnen",
|
||||
"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": {
|
||||
@@ -121,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"
|
||||
},
|
||||
@@ -130,7 +140,7 @@
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"password": "Passwort",
|
||||
"advancedOptions": "Fortgeschrittene Optionen",
|
||||
"createAccount": "Account auf dem Server erstellen"
|
||||
"createAccount": "Konto auf dem Server erstellen"
|
||||
},
|
||||
"conversations": {
|
||||
"speeddialNewChat": "Neuer chat",
|
||||
@@ -158,30 +168,39 @@
|
||||
"edit": "Bearbeiten",
|
||||
"quote": "Zitieren",
|
||||
"copy": "Inhalt kopieren",
|
||||
"messageCopied": "Nachrichteninhalt in die Zwischenablage kopiert",
|
||||
"addReaction": "Reaktion hinzufügen",
|
||||
"showError": "Fehler anzeigen",
|
||||
"showWarning": "Warnung anzeigen",
|
||||
"warning": "Warnung",
|
||||
"addToContacts": "Zu Kontaken hinzufügen",
|
||||
"addToContactsTitle": "${jid} zu Kontakten hinzufügen",
|
||||
"addToContactsBody": "Bist du dir sicher, dass du ${jid} zu deinen Kontakten hinzufügen möchtest?",
|
||||
"stickerPickerNoStickersLine1": "Du hast keine Stickerpacks installiert.",
|
||||
"stickerPickerNoStickersLine2": "Diese können in den Stickereinstellungen installiert werden.",
|
||||
"stickerSettings": "Stickereinstellungen",
|
||||
"newDeviceMessage": "${title} hat ein neues Verschlüsselungsgerät hinzugefügt",
|
||||
"newDeviceMessage": {
|
||||
"one": "Ein neues Gerät wurde hinzugefügt",
|
||||
"other": "Mehrere neue Geräte wurden hinzugefügt"
|
||||
},
|
||||
"replacedDeviceMessage": {
|
||||
"one": "Ein Gerät hat sich verändert",
|
||||
"other": "Mehrere Geräte haben sich verändert"
|
||||
},
|
||||
"messageHint": "Nachricht senden...",
|
||||
"sendImages": "Bilder senden",
|
||||
"sendFiles": "Dateien senden",
|
||||
"takePhotos": "Bilder aufnehmen"
|
||||
},
|
||||
"addcontact": {
|
||||
"title": "Neuen Kontakt hinzufügen",
|
||||
"startchat": {
|
||||
"title": "Neuer Chat",
|
||||
"xmppAddress": "XMPP-Adresse",
|
||||
"subtitle": "Du kannst einen Kontakt hinzufügen, indem Du entweder die XMPP-Adresse eingibst oder den QR-Code deines Kontaktes scannst",
|
||||
"buttonAddToContact": "Kontakt hinzufügen"
|
||||
"subtitle": "Du kannst einen neuen Chat beginnen, indem du entweder eine XMPP-Adresse eingibst oder einen QR-Code scannst.",
|
||||
"buttonAddToContact": "Neuen Chat beginnen"
|
||||
},
|
||||
"newconversation": {
|
||||
"title": "Neuer chat",
|
||||
"addContact": "Kontakt hinzufügen",
|
||||
"title": "Neuer Chat",
|
||||
"startChat": "Neuen Chat beginnen",
|
||||
"createGroupchat": "Gruppenchat erstellen"
|
||||
},
|
||||
"crop": {
|
||||
@@ -250,7 +269,7 @@
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"conversationsSection": "Unterhaltungen",
|
||||
"accountSection": "Account",
|
||||
"accountSection": "Konto",
|
||||
"signOut": "Abmelden",
|
||||
"signOutConfirmTitle": "Abmelden",
|
||||
"signOutConfirmBody": "Du bist dabei dich abzumelden. Fortfahren?",
|
||||
@@ -311,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": {
|
||||
@@ -347,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": "Пропустить"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
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
|
||||
192
flake.lock
generated
192
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,24 +114,103 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1676076353,
|
||||
"narHash": "sha256-mdUtE8Tp40cZETwcq5tCwwLqkJVV1ULJQ5GKRtbshag=",
|
||||
"owner": "AtaraxiaSjel",
|
||||
"lastModified": 1689679375,
|
||||
"narHash": "sha256-LHUC52WvyVDi9PwyL1QCpaxYWBqp4ir4iL6zgOkmcb8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "5deb99bdccbbb97e7562dee4ba8a3ee3021688e6",
|
||||
"rev": "684c17c429c42515bafb3ad775d2a710947f3d67",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "AtaraxiaSjel",
|
||||
"ref": "update/flutter",
|
||||
"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": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixpkgs-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"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
@@ -1,35 +1,52 @@
|
||||
{
|
||||
description = "Moxxy v2";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:AtaraxiaSjel/nixpkgs/update/flutter";
|
||||
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;
|
||||
};
|
||||
# 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}";
|
||||
};
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,8 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
state: String
|
||||
permissionsToRequest: List<int>
|
||||
requestNotificationPermission: bool
|
||||
excludeFromBatteryOptimisation: bool
|
||||
preferences:
|
||||
type: PreferencesState
|
||||
deserialise: true
|
||||
@@ -36,9 +37,6 @@ files:
|
||||
roster:
|
||||
type: List<RosterItem>?
|
||||
deserialise: true
|
||||
stickers:
|
||||
type: List<StickerPack>?
|
||||
deserialise: true
|
||||
# Triggered if a conversation has been added.
|
||||
# Also returned by [AddConversationCommand]
|
||||
- name: ConversationAddedEvent
|
||||
@@ -110,7 +108,7 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
id: int
|
||||
id: String
|
||||
progress: double?
|
||||
# Triggered by [RosterService] if we receive a roster push.
|
||||
- name: RosterDiffEvent
|
||||
@@ -208,7 +206,7 @@ files:
|
||||
attributes:
|
||||
conversationJid: String
|
||||
title: String
|
||||
avatarUrl: String
|
||||
avatarPath: String
|
||||
- name: StickerPackImportSuccessEvent
|
||||
extends: BackgroundEvent
|
||||
implements:
|
||||
@@ -274,6 +272,89 @@ files:
|
||||
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"
|
||||
@@ -322,7 +403,6 @@ files:
|
||||
type: Message?
|
||||
deserialise: true
|
||||
editSid: String?
|
||||
editId: int?
|
||||
currentConversationJid: String?
|
||||
- name: SendFilesCommand
|
||||
extends: BackgroundCommand
|
||||
@@ -401,6 +481,12 @@ files:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
jid: String
|
||||
- name: ExitConversationCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationType: String
|
||||
- name: SendChatStateCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -408,6 +494,7 @@ files:
|
||||
attributes:
|
||||
state: String
|
||||
jid: String
|
||||
conversationType: String
|
||||
- name: GetFeaturesCommand
|
||||
extends: BackgroundCommand
|
||||
implements:
|
||||
@@ -484,24 +571,21 @@ files:
|
||||
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
|
||||
@@ -567,7 +651,7 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
conversationJid: String
|
||||
conversationJid: String?
|
||||
olderThan: bool
|
||||
timestamp: int?
|
||||
- name: GetReactionsForMessageCommand
|
||||
@@ -575,7 +659,60 @@ files:
|
||||
implements:
|
||||
- JsonImplementation
|
||||
attributes:
|
||||
messageId: int
|
||||
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"
|
||||
|
||||
@@ -10,22 +10,24 @@ 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/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';
|
||||
@@ -35,7 +37,6 @@ 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,14 +58,22 @@ 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/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';
|
||||
@@ -83,7 +92,13 @@ 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) {
|
||||
@@ -95,7 +110,7 @@ void setupBlocs(GlobalKey<NavigatorState> navKey) {
|
||||
GetIt.I.registerSingleton<BlocklistBloc>(BlocklistBloc());
|
||||
GetIt.I.registerSingleton<ProfileBloc>(ProfileBloc());
|
||||
GetIt.I.registerSingleton<PreferencesBloc>(PreferencesBloc());
|
||||
GetIt.I.registerSingleton<AddContactBloc>(AddContactBloc());
|
||||
GetIt.I.registerSingleton<StartChatBloc>(StartChatBloc());
|
||||
GetIt.I.registerSingleton<CropBloc>(CropBloc());
|
||||
GetIt.I.registerSingleton<SendFilesBloc>(SendFilesBloc());
|
||||
GetIt.I.registerSingleton<CropBackgroundBloc>(CropBackgroundBloc());
|
||||
@@ -105,6 +120,8 @@ 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());
|
||||
}
|
||||
|
||||
void main() async {
|
||||
@@ -147,8 +164,8 @@ void main() async {
|
||||
BlocProvider<PreferencesBloc>(
|
||||
create: (_) => GetIt.I.get<PreferencesBloc>(),
|
||||
),
|
||||
BlocProvider<AddContactBloc>(
|
||||
create: (_) => GetIt.I.get<AddContactBloc>(),
|
||||
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),
|
||||
@@ -268,11 +291,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
case newConversationRoute:
|
||||
return NewConversationPage.route;
|
||||
case conversationRoute:
|
||||
final args = settings.arguments! as ConversationPageArguments;
|
||||
return PageTransition<dynamic>(
|
||||
type: PageTransitionType.rightToLeft,
|
||||
settings: settings,
|
||||
child: ConversationPage(
|
||||
conversationJid: settings.arguments! as String,
|
||||
conversationJid: args.conversationJid,
|
||||
initialText: args.initialText,
|
||||
conversationType: args.type,
|
||||
),
|
||||
);
|
||||
// case sharedMediaRoute:
|
||||
@@ -298,7 +324,11 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
case debuggingRoute:
|
||||
return DebuggingPage.route;
|
||||
case addContactRoute:
|
||||
return AddContactPage.route;
|
||||
return StartChatPage.route;
|
||||
case joinGroupchatRoute:
|
||||
return JoinGroupchatPage.getRoute(
|
||||
settings.arguments! as JoinGroupchatArguments,
|
||||
);
|
||||
case cropRoute:
|
||||
return CropPage.route;
|
||||
case sendFilesRoute:
|
||||
@@ -323,8 +353,14 @@ class MyAppState extends State<MyApp> with WidgetsBindingObserver {
|
||||
);
|
||||
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,11 +1,10 @@
|
||||
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';
|
||||
@@ -14,60 +13,107 @@ 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,
|
||||
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 conversation = await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
jid.toString(),
|
||||
accountJid,
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
jid,
|
||||
avatarUrl: avatarPath,
|
||||
jid.toString(),
|
||||
accountJid,
|
||||
avatarPath: avatarPath,
|
||||
avatarHash: hash,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -80,89 +126,23 @@ class AvatarService {
|
||||
|
||||
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.fromString(jid));
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar id via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
if (id == oldHash) return null;
|
||||
|
||||
final avatarResult = await am.getUserAvatar(jid);
|
||||
if (avatarResult.isType<AvatarError>()) {
|
||||
_log.warning('Failed to get avatar data via XEP-0084 for $jid');
|
||||
return null;
|
||||
}
|
||||
final avatar = avatarResult.get<UserAvatar>();
|
||||
|
||||
return _AvatarData(
|
||||
base64Decode(_cleanBase64String(avatar.base64)),
|
||||
avatar.hash,
|
||||
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.
|
||||
@@ -201,6 +181,7 @@ class AvatarService {
|
||||
imageSize.height.toInt(),
|
||||
// TODO(PapaTutuWawa): Maybe do a check here
|
||||
'image/png',
|
||||
null,
|
||||
),
|
||||
public,
|
||||
);
|
||||
@@ -213,38 +194,45 @@ class AvatarService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Like [requestAvatar], but fetches and processes the avatar for our own account.
|
||||
Future<void> requestOwnAvatar() async {
|
||||
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 xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.getXmppState();
|
||||
final jid = state.jid!;
|
||||
final idResult = await am.getAvatarId(JID.fromString(jid));
|
||||
if (idResult.isType<AvatarError>()) {
|
||||
_log.info('Error while getting latest avatar id for own avatar');
|
||||
return;
|
||||
}
|
||||
final id = idResult.get<String>();
|
||||
|
||||
if (id == state.avatarHash) return;
|
||||
|
||||
_log.info(
|
||||
'Mismatch between saved avatar data and server-side avatar data about ourself',
|
||||
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>()}',
|
||||
);
|
||||
final avatarDataResult = await am.getUserAvatar(jid);
|
||||
if (avatarDataResult.isType<AvatarError>()) {
|
||||
_log.severe('Failed to fetch our avatar');
|
||||
return;
|
||||
}
|
||||
final avatarData = avatarDataResult.get<UserAvatar>();
|
||||
final id = rawId.get<String>();
|
||||
|
||||
_log.info('Received data for our own avatar');
|
||||
if (id == state.avatarHash) {
|
||||
_log.finest('Not fetching avatar for $jid since the hashes are equal');
|
||||
return;
|
||||
}
|
||||
|
||||
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 xss.modifyXmppState(
|
||||
@@ -254,6 +242,9 @@ class AvatarService {
|
||||
),
|
||||
);
|
||||
|
||||
// Update our notification avatar
|
||||
await GetIt.I.get<NotificationsService>().maybeSetAvatarFromState();
|
||||
|
||||
sendEvent(SelfAvatarChangedEvent(path: avatarPath, hash: avatarData.hash));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 }
|
||||
@@ -16,19 +17,20 @@ class BlocklistService {
|
||||
bool? _supported;
|
||||
final Logger _log = Logger('BlocklistService');
|
||||
|
||||
Future<void> _removeBlocklistEntry(String jid) async {
|
||||
Future<void> _removeBlocklistEntry(String jid, String accountJid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
blocklistTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _addBlocklistEntry(String jid) async {
|
||||
Future<void> _addBlocklistEntry(String jid, String accountJid) async {
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
blocklistTable,
|
||||
{
|
||||
'jid': jid,
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -59,6 +61,7 @@ class BlocklistService {
|
||||
return;
|
||||
}
|
||||
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final blocklist = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
@@ -69,7 +72,7 @@ class BlocklistService {
|
||||
final removedItems = List<String>.empty(growable: true);
|
||||
for (final item in blocklist) {
|
||||
if (!_blocklist!.contains(item)) {
|
||||
await _addBlocklistEntry(item);
|
||||
await _addBlocklistEntry(item, accountJid!);
|
||||
_blocklist!.add(item);
|
||||
newItems.add(item);
|
||||
}
|
||||
@@ -78,7 +81,7 @@ class BlocklistService {
|
||||
// Diff the cache with the received blocklist
|
||||
for (final item in _blocklist!) {
|
||||
if (!blocklist.contains(item)) {
|
||||
await _removeBlocklistEntry(item);
|
||||
await _removeBlocklistEntry(item, accountJid!);
|
||||
_blocklist!.remove(item);
|
||||
removedItems.add(item);
|
||||
}
|
||||
@@ -98,10 +101,13 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
/// Returns the blocklist from the database
|
||||
Future<List<String>> getBlocklist() async {
|
||||
Future<List<String>> getBlocklist(String accountJid) async {
|
||||
if (_blocklist == null) {
|
||||
final blocklistRaw =
|
||||
await GetIt.I.get<DatabaseService>().database.query(blocklistTable);
|
||||
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) {
|
||||
@@ -129,6 +135,7 @@ 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) {
|
||||
@@ -139,7 +146,7 @@ class BlocklistService {
|
||||
_blocklist!.add(item);
|
||||
newBlocks.add(item);
|
||||
|
||||
await _addBlocklistEntry(item);
|
||||
await _addBlocklistEntry(item, accountJid!);
|
||||
}
|
||||
break;
|
||||
case BlockPushType.unblock:
|
||||
@@ -147,7 +154,7 @@ class BlocklistService {
|
||||
_blocklist!.removeWhere((i) => i == item);
|
||||
removedBlocks.add(item);
|
||||
|
||||
await _removeBlocklistEntry(item);
|
||||
await _removeBlocklistEntry(item, accountJid!);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -169,7 +176,10 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.add(jid);
|
||||
await _addBlocklistEntry(jid);
|
||||
await _addBlocklistEntry(
|
||||
jid,
|
||||
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
|
||||
);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
@@ -184,7 +194,10 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.remove(jid);
|
||||
await _removeBlocklistEntry(jid);
|
||||
await _removeBlocklistEntry(
|
||||
jid,
|
||||
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
|
||||
);
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getManagerById<BlockingManager>(blockingManager)!
|
||||
@@ -201,7 +214,11 @@ class BlocklistService {
|
||||
}
|
||||
|
||||
_blocklist!.clear();
|
||||
await GetIt.I.get<DatabaseService>().database.delete(blocklistTable);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
blocklistTable,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [await GetIt.I.get<XmppStateService>().getAccountJid()],
|
||||
);
|
||||
|
||||
return GetIt.I
|
||||
.get<XmppConnection>()
|
||||
|
||||
@@ -10,6 +10,7 @@ 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';
|
||||
@@ -41,19 +42,27 @@ class ContactsService {
|
||||
final Map<String, String?> _contactDisplayNames = {};
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (await _canUseContactIntegration()) {
|
||||
enableDatabaseListener();
|
||||
}
|
||||
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 {
|
||||
@@ -123,7 +132,6 @@ class ContactsService {
|
||||
Future<Map<String, String>> _getContactIds() async {
|
||||
if (_contactIds != null) return _contactIds!;
|
||||
|
||||
// TODO(Unknown): Can we just .cast<String, String>() here?
|
||||
_contactIds = Map<String, String>.fromEntries(
|
||||
(await GetIt.I.get<DatabaseService>().database.query(contactsTable)).map(
|
||||
(item) => MapEntry(
|
||||
@@ -185,6 +193,7 @@ class ContactsService {
|
||||
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);
|
||||
@@ -209,9 +218,11 @@ class ContactsService {
|
||||
// Remove the contact attributes from the conversation, if it existed
|
||||
final conversation = await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
accountJid!,
|
||||
update: (c) async {
|
||||
return cs.updateConversation(
|
||||
jid,
|
||||
accountJid,
|
||||
contactId: null,
|
||||
contactAvatarPath: null,
|
||||
contactDisplayName: null,
|
||||
@@ -227,15 +238,16 @@ class ContactsService {
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -272,11 +284,14 @@ class ContactsService {
|
||||
// Update a possibly existing conversation
|
||||
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,
|
||||
);
|
||||
},
|
||||
@@ -290,10 +305,11 @@ class ContactsService {
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -301,6 +317,7 @@ class ContactsService {
|
||||
modifiedRosterItems.add(newRosterItem);
|
||||
} else {
|
||||
final newRosterItem = await rs.addRosterItemFromData(
|
||||
accountJid,
|
||||
'',
|
||||
'',
|
||||
contact.jid,
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package: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/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';
|
||||
|
||||
@@ -26,19 +30,31 @@ class ConversationService {
|
||||
/// The lock for accessing _conversationCache
|
||||
final Lock _lock = Lock();
|
||||
|
||||
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 jid,
|
||||
String accountJid, {
|
||||
CreateConversationCallback? create,
|
||||
UpdateConversationCallback? update,
|
||||
PreRunConversationCallback? preRun,
|
||||
}) async {
|
||||
return _lock.synchronized(() async {
|
||||
final conversation = await _getConversationByJid(jid);
|
||||
final conversation = await _getConversationByJid(jid, accountJid);
|
||||
|
||||
// Pre run
|
||||
if (preRun != null) {
|
||||
@@ -62,34 +78,46 @@ class ConversationService {
|
||||
}
|
||||
|
||||
/// Loads all conversations from the database and adds them to the state and cache.
|
||||
Future<List<Conversation>> loadConversations() async {
|
||||
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);
|
||||
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 int,
|
||||
jid,
|
||||
c['lastMessageId']! as String,
|
||||
accountJid,
|
||||
queryReactionPreview: false,
|
||||
);
|
||||
}
|
||||
|
||||
GroupchatDetails? groupchatDetails;
|
||||
if (c['type'] == ConversationType.groupchat.value) {
|
||||
groupchatDetails = await gs.getGroupchatDetailsByJid(
|
||||
c['jid']! as String,
|
||||
accountJid,
|
||||
);
|
||||
}
|
||||
|
||||
tmp.add(
|
||||
Conversation.fromDatabaseJson(
|
||||
c,
|
||||
rosterItem != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
groupchatDetails,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -99,25 +127,32 @@ class ConversationService {
|
||||
|
||||
/// Wrapper around DatabaseService's loadConversations that adds the loaded
|
||||
/// to the cache.
|
||||
Future<void> _loadConversationsIfNeeded() async {
|
||||
Future<void> _loadConversationsIfNeeded(String accountJid) async {
|
||||
if (_conversationCache != null) return;
|
||||
|
||||
final conversations = await loadConversations();
|
||||
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) async {
|
||||
await _loadConversationsIfNeeded();
|
||||
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) async {
|
||||
return _lock.synchronized(() async => _getConversationByJid(jid));
|
||||
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
|
||||
@@ -131,20 +166,23 @@ class ConversationService {
|
||||
/// To prevent issues with the cache, only call from within
|
||||
/// [ConversationService.createOrUpdateConversation].
|
||||
Future<Conversation> updateConversation(
|
||||
String jid, {
|
||||
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 _getConversationByJid(jid))!;
|
||||
final conversation = (await _getConversationByJid(jid, accountJid))!;
|
||||
|
||||
final c = <String, dynamic>{};
|
||||
|
||||
@@ -160,8 +198,11 @@ class ConversationService {
|
||||
if (unreadCounter != null) {
|
||||
c['unreadCounter'] = unreadCounter;
|
||||
}
|
||||
if (avatarUrl != null) {
|
||||
c['avatarUrl'] = avatarUrl;
|
||||
if (avatarPath != null) {
|
||||
c['avatarPath'] = avatarPath;
|
||||
}
|
||||
if (avatarHash != notSpecified) {
|
||||
c['avatarHash'] = avatarHash as String?;
|
||||
}
|
||||
if (muted != null) {
|
||||
c['muted'] = boolToInt(muted);
|
||||
@@ -183,17 +224,17 @@ class ConversationService {
|
||||
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
|
||||
conversationsTable,
|
||||
c,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid, accountJid);
|
||||
var newConversation = Conversation.fromDatabaseJson(
|
||||
result,
|
||||
rosterItem != null,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
lastMessage,
|
||||
groupchatDetails,
|
||||
);
|
||||
|
||||
// Copy over the old lastMessage if a new one was not set
|
||||
@@ -212,10 +253,11 @@ class ConversationService {
|
||||
/// To prevent issues with the cache, only call from within
|
||||
/// [ConversationService.createOrUpdateConversation].
|
||||
Future<Conversation> addConversationFromData(
|
||||
String accountJid,
|
||||
String title,
|
||||
Message? lastMessage,
|
||||
ConversationType type,
|
||||
String avatarUrl,
|
||||
String avatarPath,
|
||||
String jid,
|
||||
int unreadCounter,
|
||||
int lastChangeTimestamp,
|
||||
@@ -225,20 +267,24 @@ class ConversationService {
|
||||
String? contactId,
|
||||
String? contactAvatarPath,
|
||||
String? contactDisplayName,
|
||||
GroupchatDetails? groupchatDetails,
|
||||
) async {
|
||||
final rosterItem =
|
||||
await GetIt.I.get<RosterService>().getRosterItemByJid(jid);
|
||||
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 != null && !rosterItem.pseudoRosterItem,
|
||||
rosterItem?.subscription ?? 'none',
|
||||
rosterItem?.showAddToRosterButton ?? true,
|
||||
muted,
|
||||
encrypted,
|
||||
ChatState.gone,
|
||||
@@ -255,6 +301,14 @@ class ConversationService {
|
||||
_conversationCache![newConversation.jid] = newConversation;
|
||||
}
|
||||
|
||||
if (type == ConversationType.groupchat && groupchatDetails != null) {
|
||||
await gs.addGroupchatDetailsFromData(
|
||||
jid,
|
||||
accountJid,
|
||||
groupchatDetails.nick,
|
||||
);
|
||||
}
|
||||
|
||||
return newConversation;
|
||||
}
|
||||
|
||||
@@ -263,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';
|
||||
|
||||
|
||||
@@ -3,13 +3,6 @@ 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';
|
||||
@@ -19,6 +12,12 @@ 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;
|
||||
|
||||
@@ -12,15 +12,19 @@ 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)
|
||||
)''',
|
||||
);
|
||||
|
||||
// Messages
|
||||
await db.execute('''
|
||||
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,
|
||||
@@ -34,7 +38,7 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
displayed INTEGER,
|
||||
acked INTEGER,
|
||||
originId TEXT,
|
||||
quote_id INTEGER,
|
||||
quote_id TEXT,
|
||||
file_metadata_id TEXT,
|
||||
isDownloading INTEGER NOT NULL,
|
||||
isUploading INTEGER NOT NULL,
|
||||
@@ -42,31 +46,64 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
isEdited INTEGER NOT NULL,
|
||||
containsNoStore INTEGER NOT NULL,
|
||||
stickerPackId TEXT,
|
||||
occupantId 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)
|
||||
)''');
|
||||
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('''
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $reactionsTable (
|
||||
accountJid TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
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)
|
||||
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, senderJid)',
|
||||
'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('''
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $fileMetadataTable (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
path TEXT,
|
||||
@@ -83,8 +120,10 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
cipherTextHashes TEXT,
|
||||
filename TEXT NOT NULL,
|
||||
size INTEGER
|
||||
)''');
|
||||
await db.execute('''
|
||||
)''',
|
||||
);
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $fileMetadataHashesTable (
|
||||
algorithm TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
@@ -92,7 +131,8 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
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)',
|
||||
);
|
||||
@@ -101,43 +141,52 @@ Future<void> createDatabase(Database db, int version) async {
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $conversationsTable (
|
||||
jid TEXT NOT NULL PRIMARY KEY,
|
||||
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)',
|
||||
'CREATE INDEX idx_conversation_id ON $conversationsTable (jid, accountJid)',
|
||||
);
|
||||
|
||||
// Contacts
|
||||
await db.execute('''
|
||||
await db.execute(
|
||||
'''
|
||||
CREATE TABLE $contactsTable (
|
||||
id TEXT PRIMARY KEY,
|
||||
jid TEXT NOT NULL
|
||||
)''');
|
||||
)''',
|
||||
);
|
||||
|
||||
// Roster
|
||||
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,
|
||||
@@ -145,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
|
||||
)''',
|
||||
);
|
||||
@@ -172,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
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -180,86 +232,72 @@ 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)
|
||||
);
|
||||
''',
|
||||
);
|
||||
|
||||
// Subscription requests
|
||||
await db.execute('''
|
||||
CREATE TABLE $subscriptionsTable(
|
||||
jid TEXT PRIMARY KEY
|
||||
)''');
|
||||
|
||||
// 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)
|
||||
)''',
|
||||
);
|
||||
|
||||
@@ -272,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(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:moxxyv2/service/database/creation.dart';
|
||||
@@ -41,13 +40,123 @@ import 'package:moxxyv2/service/database/migrations/0002_reactions.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0002_reactions_2.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0002_shared_media.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0002_sticker_metadata.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_avatar_hashes.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_file_transfer_error_to_warning.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_groupchat_table.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_jid_attribute.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_new_omemo.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_new_omemo_pseudo_messages.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_notifications.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_occupant_id.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_remove_subscriptions.dart';
|
||||
import 'package:moxxyv2/service/database/migrations/0003_sticker_pack_timestamp.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:path/path.dart' as path;
|
||||
import 'package:random_string/random_string.dart';
|
||||
// ignore: implementation_imports
|
||||
import 'package:sqflite_common/src/sql_builder.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
const databasePasswordKey = 'database_encryption_password';
|
||||
@internal
|
||||
const List<Migration<Database>> migrations = [
|
||||
Migration(2, upgradeFromV1ToV2),
|
||||
Migration(3, upgradeFromV2ToV3),
|
||||
Migration(4, upgradeFromV3ToV4),
|
||||
Migration(5, upgradeFromV4ToV5),
|
||||
Migration(6, upgradeFromV5ToV6),
|
||||
Migration(7, upgradeFromV6ToV7),
|
||||
Migration(8, upgradeFromV7ToV8),
|
||||
Migration(9, upgradeFromV8ToV9),
|
||||
Migration(10, upgradeFromV9ToV10),
|
||||
Migration(11, upgradeFromV10ToV11),
|
||||
Migration(12, upgradeFromV11ToV12),
|
||||
Migration(13, upgradeFromV12ToV13),
|
||||
Migration(14, upgradeFromV13ToV14),
|
||||
Migration(15, upgradeFromV14ToV15),
|
||||
Migration(16, upgradeFromV15ToV16),
|
||||
Migration(17, upgradeFromV16ToV17),
|
||||
Migration(18, upgradeFromV17ToV18),
|
||||
Migration(19, upgradeFromV18ToV19),
|
||||
Migration(20, upgradeFromV19ToV20),
|
||||
Migration(21, upgradeFromV20ToV21),
|
||||
Migration(22, upgradeFromV21ToV22),
|
||||
Migration(23, upgradeFromV22ToV23),
|
||||
Migration(24, upgradeFromV23ToV24),
|
||||
Migration(25, upgradeFromV24ToV25),
|
||||
Migration(26, upgradeFromV25ToV26),
|
||||
Migration(27, upgradeFromV26ToV27),
|
||||
Migration(28, upgradeFromV27ToV28),
|
||||
Migration(29, upgradeFromV28ToV29),
|
||||
Migration(30, upgradeFromV29ToV30),
|
||||
Migration(31, upgradeFromV30ToV31),
|
||||
Migration(32, upgradeFromV31ToV32),
|
||||
Migration(33, upgradeFromV32ToV33),
|
||||
Migration(34, upgradeFromV33ToV34),
|
||||
Migration(35, upgradeFromV34ToV35),
|
||||
Migration(36, upgradeFromV35ToV36),
|
||||
Migration(37, upgradeFromV36ToV37),
|
||||
Migration(38, upgradeFromV37ToV38),
|
||||
Migration(39, upgradeFromV38ToV39),
|
||||
Migration(40, upgradeFromV39ToV40),
|
||||
Migration(41, upgradeFromV40ToV41),
|
||||
Migration(42, upgradeFromV41ToV42),
|
||||
Migration(43, upgradeFromV42ToV43),
|
||||
Migration(44, upgradeFromV43ToV44),
|
||||
Migration(45, upgradeFromV44ToV45),
|
||||
Migration(46, upgradeFromV45ToV46),
|
||||
Migration(47, upgradeFromV46ToV47),
|
||||
];
|
||||
|
||||
class DatabaseService {
|
||||
/// Logger.
|
||||
final Logger _log = Logger('DatabaseService');
|
||||
|
||||
/// The database.
|
||||
late Database database;
|
||||
|
||||
Future<void> initialize() async {
|
||||
final dbPath = path.join(
|
||||
await getDatabasesPath(),
|
||||
'moxxy.db',
|
||||
);
|
||||
final dbPassword =
|
||||
await GetIt.I.get<XmppStateService>().getOrCreateDatabaseKey();
|
||||
|
||||
// Just some sanity checks
|
||||
final version = migrations.last.version;
|
||||
assert(
|
||||
migrations.every((migration) => migration.version <= version),
|
||||
"Every migration's version must be smaller or equal to the last version",
|
||||
);
|
||||
assert(
|
||||
migrations
|
||||
.sublist(0, migrations.length - 1)
|
||||
.every((migration) => migration.version < version),
|
||||
'The last migration must have the largest version',
|
||||
);
|
||||
|
||||
database = await openDatabase(
|
||||
dbPath,
|
||||
password: dbPassword,
|
||||
version: version,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
// keys in the onConfigure phase, but re-enable them here.
|
||||
// See https://github.com/tekartik/sqflite/issues/624#issuecomment-813324273
|
||||
// for the "solution".
|
||||
await db.execute('PRAGMA foreign_keys = OFF');
|
||||
},
|
||||
onOpen: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
await runMigrations(_log, db, migrations, oldVersion, 'database');
|
||||
},
|
||||
);
|
||||
|
||||
_log.finest('Database setup done');
|
||||
}
|
||||
}
|
||||
|
||||
extension DatabaseHelpers on Database {
|
||||
/// Count the number of rows in [table] where [where] with the arguments [whereArgs]
|
||||
@@ -65,6 +174,24 @@ extension DatabaseHelpers on Database {
|
||||
)!;
|
||||
}
|
||||
|
||||
/// Like insert but returns the affected row.
|
||||
Future<Map<String, Object?>> insertAndReturn(
|
||||
String table,
|
||||
Map<String, Object?> values,
|
||||
) async {
|
||||
final q = SqlBuilder.insert(
|
||||
table,
|
||||
values,
|
||||
);
|
||||
|
||||
final result = await rawQuery(
|
||||
'${q.sql} RETURNING *',
|
||||
q.arguments,
|
||||
);
|
||||
assert(result.length == 1, 'Only one row must be returned');
|
||||
return result.first;
|
||||
}
|
||||
|
||||
/// Like update but returns the affected row.
|
||||
Future<Map<String, Object?>> updateAndReturn(
|
||||
String table,
|
||||
@@ -86,119 +213,4 @@ extension DatabaseHelpers on Database {
|
||||
assert(result.length == 1, 'Only one row must be returned');
|
||||
return result.first;
|
||||
}
|
||||
|
||||
/// Like insert but returns the affected row.
|
||||
Future<Map<String, Object?>> insertAndReturn(
|
||||
String table,
|
||||
Map<String, Object?> values,
|
||||
) async {
|
||||
final q = SqlBuilder.insert(
|
||||
table,
|
||||
values,
|
||||
);
|
||||
|
||||
final result = await rawQuery(
|
||||
'${q.sql} RETURNING *',
|
||||
q.arguments,
|
||||
);
|
||||
assert(result.length == 1, 'Only one row must be returned');
|
||||
return result.first;
|
||||
}
|
||||
}
|
||||
|
||||
@internal
|
||||
const List<DatabaseMigration<Database>> migrations = [
|
||||
DatabaseMigration(2, upgradeFromV1ToV2),
|
||||
DatabaseMigration(3, upgradeFromV2ToV3),
|
||||
DatabaseMigration(4, upgradeFromV3ToV4),
|
||||
DatabaseMigration(5, upgradeFromV4ToV5),
|
||||
DatabaseMigration(6, upgradeFromV5ToV6),
|
||||
DatabaseMigration(7, upgradeFromV6ToV7),
|
||||
DatabaseMigration(8, upgradeFromV7ToV8),
|
||||
DatabaseMigration(9, upgradeFromV8ToV9),
|
||||
DatabaseMigration(10, upgradeFromV9ToV10),
|
||||
DatabaseMigration(11, upgradeFromV10ToV11),
|
||||
DatabaseMigration(12, upgradeFromV11ToV12),
|
||||
DatabaseMigration(13, upgradeFromV12ToV13),
|
||||
DatabaseMigration(14, upgradeFromV13ToV14),
|
||||
DatabaseMigration(15, upgradeFromV14ToV15),
|
||||
DatabaseMigration(16, upgradeFromV15ToV16),
|
||||
DatabaseMigration(17, upgradeFromV16ToV17),
|
||||
DatabaseMigration(18, upgradeFromV17ToV18),
|
||||
DatabaseMigration(19, upgradeFromV18ToV19),
|
||||
DatabaseMigration(20, upgradeFromV19ToV20),
|
||||
DatabaseMigration(21, upgradeFromV20ToV21),
|
||||
DatabaseMigration(22, upgradeFromV21ToV22),
|
||||
DatabaseMigration(23, upgradeFromV22ToV23),
|
||||
DatabaseMigration(24, upgradeFromV23ToV24),
|
||||
DatabaseMigration(25, upgradeFromV24ToV25),
|
||||
DatabaseMigration(26, upgradeFromV25ToV26),
|
||||
DatabaseMigration(27, upgradeFromV26ToV27),
|
||||
DatabaseMigration(28, upgradeFromV27ToV28),
|
||||
DatabaseMigration(29, upgradeFromV28ToV29),
|
||||
DatabaseMigration(30, upgradeFromV29ToV30),
|
||||
DatabaseMigration(31, upgradeFromV30ToV31),
|
||||
DatabaseMigration(32, upgradeFromV31ToV32),
|
||||
DatabaseMigration(33, upgradeFromV32ToV33),
|
||||
DatabaseMigration(34, upgradeFromV33ToV34),
|
||||
DatabaseMigration(35, upgradeFromV34ToV35),
|
||||
DatabaseMigration(36, upgradeFromV35ToV36),
|
||||
DatabaseMigration(37, upgradeFromV36ToV37),
|
||||
];
|
||||
|
||||
class DatabaseService {
|
||||
/// Secure storage for accesing the database encryption key.
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage(
|
||||
// TODO(Unknown): Set other options
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
/// Logger.
|
||||
final Logger _log = Logger('DatabaseService');
|
||||
|
||||
/// The database.
|
||||
late Database database;
|
||||
|
||||
Future<void> initialize() async {
|
||||
final dbPath = path.join(
|
||||
await getDatabasesPath(),
|
||||
'moxxy.db',
|
||||
);
|
||||
|
||||
String key;
|
||||
if (await _storage.containsKey(key: databasePasswordKey)) {
|
||||
_log.finest('Database encryption key found');
|
||||
key = (await _storage.read(key: databasePasswordKey))!;
|
||||
} else {
|
||||
_log.finest('Database encryption not key found. Generating it...');
|
||||
key = randomAlphaNumeric(
|
||||
40,
|
||||
provider: CoreRandomProvider.from(Random.secure()),
|
||||
);
|
||||
await _storage.write(key: databasePasswordKey, value: key);
|
||||
_log.finest('Key generation done...');
|
||||
}
|
||||
|
||||
database = await openDatabase(
|
||||
dbPath,
|
||||
password: key,
|
||||
version: 37,
|
||||
onCreate: createDatabase,
|
||||
onConfigure: (db) async {
|
||||
// In order to do schema changes during database upgrades, we disable foreign
|
||||
// keys in the onConfigure phase, but re-enable them here.
|
||||
// See https://github.com/tekartik/sqflite/issues/624#issuecomment-813324273
|
||||
// for the "solution".
|
||||
await db.execute('PRAGMA foreign_keys = OFF');
|
||||
},
|
||||
onOpen: (db) async {
|
||||
await db.execute('PRAGMA foreign_keys = ON');
|
||||
},
|
||||
onUpgrade: (db, oldVersion, newVersion) async {
|
||||
await runMigrations(_log, db, migrations, oldVersion);
|
||||
},
|
||||
);
|
||||
|
||||
_log.finest('Database setup done');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
|
||||
/// Conversion helpers for bool <-> int as sqlite has no "real" booleans
|
||||
int boolToInt(bool b) => b ? 1 : 0;
|
||||
bool intToBool(int i) => i == 0 ? false : true;
|
||||
@@ -10,32 +8,6 @@ bool stringToBool(String s) => s == 'true' ? true : false;
|
||||
String intToString(int i) => '$i';
|
||||
int stringToInt(String s) => int.parse(s);
|
||||
|
||||
String conversationTypeToString(ConversationType type) {
|
||||
switch (type) {
|
||||
case ConversationType.chat:
|
||||
{
|
||||
return 'chat';
|
||||
}
|
||||
case ConversationType.note:
|
||||
{
|
||||
return 'note';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConversationType stringToConversationType(String type) {
|
||||
switch (type) {
|
||||
case 'chat':
|
||||
{
|
||||
return ConversationType.chat;
|
||||
}
|
||||
default:
|
||||
{
|
||||
return ConversationType.note;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a map [map], extract all key-value pairs from [map] where the key starts with
|
||||
/// [prefix]. Combine those key-value pairs into a new map, where the leading [prefix]
|
||||
/// is removed from all key names.
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// A function to be called when a migration should be performed.
|
||||
typedef DatabaseMigrationCallback<T> = Future<void> Function(T);
|
||||
typedef MigrationCallback<T> = Future<void> Function(T);
|
||||
|
||||
/// This class represents a single database migration.
|
||||
class DatabaseMigration<T> {
|
||||
const DatabaseMigration(this.version, this.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 DatabaseMigrationCallback<T> migration;
|
||||
final MigrationCallback<T> migration;
|
||||
}
|
||||
|
||||
/// Given the database [db] with the current version [version], goes through the list of
|
||||
/// 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.
|
||||
///
|
||||
@@ -23,22 +24,32 @@ class DatabaseMigration<T> {
|
||||
/// database argument, just pass in whatever (the tests use an integer).
|
||||
Future<void> runMigrations<T>(
|
||||
Logger log,
|
||||
T db,
|
||||
List<DatabaseMigration<T>> migrations,
|
||||
T param,
|
||||
List<Migration<T>> migrations,
|
||||
int version,
|
||||
) async {
|
||||
final sortedMigrations = List<DatabaseMigration<T>>.from(migrations)
|
||||
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 database migration $currentVersion -> ${migration.version}',
|
||||
'Running $typeName migration $currentVersion -> ${migration.version}',
|
||||
);
|
||||
await migration.migration(db);
|
||||
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,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,
|
||||
|
||||
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
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -10,8 +11,9 @@ 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:path_provider/path_provider.dart';
|
||||
import 'package:sqflite_common/sql.dart';
|
||||
|
||||
/// A class for returning whether a file metadata element was just created or retrieved.
|
||||
class FileMetadataWrapper {
|
||||
@@ -51,7 +53,7 @@ Future<String> computeCachedPathForFile(
|
||||
Map<HashFunction, String>? hashes,
|
||||
) async {
|
||||
final basePath = path.join(
|
||||
(await getApplicationDocumentsDirectory()).path,
|
||||
await MoxplatformPlugin.platform.getPersistentDataPath(),
|
||||
'media',
|
||||
);
|
||||
final baseDir = Directory(basePath);
|
||||
@@ -67,7 +69,8 @@ Future<String> computeCachedPathForFile(
|
||||
return path.join(
|
||||
basePath,
|
||||
hash != null
|
||||
? '$hash.$ext'
|
||||
// NOTE: [ext] already includes a leading "."
|
||||
? '$hash$ext'
|
||||
: '$filename.${DateTime.now().millisecondsSinceEpoch}.$ext',
|
||||
);
|
||||
}
|
||||
@@ -89,6 +92,10 @@ class FilesService {
|
||||
'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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -317,6 +324,20 @@ class FilesService {
|
||||
} 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');
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -124,11 +124,7 @@ String getUnrecoverableErrorString(NonRecoverableErrorEvent event) {
|
||||
/// This information is complemented either the srcUrl or – if unavailable –
|
||||
/// by the body of the quoted message. For non-media messages, we always use
|
||||
/// the body as fallback.
|
||||
String? createFallbackBodyForQuotedMessage(Message? quotedMessage) {
|
||||
if (quotedMessage == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String createFallbackBodyForQuotedMessage(Message quotedMessage) {
|
||||
if (quotedMessage.isMedia) {
|
||||
// Create formatted size string, if size is stored
|
||||
String quoteMessageSize;
|
||||
|
||||
@@ -6,7 +6,6 @@ import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/connectivity.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
@@ -23,6 +22,7 @@ import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
@@ -127,9 +127,6 @@ class HttpFileTransferService {
|
||||
) async {
|
||||
if (!File(to).existsSync()) {
|
||||
await File(job.path).copy(to);
|
||||
|
||||
// Let the media scanner index the file
|
||||
MoxplatformPlugin.media.scanFile(to);
|
||||
} else {
|
||||
_log.finest(
|
||||
'Skipping file copy on upload as file is already at media location',
|
||||
@@ -137,21 +134,27 @@ class HttpFileTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _fileUploadFailed(FileUploadJob job, int error) async {
|
||||
Future<void> _fileUploadFailed(
|
||||
FileUploadJob job,
|
||||
MessageErrorType error,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
|
||||
// Notify UI of upload failure
|
||||
for (final recipient in job.recipients) {
|
||||
final m = job.messageMap[recipient]!;
|
||||
final msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
m.id,
|
||||
job.accountJid,
|
||||
errorType: error,
|
||||
isUploading: false,
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Update the conversation list
|
||||
final conversation = await cs.getConversationByJid(recipient);
|
||||
final conversation =
|
||||
await cs.getConversationByJid(recipient, job.accountJid);
|
||||
if (conversation?.lastMessage?.id == msg.id) {
|
||||
final newConversation = conversation!.copyWith(
|
||||
lastMessage: msg,
|
||||
@@ -190,7 +193,7 @@ class HttpFileTransferService {
|
||||
);
|
||||
} catch (ex) {
|
||||
_log.warning('Encrypting ${job.path} failed: $ex');
|
||||
await _fileUploadFailed(job, messageFailedToEncryptFile);
|
||||
await _fileUploadFailed(job, MessageErrorType.failedToEncryptFile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -209,7 +212,7 @@ class HttpFileTransferService {
|
||||
|
||||
if (slotResult.isType<HttpFileUploadError>()) {
|
||||
_log.severe('Failed to request upload slot for ${job.path}!');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||
return;
|
||||
}
|
||||
final slot = slotResult.get<HttpFileUploadSlot>();
|
||||
@@ -225,7 +228,7 @@ class HttpFileTransferService {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.messageMap.values.first.id,
|
||||
id: job.messageMap[job.recipients.first]!.id,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
@@ -236,7 +239,7 @@ class HttpFileTransferService {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
if (!isRequestOkay(uploadStatusCode)) {
|
||||
_log.severe('Upload failed due to status code $uploadStatusCode');
|
||||
await _fileUploadFailed(job, fileUploadFailedError);
|
||||
await _fileUploadFailed(job, MessageErrorType.fileUploadFailed);
|
||||
return;
|
||||
} else {
|
||||
_log.fine('Upload was successful');
|
||||
@@ -322,9 +325,11 @@ class HttpFileTransferService {
|
||||
const uuid = Uuid();
|
||||
for (final recipient in job.recipients) {
|
||||
// Notify UI of upload completion
|
||||
final m = job.messageMap[recipient]!;
|
||||
var msg = await ms.updateMessage(
|
||||
job.messageMap[recipient]!.id,
|
||||
errorType: noError,
|
||||
m.id,
|
||||
job.accountJid,
|
||||
errorType: null,
|
||||
isUploading: false,
|
||||
fileMetadata: metadata,
|
||||
);
|
||||
@@ -332,20 +337,20 @@ class HttpFileTransferService {
|
||||
final oldSid = msg.sid;
|
||||
msg = await ms.updateMessage(
|
||||
msg.id,
|
||||
job.accountJid,
|
||||
sid: uuid.v4(),
|
||||
originId: uuid.v4(),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
// Send the message to the recipient
|
||||
conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
MessageDetails(
|
||||
to: recipient,
|
||||
body: slot.getUrl,
|
||||
requestDeliveryReceipt: true,
|
||||
id: msg.sid,
|
||||
originId: msg.originId,
|
||||
sfs: StatelessFileSharingData(
|
||||
await conn.getManagerById<MessageManager>(messageManager)!.sendMessage(
|
||||
JID.fromString(recipient),
|
||||
TypedMap<StanzaHandlerExtension>.fromList([
|
||||
MessageBodyData(slot.getUrl),
|
||||
const MessageDeliveryReceiptData(true),
|
||||
StableIdData(msg.originId, null),
|
||||
StatelessFileSharingData(
|
||||
FileMetadataData(
|
||||
mediaType: job.mime,
|
||||
size: stat.size,
|
||||
@@ -353,11 +358,12 @@ class HttpFileTransferService {
|
||||
thumbnails: job.thumbnails,
|
||||
hashes: plaintextHashes,
|
||||
),
|
||||
<StatelessFileSharingSource>[source],
|
||||
),
|
||||
shouldEncrypt: job.encryptMap[recipient]!,
|
||||
funReplacement: oldSid,
|
||||
[source],
|
||||
includeOOBFallback: true,
|
||||
),
|
||||
FileUploadNotificationReplacementData(oldSid),
|
||||
MessageIdData(msg.sid),
|
||||
]),
|
||||
);
|
||||
_log.finest(
|
||||
'Sent message with file upload for ${job.path} to $recipient',
|
||||
@@ -390,12 +396,16 @@ class HttpFileTransferService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _fileDownloadFailed(FileDownloadJob job, int error) async {
|
||||
Future<void> _fileDownloadFailed(
|
||||
FileDownloadJob job,
|
||||
MessageErrorType error,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
|
||||
// Notify UI of download failure
|
||||
final msg = await ms.updateMessage(
|
||||
job.mId,
|
||||
job.messageId,
|
||||
job.accountJid,
|
||||
errorType: error,
|
||||
isDownloading: false,
|
||||
);
|
||||
@@ -436,7 +446,7 @@ class HttpFileTransferService {
|
||||
final progress = current.toDouble() / total.toDouble();
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
id: job.messageId,
|
||||
progress: progress == 1 ? 0.99 : progress,
|
||||
),
|
||||
);
|
||||
@@ -451,7 +461,7 @@ class HttpFileTransferService {
|
||||
_log.warning(
|
||||
'HTTP GET of $downloadUrl returned $downloadStatusCode',
|
||||
);
|
||||
await _fileDownloadFailed(job, fileDownloadFailedError);
|
||||
await _fileDownloadFailed(job, MessageErrorType.fileDownloadFailed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -462,7 +472,7 @@ class HttpFileTransferService {
|
||||
// The file was downloaded and is now being decrypted
|
||||
sendEvent(
|
||||
ProgressEvent(
|
||||
id: job.mId,
|
||||
id: job.messageId,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -479,7 +489,7 @@ class HttpFileTransferService {
|
||||
|
||||
if (!result.decryptionOkay) {
|
||||
_log.warning('Failed to decrypt $downloadPath');
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -488,7 +498,7 @@ class HttpFileTransferService {
|
||||
_log.warning(
|
||||
'Decryption of $downloadPath ($downloadedPath) failed: $ex',
|
||||
);
|
||||
await _fileDownloadFailed(job, messageFailedToDecryptFile);
|
||||
await _fileDownloadFailed(job, MessageErrorType.failedToDecryptFile);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -527,8 +537,6 @@ class HttpFileTransferService {
|
||||
int? mediaHeight;
|
||||
if (mime != null) {
|
||||
if (mime.startsWith('image/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(downloadedPath);
|
||||
if (imageSize == null) {
|
||||
@@ -538,25 +546,33 @@ class HttpFileTransferService {
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();
|
||||
} else if (mime.startsWith('video/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
|
||||
/*
|
||||
if (canGenerateVideoThumbnail(mime)) {
|
||||
try {
|
||||
// Generate thumbnail
|
||||
final thumbnailPath = await getVideoThumbnailPath(
|
||||
final thumbnailPath = await maybeGenerateVideoThumbnail(
|
||||
downloadedPath,
|
||||
job.conversationJid,
|
||||
);
|
||||
|
||||
if (thumbnailPath != null) {
|
||||
// Find out the dimensions
|
||||
final imageSize = await getImageSizeFromPath(thumbnailPath);
|
||||
if (imageSize == null) {
|
||||
_log.warning('Failed to get image size for $downloadedPath ($thumbnailPath)');
|
||||
_log.warning(
|
||||
'Failed to get image size for $downloadedPath ($thumbnailPath)',
|
||||
);
|
||||
}
|
||||
|
||||
mediaWidth = imageSize?.width.toInt();
|
||||
mediaHeight = imageSize?.height.toInt();*/
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
MoxplatformPlugin.media.scanFile(downloadedPath);
|
||||
mediaHeight = imageSize?.height.toInt();
|
||||
}
|
||||
} catch (ex) {
|
||||
_log.warning('Failed to generate thumbnail for $downloadedPath');
|
||||
}
|
||||
} else {
|
||||
_log.info(
|
||||
'Not generating thumbnail for $downloadedPath because canGenerateVideoThumbnail returned false',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,45 +587,61 @@ class HttpFileTransferService {
|
||||
);
|
||||
|
||||
// Only add the hash pointers if the file hashes match what was sent
|
||||
if (job.location.plaintextHashes?.isNotEmpty ?? false) {
|
||||
if (integrityCheckPassed) {
|
||||
if ((job.location.plaintextHashes?.isNotEmpty ?? false) &&
|
||||
integrityCheckPassed &&
|
||||
job.createMetadataHashes) {
|
||||
await fs.createMetadataHashEntries(
|
||||
job.location.plaintextHashes!,
|
||||
job.metadataId,
|
||||
);
|
||||
} else {
|
||||
_log.warning('Integrity check failed for file');
|
||||
}
|
||||
}
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conversation = (await cs.getConversationByJid(job.conversationJid))!;
|
||||
final conversation = (await cs.getConversationByJid(
|
||||
job.conversationJid,
|
||||
job.accountJid,
|
||||
))!;
|
||||
|
||||
// Figure out if we should show a warning
|
||||
MessageWarningType? warning;
|
||||
if (!integrityCheckPassed) {
|
||||
warning = MessageWarningType.fileIntegrityCheckFailed;
|
||||
} else if (conversation.encrypted && !decryptionKeysAvailable) {
|
||||
warning = MessageWarningType.chatEncryptedButFilePlaintext;
|
||||
}
|
||||
|
||||
final msg = await GetIt.I.get<MessageService>().updateMessage(
|
||||
job.mId,
|
||||
job.messageId,
|
||||
job.accountJid,
|
||||
fileMetadata: metadata,
|
||||
isFileUploadNotification: false,
|
||||
warningType:
|
||||
integrityCheckPassed ? null : warningFileIntegrityCheckFailed,
|
||||
errorType: conversation.encrypted && !decryptionKeysAvailable
|
||||
? messageChatEncryptedButFileNot
|
||||
: null,
|
||||
warningType: warning,
|
||||
isDownloading: false,
|
||||
);
|
||||
|
||||
sendEvent(MessageUpdatedEvent(message: msg));
|
||||
|
||||
final updatedConversation = conversation.copyWith(
|
||||
lastMessage: conversation.lastMessage?.id == job.mId
|
||||
lastMessage: conversation.lastMessage?.id == job.messageId
|
||||
? msg
|
||||
: conversation.lastMessage,
|
||||
);
|
||||
cs.setConversation(updatedConversation);
|
||||
|
||||
// Show a notification
|
||||
if (notification.shouldShowNotification(msg.conversationJid) &&
|
||||
job.shouldShowNotification) {
|
||||
final shouldShowNotification =
|
||||
notification.shouldShowNotification(msg.conversationJid);
|
||||
if (shouldShowNotification && job.shouldShowNotification) {
|
||||
_log.finest('Creating notification with bigPicture $downloadedPath');
|
||||
await notification.showNotification(updatedConversation, msg, '');
|
||||
await notification.updateOrShowNotification(
|
||||
updatedConversation,
|
||||
msg,
|
||||
job.accountJid,
|
||||
);
|
||||
} else {
|
||||
_log.finest(
|
||||
'Not creating or updating notification for $downloadedPath: notification.shouldShowNotification=$shouldShowNotification, job.shouldShowNotification=${job.shouldShowNotification}',
|
||||
);
|
||||
}
|
||||
|
||||
sendEvent(ConversationUpdatedEvent(conversation: updatedConversation));
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/models/message.dart';
|
||||
@immutable
|
||||
class FileUploadJob {
|
||||
const FileUploadJob(
|
||||
this.accountJid,
|
||||
this.recipients,
|
||||
this.path,
|
||||
this.mime,
|
||||
@@ -23,11 +24,13 @@ class FileUploadJob {
|
||||
// Recipient -> Message
|
||||
final Map<String, Message> messageMap;
|
||||
final String metadataId;
|
||||
final List<Thumbnail> thumbnails;
|
||||
final String accountJid;
|
||||
final List<JingleContentThumbnail> thumbnails;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FileUploadJob &&
|
||||
accountJid == other.accountJid &&
|
||||
recipients == other.recipients &&
|
||||
path == other.path &&
|
||||
messageMap == other.messageMap &&
|
||||
@@ -52,37 +55,59 @@ class FileUploadJob {
|
||||
@immutable
|
||||
class FileDownloadJob {
|
||||
const FileDownloadJob(
|
||||
this.location,
|
||||
this.mId,
|
||||
this.metadataId,
|
||||
this.messageId,
|
||||
this.conversationJid,
|
||||
this.accountJid,
|
||||
this.location,
|
||||
this.metadataId,
|
||||
this.createMetadataHashes,
|
||||
this.mimeGuess, {
|
||||
this.shouldShowNotification = true,
|
||||
});
|
||||
final MediaFileLocation location;
|
||||
final int mId;
|
||||
final String metadataId;
|
||||
|
||||
/// The message id.
|
||||
final String messageId;
|
||||
|
||||
/// The JID of the conversation we're downloading the file in.
|
||||
final String conversationJid;
|
||||
|
||||
/// The associated account.
|
||||
final String accountJid;
|
||||
|
||||
/// The location where the file can be found.
|
||||
final MediaFileLocation location;
|
||||
|
||||
/// The id of the file metadata describing the file.
|
||||
final String metadataId;
|
||||
|
||||
/// Flag indicating whether we should create hash pointers to the file metadata
|
||||
/// object.
|
||||
final bool createMetadataHashes;
|
||||
|
||||
/// A guess to the files's MIME type.
|
||||
final String? mimeGuess;
|
||||
|
||||
/// Flag indicating whether a notification should be shown after successful download.
|
||||
final bool shouldShowNotification;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is FileDownloadJob &&
|
||||
location == other.location &&
|
||||
mId == other.mId &&
|
||||
metadataId == other.metadataId &&
|
||||
messageId == other.messageId &&
|
||||
conversationJid == other.conversationJid &&
|
||||
location == other.location &&
|
||||
accountJid == other.accountJid &&
|
||||
metadataId == other.metadataId &&
|
||||
mimeGuess == other.mimeGuess &&
|
||||
shouldShowNotification == other.shouldShowNotification;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
location.hashCode ^
|
||||
mId.hashCode ^
|
||||
metadataId.hashCode ^
|
||||
conversationJid.hashCode ^
|
||||
messageId.hashCode ^
|
||||
location.hashCode ^
|
||||
metadataId.hashCode ^
|
||||
mimeGuess.hashCode ^
|
||||
shouldShowNotification.hashCode;
|
||||
}
|
||||
|
||||
16
lib/service/lifecycle.dart
Normal file
16
lib/service/lifecycle.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
/// Service which provides other services with information about the state of
|
||||
/// the app, i.e. if it's in the foreground, minimized, ...
|
||||
class LifecycleService {
|
||||
final Logger _log = Logger('LifecycleService');
|
||||
|
||||
/// Flag indicating whether the app is currently active, i.e. in the foreground (true),
|
||||
/// or inactive (false).
|
||||
bool _active = false;
|
||||
bool get isActive => _active;
|
||||
set isActive(bool flag) {
|
||||
_log.finest('Setting isActive to $flag');
|
||||
_active = flag;
|
||||
}
|
||||
}
|
||||
@@ -10,47 +10,34 @@ import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/shared/cache.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:moxxyv2/shared/warning_types.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
class MessageService {
|
||||
/// Logger
|
||||
final Logger _log = Logger('MessageService');
|
||||
|
||||
final LRUCache<String, List<Message>> _messageCache =
|
||||
LRUCache(conversationMessagePageCacheSize);
|
||||
final Lock _cacheLock = Lock();
|
||||
/// UUID instance for message ids.
|
||||
final _uuid = const Uuid();
|
||||
|
||||
Future<Message?> getMessageById(
|
||||
int id,
|
||||
String conversationJid, {
|
||||
bool queryReactionPreview = true,
|
||||
}) async {
|
||||
Future<Message> _parseMessage(
|
||||
Map<String, Object?> rawMessage,
|
||||
String accountJid,
|
||||
bool queryReactionPreview,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final messagesRaw = await db.query(
|
||||
messagesTable,
|
||||
where: 'id = ? AND conversationJid = ?',
|
||||
whereArgs: [id, conversationJid],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (messagesRaw.isEmpty) return null;
|
||||
|
||||
// TODO(PapaTutuWawa): Load the quoted message
|
||||
final msg = messagesRaw.first;
|
||||
|
||||
// Load the file metadata, if available
|
||||
FileMetadata? fm;
|
||||
if (msg['file_metadata_id'] != null) {
|
||||
if (rawMessage['file_metadata_id'] != null) {
|
||||
final rawFm = (await db.query(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [msg['file_metadata_id']],
|
||||
whereArgs: [rawMessage['file_metadata_id']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
@@ -58,32 +45,65 @@ class MessageService {
|
||||
}
|
||||
|
||||
return Message.fromDatabaseJson(
|
||||
msg,
|
||||
rawMessage,
|
||||
null,
|
||||
fm,
|
||||
queryReactionPreview
|
||||
? await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(msg['id']! as int)
|
||||
? await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(
|
||||
rawMessage['id']! as String,
|
||||
accountJid,
|
||||
)
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageByXmppId(
|
||||
/// Queries the database for a message with a stanza id of [id] inside
|
||||
/// the conversation [conversationJid], if specified, in the context of the account
|
||||
/// [accountJid].
|
||||
Future<Message?> getMessageById(
|
||||
String id,
|
||||
String conversationJid, {
|
||||
bool includeOriginId = true,
|
||||
String accountJid, {
|
||||
String? conversationJid,
|
||||
bool queryReactionPreview = true,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final idQuery = includeOriginId ? '(sid = ? OR originId = ?)' : 'sid = ?';
|
||||
final messagesRaw = await db.query(
|
||||
messagesTable,
|
||||
where: 'conversationJid = ? AND $idQuery',
|
||||
where: conversationJid != null
|
||||
? 'id = ? AND accountJid = ? AND conversationJid = ?'
|
||||
: 'id = ? AND accountJid = ?',
|
||||
whereArgs: [
|
||||
conversationJid,
|
||||
if (includeOriginId) id,
|
||||
id,
|
||||
accountJid,
|
||||
if (conversationJid != null) conversationJid,
|
||||
],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (messagesRaw.isEmpty) return null;
|
||||
|
||||
return _parseMessage(messagesRaw.first, accountJid, queryReactionPreview);
|
||||
}
|
||||
|
||||
/// Queries the database for a message with a stanza id of [originId] inside
|
||||
/// the conversation [conversationJid], if specified, in the context of the account
|
||||
/// [accountJid].
|
||||
Future<Message?> getMessageByOriginId(
|
||||
String originId,
|
||||
String accountJid, {
|
||||
String? conversationJid,
|
||||
bool queryReactionPreview = true,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final messagesRaw = await db.query(
|
||||
messagesTable,
|
||||
where: conversationJid != null
|
||||
? 'accountJid = ? AND originId = ? AND conversationJid = ?'
|
||||
: 'accountJid = ? AND originId = ?',
|
||||
whereArgs: [
|
||||
accountJid,
|
||||
originId,
|
||||
if (conversationJid != null) conversationJid,
|
||||
],
|
||||
limit: 1,
|
||||
);
|
||||
@@ -91,30 +111,36 @@ class MessageService {
|
||||
if (messagesRaw.isEmpty) return null;
|
||||
|
||||
// TODO(PapaTutuWawa): Load the quoted message
|
||||
final msg = messagesRaw.first;
|
||||
|
||||
FileMetadata? fm;
|
||||
if (msg['file_metadata_id'] != null) {
|
||||
final rawFm = (await db.query(
|
||||
fileMetadataTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [msg['file_metadata_id']],
|
||||
limit: 1,
|
||||
))
|
||||
.first;
|
||||
fm = FileMetadata.fromDatabaseJson(rawFm);
|
||||
return _parseMessage(messagesRaw.first, accountJid, queryReactionPreview);
|
||||
}
|
||||
|
||||
return Message.fromDatabaseJson(
|
||||
msg,
|
||||
null,
|
||||
fm,
|
||||
queryReactionPreview
|
||||
? await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(msg['id']! as int)
|
||||
: [],
|
||||
/// Query the database for the message with a stanza id of [sid] in the context of [accountJid].
|
||||
/// If [conversationJid] is specified, then the message must also be within the conversation with
|
||||
/// [conversationJid].
|
||||
Future<Message?> getMessageByStanzaId(
|
||||
String sid,
|
||||
String accountJid, {
|
||||
String? conversationJid,
|
||||
bool queryReactionPreview = true,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final messagesRaw = await db.query(
|
||||
messagesTable,
|
||||
where: conversationJid != null
|
||||
? 'accountJid = ? AND sid = ? AND conversationJid = ?'
|
||||
: 'accountJid = ? AND sid = ?',
|
||||
whereArgs: [
|
||||
accountJid,
|
||||
sid,
|
||||
if (conversationJid != null) conversationJid,
|
||||
],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
if (messagesRaw.isEmpty) return null;
|
||||
|
||||
// TODO(PapaTutuWawa): Load the quoted message
|
||||
return _parseMessage(messagesRaw.first, accountJid, queryReactionPreview);
|
||||
}
|
||||
|
||||
/// Return a list of messages for [jid]. If [olderThan] is true, then all messages are older than [oldestTimestamp], if
|
||||
@@ -122,27 +148,20 @@ class MessageService {
|
||||
/// than [oldestTimestamp], or the newest messages are returned if null.
|
||||
Future<List<Message>> getPaginatedMessagesForJid(
|
||||
String jid,
|
||||
String accountJid,
|
||||
bool olderThan,
|
||||
int? oldestTimestamp,
|
||||
) async {
|
||||
if (olderThan && oldestTimestamp == null) {
|
||||
final result = await _cacheLock.synchronized<List<Message>?>(() {
|
||||
return _messageCache.getValue(jid);
|
||||
});
|
||||
if (result != null) return result;
|
||||
}
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final comparator = olderThan ? '<' : '>';
|
||||
final query = oldestTimestamp != null
|
||||
? 'conversationJid = ? AND timestamp $comparator ?'
|
||||
: 'conversationJid = ?';
|
||||
? 'conversationJid = ? AND accountJid = ? AND timestamp $comparator ?'
|
||||
: 'conversationJid = ? AND accountJid = ?';
|
||||
final rawMessages = await db.rawQuery(
|
||||
// LEFT JOIN $messagesTable quote ON msg.quote_id = quote.id
|
||||
'''
|
||||
SELECT
|
||||
msg.*,
|
||||
quote.id AS quote_id,
|
||||
quote.sender AS quote_sender,
|
||||
quote.body AS quote_body,
|
||||
quote.timestamp AS quote_timestamp,
|
||||
@@ -187,6 +206,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $m
|
||||
''',
|
||||
[
|
||||
jid,
|
||||
accountJid,
|
||||
if (oldestTimestamp != null) oldestTimestamp,
|
||||
],
|
||||
);
|
||||
@@ -228,38 +248,35 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $m
|
||||
m,
|
||||
quotes,
|
||||
fm,
|
||||
await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(m['id']! as int),
|
||||
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(
|
||||
m['id']! as String,
|
||||
accountJid,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (olderThan && oldestTimestamp == null) {
|
||||
await _cacheLock.synchronized(() {
|
||||
_messageCache.cache(
|
||||
jid,
|
||||
page,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
/// Like getPaginatedMessagesForJid, but instead only returns messages that have file
|
||||
/// metadata attached. This method bypasses the cache and does not load the message's
|
||||
/// quoted message, if it exists.
|
||||
/// quoted message, if it exists. If [jid] is set to null, then the media messages for
|
||||
/// all conversations are queried.
|
||||
Future<List<Message>> getPaginatedSharedMediaMessagesForJid(
|
||||
String jid,
|
||||
String? jid,
|
||||
String accountJid,
|
||||
bool olderThan,
|
||||
int? oldestTimestamp,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final comparator = olderThan ? '<' : '>';
|
||||
final queryPrefix = jid != null
|
||||
? 'conversationJid = ? AND accountJid = ? AND'
|
||||
: 'accountJid = ? AND';
|
||||
final query = oldestTimestamp != null
|
||||
? 'conversationJid = ? AND file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||
: 'conversationJid = ? AND file_metadata_id IS NOT NULL';
|
||||
? 'file_metadata_id IS NOT NULL AND timestamp $comparator ?'
|
||||
: 'file_metadata_id IS NOT NULL';
|
||||
final rawMessages = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
@@ -279,11 +296,27 @@ SELECT
|
||||
fm.cipherTextHashes as fm_cipherTextHashes,
|
||||
fm.filename as fm_filename,
|
||||
fm.size as fm_size
|
||||
FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $sharedMediaPaginationSize) AS msg
|
||||
LEFT JOIN $fileMetadataTable fm ON msg.file_metadata_id = fm.id;
|
||||
FROM
|
||||
(SELECT
|
||||
*
|
||||
FROM
|
||||
$messagesTable
|
||||
WHERE
|
||||
$queryPrefix $query
|
||||
ORDER BY timestamp
|
||||
DESC LIMIT $sharedMediaPaginationSize
|
||||
) AS msg
|
||||
LEFT JOIN
|
||||
$fileMetadataTable fm
|
||||
ON
|
||||
msg.file_metadata_id = fm.id
|
||||
WHERE
|
||||
fm_path IS NOT NULL
|
||||
AND NOT EXISTS (SELECT id FROM $stickersTable WHERE file_metadata_id = fm.id);
|
||||
''',
|
||||
[
|
||||
jid,
|
||||
if (jid != null) jid,
|
||||
accountJid,
|
||||
if (oldestTimestamp != null) oldestTimestamp,
|
||||
],
|
||||
);
|
||||
@@ -301,9 +334,10 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
FileMetadata.fromDatabaseJson(
|
||||
getPrefixedSubMap(m, 'fm_'),
|
||||
),
|
||||
await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(m['id']! as int),
|
||||
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(
|
||||
m['id']! as String,
|
||||
accountJid,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -313,6 +347,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addMessageFromData that updates the cache.
|
||||
Future<Message> addMessageFromData(
|
||||
String accountJid,
|
||||
String body,
|
||||
int timestamp,
|
||||
String sender,
|
||||
@@ -324,23 +359,25 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
String? originId,
|
||||
String? quoteId,
|
||||
FileMetadata? fileMetadata,
|
||||
int? errorType,
|
||||
int? warningType,
|
||||
MessageErrorType? errorType,
|
||||
MessageWarningType? warningType,
|
||||
bool isDownloading = false,
|
||||
bool isUploading = false,
|
||||
String? stickerPackId,
|
||||
int? pseudoMessageType,
|
||||
String? occupantId,
|
||||
PseudoMessageType? pseudoMessageType,
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
bool received = false,
|
||||
bool displayed = false,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
var m = Message(
|
||||
var message = Message(
|
||||
_uuid.v4(),
|
||||
accountJid,
|
||||
sender,
|
||||
body,
|
||||
timestamp,
|
||||
sid,
|
||||
-1,
|
||||
conversationJid,
|
||||
isFileUploadNotification,
|
||||
encrypted,
|
||||
@@ -355,64 +392,29 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
isUploading: isUploading,
|
||||
isDownloading: isDownloading,
|
||||
stickerPackId: stickerPackId,
|
||||
occupantId: occupantId,
|
||||
pseudoMessageType: pseudoMessageType,
|
||||
pseudoMessageData: pseudoMessageData,
|
||||
);
|
||||
|
||||
if (quoteId != null) {
|
||||
final quotes = await getMessageByXmppId(quoteId, conversationJid);
|
||||
final quotes = await getMessageById(quoteId, accountJid);
|
||||
if (quotes == null) {
|
||||
_log.warning('Failed to add quote for message with id $quoteId');
|
||||
} else {
|
||||
m = m.copyWith(quotes: quotes);
|
||||
message = message.copyWith(quotes: quotes);
|
||||
}
|
||||
}
|
||||
|
||||
m = m.copyWith(
|
||||
id: await db.insert(messagesTable, m.toDatabaseJson()),
|
||||
);
|
||||
|
||||
await _cacheLock.synchronized(() {
|
||||
final cachedList = _messageCache.getValue(conversationJid);
|
||||
if (cachedList != null) {
|
||||
_messageCache.replaceValue(
|
||||
conversationJid,
|
||||
clampedListPrepend(
|
||||
cachedList,
|
||||
m,
|
||||
messagePaginationSize,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
Future<Message?> getMessageByStanzaId(
|
||||
String conversationJid,
|
||||
String stanzaId,
|
||||
) async {
|
||||
return getMessageByXmppId(
|
||||
stanzaId,
|
||||
conversationJid,
|
||||
includeOriginId: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Message?> getMessageByStanzaOrOriginId(
|
||||
String conversationJid,
|
||||
String id,
|
||||
) async {
|
||||
return getMessageByXmppId(
|
||||
id,
|
||||
conversationJid,
|
||||
);
|
||||
await db.insert(messagesTable, message.toDatabaseJson());
|
||||
return message;
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s updateMessage that updates the cache
|
||||
Future<Message> updateMessage(
|
||||
int id, {
|
||||
String id,
|
||||
String accountJid, {
|
||||
String? sid,
|
||||
Object? body = notSpecified,
|
||||
bool? received,
|
||||
bool? displayed,
|
||||
@@ -424,9 +426,9 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
bool? isUploading,
|
||||
bool? isDownloading,
|
||||
Object? originId = notSpecified,
|
||||
Object? sid = notSpecified,
|
||||
bool? isRetracted,
|
||||
bool? isEdited,
|
||||
String? occupantId,
|
||||
}) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final m = <String, dynamic>{};
|
||||
@@ -444,7 +446,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
m['acked'] = boolToInt(acked);
|
||||
}
|
||||
if (errorType != notSpecified) {
|
||||
m['errorType'] = errorType as int?;
|
||||
m['errorType'] = (errorType as MessageErrorType?)?.value;
|
||||
}
|
||||
if (warningType != notSpecified) {
|
||||
m['warningType'] = warningType as int?;
|
||||
@@ -458,9 +460,6 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
if (isUploading != null) {
|
||||
m['isUploading'] = boolToInt(isUploading);
|
||||
}
|
||||
if (sid != notSpecified) {
|
||||
m['sid'] = sid as String?;
|
||||
}
|
||||
if (originId != notSpecified) {
|
||||
m['originId'] = originId as String?;
|
||||
}
|
||||
@@ -473,19 +472,25 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
if (isEdited != null) {
|
||||
m['isEdited'] = boolToInt(isEdited);
|
||||
}
|
||||
if (sid != null) {
|
||||
m['sid'] = sid;
|
||||
}
|
||||
if (occupantId != null) {
|
||||
m['occupantId'] = occupantId;
|
||||
}
|
||||
|
||||
final updatedMessage = await db.updateAndReturn(
|
||||
messagesTable,
|
||||
m,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
where: 'id = ? AND accountJid = ?',
|
||||
whereArgs: [id, accountJid],
|
||||
);
|
||||
|
||||
Message? quotes;
|
||||
if (updatedMessage['quote_id'] != null) {
|
||||
quotes = await getMessageById(
|
||||
updatedMessage['quote_id']! as int,
|
||||
updatedMessage['conversationJid']! as String,
|
||||
updatedMessage['quote_id']! as String,
|
||||
accountJid,
|
||||
queryReactionPreview: false,
|
||||
);
|
||||
}
|
||||
@@ -508,25 +513,11 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
updatedMessage,
|
||||
quotes,
|
||||
metadata,
|
||||
await GetIt.I.get<ReactionsService>().getPreviewReactionsForMessage(id),
|
||||
await GetIt.I
|
||||
.get<ReactionsService>()
|
||||
.getPreviewReactionsForMessage(id, accountJid),
|
||||
);
|
||||
|
||||
await _cacheLock.synchronized(() {
|
||||
final page = _messageCache.getValue(msg.conversationJid);
|
||||
if (page != null) {
|
||||
_messageCache.replaceValue(
|
||||
msg.conversationJid,
|
||||
page.map((m) {
|
||||
if (m.id == msg.id) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
return m;
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
@@ -543,13 +534,15 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
/// the UI already verifies it.
|
||||
Future<void> retractMessage(
|
||||
String conversationJid,
|
||||
String accountJid,
|
||||
String originId,
|
||||
String bareSender,
|
||||
bool selfRetract,
|
||||
) async {
|
||||
final msg = await getMessageByXmppId(
|
||||
final msg = await getMessageByOriginId(
|
||||
originId,
|
||||
conversationJid,
|
||||
accountJid,
|
||||
queryReactionPreview: false,
|
||||
);
|
||||
|
||||
if (msg == null) {
|
||||
@@ -572,6 +565,7 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
final isMedia = msg.isMedia;
|
||||
final retractedMessage = await updateMessage(
|
||||
msg.id,
|
||||
accountJid,
|
||||
warningType: null,
|
||||
errorType: null,
|
||||
isRetracted: true,
|
||||
@@ -581,7 +575,8 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
sendEvent(MessageUpdatedEvent(message: retractedMessage));
|
||||
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final conversation = await cs.getConversationByJid(conversationJid);
|
||||
final conversation =
|
||||
await cs.getConversationByJid(conversationJid, accountJid);
|
||||
if (conversation != null) {
|
||||
if (conversation.lastMessage?.id == msg.id) {
|
||||
final newConversation = conversation.copyWith(
|
||||
@@ -609,21 +604,32 @@ FROM (SELECT * FROM $messagesTable WHERE $query ORDER BY timestamp DESC LIMIT $s
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> replaceMessageInCache(Message message) async {
|
||||
await _cacheLock.synchronized(() {
|
||||
final cachedList = _messageCache.getValue(message.conversationJid);
|
||||
if (cachedList != null) {
|
||||
_messageCache.replaceValue(
|
||||
message.conversationJid,
|
||||
cachedList.map((m) {
|
||||
if (m.id == message.id) {
|
||||
return message;
|
||||
}
|
||||
/// Marks the message with the message id [id] as displayed and sends an
|
||||
/// [MessageUpdatedEvent] to the UI. if [sendChatMarker] is true, then
|
||||
/// a Chat Marker with <displayed /> is sent to the message's
|
||||
/// conversationJid attribute.
|
||||
Future<Message> markMessageAsRead(
|
||||
String id,
|
||||
String accountJid,
|
||||
bool sendChatMarker,
|
||||
) async {
|
||||
final newMessage = await updateMessage(
|
||||
id,
|
||||
accountJid,
|
||||
displayed: true,
|
||||
);
|
||||
|
||||
return m;
|
||||
}).toList(),
|
||||
// Tell the UI
|
||||
sendEvent(MessageUpdatedEvent(message: newMessage));
|
||||
|
||||
if (sendChatMarker) {
|
||||
await GetIt.I.get<XmppService>().sendReadMarker(
|
||||
// TODO(Unknown): This is wrong once groupchats are implemented
|
||||
newMessage.conversationJid,
|
||||
newMessage.originId ?? newMessage.sid,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return newMessage;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
class MoxxyOmemoManager extends BaseOmemoManager {
|
||||
MoxxyOmemoManager() : super();
|
||||
|
||||
@override
|
||||
Future<OmemoManager> getOmemoManager() async {
|
||||
final os = GetIt.I.get<OmemoService>();
|
||||
await os.ensureInitialized();
|
||||
return os.omemoManager;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldEncryptStanza(JID toJid, Stanza stanza) async {
|
||||
// Never encrypt stanzas that contain PubSub elements
|
||||
if (stanza.firstTag('pubsub', xmlns: pubsubXmlns) != null ||
|
||||
stanza.firstTag('pubsub', xmlns: pubsubOwnerXmlns) != null ||
|
||||
stanza.firstTagByXmlns(carbonsXmlns) != null ||
|
||||
stanza.firstTagByXmlns(rosterXmlns) != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Encrypt when the conversation is set to use OMEMO.
|
||||
return GetIt.I
|
||||
.get<ConversationService>()
|
||||
.shouldEncryptForConversation(toJid);
|
||||
}
|
||||
}
|
||||
|
||||
class MoxxyBTBVTrustManager extends BlindTrustBeforeVerificationTrustManager {
|
||||
MoxxyBTBVTrustManager(
|
||||
Map<RatchetMapKey, BTBVTrustState> trustCache,
|
||||
Map<RatchetMapKey, bool> enablementCache,
|
||||
Map<String, List<int>> devices,
|
||||
) : super(
|
||||
trustCache: trustCache,
|
||||
enablementCache: enablementCache,
|
||||
devices: devices,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> commitState() async {
|
||||
await GetIt.I.get<OmemoService>().commitTrustManager(await toJson());
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,46 @@
|
||||
import 'dart:async';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
/// Update the "showAddToRoster" state of the conversation with jid [jid] to
|
||||
/// [showAddToRoster], if the conversation exists.
|
||||
Future<void> updateConversation(
|
||||
String jid,
|
||||
String accountJid,
|
||||
bool showAddToRoster,
|
||||
) async {
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
final newConversation = await cs.createOrUpdateConversation(
|
||||
jid,
|
||||
accountJid,
|
||||
update: (conversation) async {
|
||||
final c = conversation.copyWith(
|
||||
showAddToRoster: showAddToRoster,
|
||||
);
|
||||
cs.setConversation(c);
|
||||
return c;
|
||||
},
|
||||
);
|
||||
if (newConversation != null) {
|
||||
sendEvent(ConversationUpdatedEvent(conversation: newConversation));
|
||||
}
|
||||
}
|
||||
|
||||
class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
@override
|
||||
Future<RosterCacheLoadResult> loadRosterCache() async {
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final state = await GetIt.I.get<XmppStateService>().state;
|
||||
return RosterCacheLoadResult(
|
||||
(await GetIt.I.get<XmppStateService>().getXmppState()).lastRosterVersion,
|
||||
(await rs.getRoster())
|
||||
state.lastRosterVersion,
|
||||
(await rs.getRoster(accountJid!))
|
||||
.map(
|
||||
(item) => XmppRosterItem(
|
||||
jid: item.jid,
|
||||
@@ -35,6 +62,7 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
List<XmppRosterItem> added,
|
||||
) async {
|
||||
final rs = GetIt.I.get<RosterService>();
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
await xss.modifyXmppState(
|
||||
(state) => state.copyWith(
|
||||
@@ -44,18 +72,19 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
|
||||
// Remove stale items
|
||||
for (final jid in removed) {
|
||||
await rs.removeRosterItemByJid(jid);
|
||||
await rs.removeRosterItem(jid, accountJid!);
|
||||
await updateConversation(jid, accountJid, true);
|
||||
}
|
||||
|
||||
// Create new roster items
|
||||
final rosterAdded = List<RosterItem>.empty(growable: true);
|
||||
for (final item in added) {
|
||||
final exists = await rs.getRosterItemByJid(item.jid) != null;
|
||||
final exists = await rs.getRosterItemByJid(item.jid, accountJid!) != null;
|
||||
// Skip adding items twice
|
||||
if (exists) continue;
|
||||
|
||||
rosterAdded.add(
|
||||
await rs.addRosterItemFromData(
|
||||
final newRosterItem = await rs.addRosterItemFromData(
|
||||
accountJid,
|
||||
'',
|
||||
'',
|
||||
item.jid,
|
||||
@@ -67,27 +96,41 @@ class MoxxyRosterStateManager extends BaseRosterStateManager {
|
||||
null,
|
||||
null,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
rosterAdded.add(newRosterItem);
|
||||
|
||||
// Update the cached conversation item
|
||||
await updateConversation(
|
||||
item.jid,
|
||||
accountJid,
|
||||
newRosterItem.showAddToRosterButton,
|
||||
);
|
||||
}
|
||||
|
||||
// Update modified items
|
||||
final rosterModified = List<RosterItem>.empty(growable: true);
|
||||
for (final item in modified) {
|
||||
final ritem = await rs.getRosterItemByJid(item.jid);
|
||||
final ritem = await rs.getRosterItemByJid(item.jid, accountJid!);
|
||||
if (ritem == null) {
|
||||
//_log.warning('Could not find roster item with JID $jid during update');
|
||||
continue;
|
||||
}
|
||||
|
||||
rosterModified.add(
|
||||
await rs.updateRosterItem(
|
||||
ritem.id,
|
||||
final newRosterItem = await rs.updateRosterItem(
|
||||
ritem.jid,
|
||||
accountJid,
|
||||
title: item.name,
|
||||
subscription: item.subscription,
|
||||
ask: item.ask,
|
||||
groups: item.groups,
|
||||
),
|
||||
);
|
||||
rosterModified.add(newRosterItem);
|
||||
|
||||
// Update the cached conversation item
|
||||
await updateConversation(
|
||||
item.jid,
|
||||
accountJid,
|
||||
newRosterItem.showAddToRosterButton,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ class MoxxyStreamManagementManager extends StreamManagementManager {
|
||||
|
||||
@override
|
||||
Future<void> loadState() async {
|
||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.state;
|
||||
if (state.smState != null) {
|
||||
await setState(state.smState!);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:moxxy_native/moxxy_native.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
|
||||
/// Recreate all notification channels to apply settings that cannot be applied after the notification
|
||||
/// channel has been created.
|
||||
Future<void> upgradeV1ToV2NonDb(int _) async {
|
||||
// Ensure that we can use the device locale
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
LocaleSettings.useDeviceLocale();
|
||||
|
||||
final api = MoxxyNotificationsApi();
|
||||
|
||||
// Remove all notification channels, so that we can recreate them
|
||||
await api.deleteNotificationChannels([
|
||||
'FOREGROUND_DEFAULT',
|
||||
'message_channel',
|
||||
'warning_channel',
|
||||
// Not sure where this one comes from
|
||||
'warning',
|
||||
]);
|
||||
|
||||
// Set up notification groups
|
||||
await api.createNotificationGroups(
|
||||
[
|
||||
NotificationGroup(
|
||||
id: messageNotificationGroupId,
|
||||
description: 'Chat messages',
|
||||
),
|
||||
NotificationGroup(
|
||||
id: warningNotificationChannelId,
|
||||
description: 'Warnings',
|
||||
),
|
||||
NotificationGroup(
|
||||
id: foregroundServiceNotificationGroupId,
|
||||
description: 'Foreground service',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// Set up the notitifcation channels.
|
||||
await api.createNotificationChannels([
|
||||
NotificationChannel(
|
||||
title: t.notifications.channels.messagesChannelName,
|
||||
description: t.notifications.channels.messagesChannelDescription,
|
||||
id: messageNotificationChannelId,
|
||||
importance: NotificationChannelImportance.HIGH,
|
||||
showBadge: true,
|
||||
vibration: true,
|
||||
enableLights: true,
|
||||
),
|
||||
NotificationChannel(
|
||||
title: t.notifications.channels.warningChannelName,
|
||||
description: t.notifications.channels.warningChannelDescription,
|
||||
id: warningNotificationChannelId,
|
||||
importance: NotificationChannelImportance.DEFAULT,
|
||||
showBadge: false,
|
||||
vibration: true,
|
||||
enableLights: false,
|
||||
),
|
||||
// The foreground notification channel is only required on Android
|
||||
if (Platform.isAndroid)
|
||||
NotificationChannel(
|
||||
title: t.notifications.channels.serviceChannelName,
|
||||
description: t.notifications.channels.serviceChannelDescription,
|
||||
id: foregroundServiceNotificationChannelId,
|
||||
importance: NotificationChannelImportance.MIN,
|
||||
showBadge: false,
|
||||
vibration: false,
|
||||
enableLights: false,
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -1,99 +1,256 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'package:awesome_notifications/awesome_notifications.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxxy_native/moxxy_native.dart' as native;
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/events.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/lifecycle.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart' as modelc;
|
||||
import 'package:moxxyv2/shared/models/message.dart' as modelm;
|
||||
import 'package:moxxyv2/shared/models/notification.dart' as modeln;
|
||||
import 'package:moxxyv2/shared/thumbnails/helpers.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
|
||||
const _maxNotificationId = 2147483647;
|
||||
const _messageChannelKey = 'message_channel';
|
||||
const _warningChannelKey = 'warning_channel';
|
||||
const _notificationActionKeyRead = 'markAsRead';
|
||||
const _notificationActionKeyReply = 'reply';
|
||||
|
||||
// TODO(Unknown): Add resolution dependent drawables for the notification icon
|
||||
/// Message payload keys.
|
||||
const _conversationJidKey = 'conversationJid';
|
||||
const _messageIdKey = 'message_id';
|
||||
const _conversationTitleKey = 'title';
|
||||
const _conversationAvatarKey = 'avatarPath';
|
||||
|
||||
class NotificationsService {
|
||||
NotificationsService() : _log = Logger('NotificationsService');
|
||||
// ignore: unused_field
|
||||
final Logger _log;
|
||||
NotificationsService() {
|
||||
_eventStream = _channel
|
||||
.receiveBroadcastStream()
|
||||
.cast<Object>()
|
||||
.map(native.NotificationEvent.decode);
|
||||
}
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
static Future<void> onReceivedAction(ReceivedAction action) async {
|
||||
final logger = Logger('NotificationHandler');
|
||||
/// Logging.
|
||||
final Logger _log = Logger('NotificationsService');
|
||||
|
||||
if (action.buttonKeyPressed.isEmpty && action.buttonKeyInput.isEmpty) {
|
||||
/// The Pigeon channel to the native side
|
||||
final native.MoxxyNotificationsApi _api = native.MoxxyNotificationsApi();
|
||||
final EventChannel _channel =
|
||||
const EventChannel('org.moxxy.moxxyv2/notification_stream');
|
||||
late final Stream<native.NotificationEvent> _eventStream;
|
||||
|
||||
/// Called when something happens to the notification, i.e. the actions are triggered or
|
||||
/// the notification has been tapped.
|
||||
Future<void> onNotificationEvent(native.NotificationEvent event) async {
|
||||
final conversationJid = event.extra![_conversationJidKey]!;
|
||||
if (event.type == native.NotificationEventType.open) {
|
||||
// The notification has been tapped
|
||||
sendEvent(
|
||||
MessageNotificationTappedEvent(
|
||||
conversationJid: action.payload!['conversationJid']!,
|
||||
title: action.payload!['title']!,
|
||||
avatarUrl: action.payload!['avatarUrl']!,
|
||||
conversationJid: conversationJid,
|
||||
title: event.extra![_conversationTitleKey]!,
|
||||
avatarPath: event.extra![_conversationAvatarKey]!,
|
||||
),
|
||||
);
|
||||
} else if (action.buttonKeyPressed == _notificationActionKeyRead) {
|
||||
// TODO(Unknown): Maybe refactor this call such that we don't have to use
|
||||
// a command.
|
||||
await performMarkMessageAsRead(
|
||||
MarkMessageAsReadCommand(
|
||||
conversationJid: action.payload!['conversationJid']!,
|
||||
sid: action.payload!['sid']!,
|
||||
newUnreadCounter: 0,
|
||||
} else if (event.type == native.NotificationEventType.markAsRead) {
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
// Mark the message as read
|
||||
await GetIt.I.get<MessageService>().markMessageAsRead(
|
||||
event.extra![_messageIdKey]!,
|
||||
accountJid!,
|
||||
// [XmppService.sendReadMarker] will check whether the *SHOULD* send
|
||||
// the marker, i.e. if the privacy settings allow it.
|
||||
true,
|
||||
);
|
||||
|
||||
// Update the conversation
|
||||
final cs = GetIt.I.get<ConversationService>();
|
||||
await cs.createOrUpdateConversation(
|
||||
conversationJid,
|
||||
accountJid,
|
||||
update: (conversation) async {
|
||||
final newConversation = await cs.updateConversation(
|
||||
conversationJid,
|
||||
accountJid,
|
||||
unreadCounter: 0,
|
||||
);
|
||||
|
||||
// Notify the UI
|
||||
sendEvent(
|
||||
ConversationUpdatedEvent(
|
||||
conversation: newConversation,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
logger.warning(
|
||||
'Received unknown notification action key ${action.buttonKeyPressed}',
|
||||
|
||||
return newConversation;
|
||||
},
|
||||
);
|
||||
|
||||
// Clear notifications
|
||||
await dismissNotificationsByJid(conversationJid, accountJid);
|
||||
} else if (event.type == native.NotificationEventType.reply) {
|
||||
// Save this as a notification so that we can display it later
|
||||
assert(
|
||||
event.payload != null,
|
||||
'Reply payload must be not null',
|
||||
);
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final notification = modeln.Notification(
|
||||
event.id,
|
||||
conversationJid,
|
||||
accountJid!,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
event.payload!,
|
||||
null,
|
||||
null,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
notificationsTable,
|
||||
notification.toJson(),
|
||||
);
|
||||
|
||||
// Send the actual reply
|
||||
await GetIt.I.get<XmppService>().sendMessage(
|
||||
accountJid: accountJid,
|
||||
body: event.payload!,
|
||||
recipients: [conversationJid],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
final an = AwesomeNotifications();
|
||||
await an.initialize(
|
||||
'resource://drawable/ic_service_icon',
|
||||
[
|
||||
NotificationChannel(
|
||||
channelKey: _messageChannelKey,
|
||||
channelName: t.notifications.channels.messagesChannelName,
|
||||
channelDescription:
|
||||
t.notifications.channels.messagesChannelDescription,
|
||||
/// Configures the translatable strings on the native side
|
||||
/// using locale is currently configured.
|
||||
Future<void> configureNotificationI18n() async {
|
||||
await _api.setNotificationI18n(
|
||||
native.NotificationI18nData(
|
||||
reply: t.notifications.message.reply,
|
||||
markAsRead: t.notifications.message.markAsRead,
|
||||
you: t.messages.you,
|
||||
),
|
||||
NotificationChannel(
|
||||
channelKey: _warningChannelKey,
|
||||
channelName: t.notifications.channels.warningChannelName,
|
||||
channelDescription:
|
||||
t.notifications.channels.warningChannelDescription,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> initialize() async {
|
||||
// Set up notification groups
|
||||
await _api.createNotificationGroups(
|
||||
[
|
||||
native.NotificationGroup(
|
||||
id: messageNotificationGroupId,
|
||||
description: 'Chat messages',
|
||||
),
|
||||
native.NotificationGroup(
|
||||
id: warningNotificationChannelId,
|
||||
description: 'Warnings',
|
||||
),
|
||||
native.NotificationGroup(
|
||||
id: foregroundServiceNotificationGroupId,
|
||||
description: 'Foreground service',
|
||||
),
|
||||
],
|
||||
debug: kDebugMode,
|
||||
);
|
||||
await an.setListeners(
|
||||
onActionReceivedMethod: onReceivedAction,
|
||||
);
|
||||
|
||||
// Set up the notitifcation channels.
|
||||
await _api.createNotificationChannels([
|
||||
native.NotificationChannel(
|
||||
title: t.notifications.channels.messagesChannelName,
|
||||
description: t.notifications.channels.messagesChannelDescription,
|
||||
id: messageNotificationChannelId,
|
||||
importance: native.NotificationChannelImportance.HIGH,
|
||||
showBadge: true,
|
||||
vibration: true,
|
||||
enableLights: true,
|
||||
),
|
||||
native.NotificationChannel(
|
||||
title: t.notifications.channels.warningChannelName,
|
||||
description: t.notifications.channels.warningChannelDescription,
|
||||
id: warningNotificationChannelId,
|
||||
importance: native.NotificationChannelImportance.DEFAULT,
|
||||
showBadge: false,
|
||||
vibration: true,
|
||||
enableLights: false,
|
||||
),
|
||||
// The foreground notification channel is only required on Android
|
||||
if (Platform.isAndroid)
|
||||
native.NotificationChannel(
|
||||
title: t.notifications.channels.serviceChannelName,
|
||||
description: t.notifications.channels.serviceChannelDescription,
|
||||
id: foregroundServiceNotificationChannelId,
|
||||
importance: native.NotificationChannelImportance.MIN,
|
||||
showBadge: false,
|
||||
vibration: false,
|
||||
enableLights: false,
|
||||
),
|
||||
]);
|
||||
|
||||
// Configure i18n
|
||||
await configureNotificationI18n();
|
||||
|
||||
// Listen to notification events
|
||||
_eventStream.listen(onNotificationEvent);
|
||||
}
|
||||
|
||||
/// Returns true if a notification should be shown. false otherwise.
|
||||
bool shouldShowNotification(String jid) {
|
||||
return GetIt.I.get<XmppService>().getCurrentlyOpenedChatJid() != jid;
|
||||
return GetIt.I.get<ConversationService>().activeConversationJid != jid ||
|
||||
!GetIt.I.get<LifecycleService>().isActive;
|
||||
}
|
||||
|
||||
/// Show a notification for a message [m] grouped by its conversationJid
|
||||
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
||||
/// then Android's BigPicture will be used.
|
||||
Future<void> showNotification(
|
||||
/// Queries the notifications for the conversation [jid] from the database.
|
||||
Future<List<modeln.Notification>> _getNotificationsForJid(
|
||||
String jid,
|
||||
String accountJid,
|
||||
) async {
|
||||
final rawNotifications =
|
||||
await GetIt.I.get<DatabaseService>().database.query(
|
||||
notificationsTable,
|
||||
where: 'conversationJid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
return rawNotifications.map(modeln.Notification.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<int?> _clearNotificationsForJid(String jid, String accountJid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
|
||||
final result = await db.query(
|
||||
notificationsTable,
|
||||
where: 'conversationJid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
limit: 1,
|
||||
);
|
||||
|
||||
// Assumption that all rows with the same conversationJid have the same id.
|
||||
final id = result.isNotEmpty ? result.first['id']! as int : null;
|
||||
await db.delete(
|
||||
notificationsTable,
|
||||
where: 'conversationJid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
Future<modeln.Notification> _createNotification(
|
||||
modelc.Conversation c,
|
||||
modelm.Message m,
|
||||
String title, {
|
||||
String? body,
|
||||
String accountJid,
|
||||
String? avatarPath,
|
||||
int id, {
|
||||
bool shouldOverride = false,
|
||||
}) async {
|
||||
// See https://github.com/MaikuB/flutter_local_notifications/blob/master/flutter_local_notifications/example/lib/main.dart#L1293
|
||||
String body;
|
||||
@@ -105,67 +262,269 @@ class NotificationsService {
|
||||
body = m.body;
|
||||
}
|
||||
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactIntegrationEnabled = await css.isContactIntegrationEnabled();
|
||||
final title =
|
||||
contactIntegrationEnabled ? c.contactDisplayName ?? c.title : c.title;
|
||||
final avatarPath = contactIntegrationEnabled
|
||||
? c.contactAvatarPath ?? c.avatarUrl
|
||||
: c.avatarUrl;
|
||||
assert(
|
||||
implies(m.fileMetadata?.path != null, m.fileMetadata?.mimeType != null),
|
||||
'File metadata has path but no mime type',
|
||||
);
|
||||
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
id: m.id,
|
||||
groupKey: c.jid,
|
||||
channelKey: _messageChannelKey,
|
||||
summary: title,
|
||||
title: title,
|
||||
body: body,
|
||||
largeIcon: avatarPath.isNotEmpty ? 'file://$avatarPath' : null,
|
||||
notificationLayout: m.isThumbnailable
|
||||
? NotificationLayout.BigPicture
|
||||
: NotificationLayout.Messaging,
|
||||
category: NotificationCategory.Message,
|
||||
bigPicture: m.isThumbnailable ? 'file://${m.fileMetadata!.path}' : null,
|
||||
payload: <String, String>{
|
||||
'conversationJid': c.jid,
|
||||
'sid': m.sid,
|
||||
'title': title,
|
||||
'avatarUrl': avatarPath,
|
||||
// Use the resource (nick) when the chat is a groupchat
|
||||
final senderJid = m.senderJid;
|
||||
final senderTitle = c.isGroupchat
|
||||
? senderJid.resource
|
||||
: await c.titleWithOptionalContactService;
|
||||
|
||||
// If the file is a video, use its thumbnail, if available
|
||||
var filePath = m.fileMetadata?.path;
|
||||
var fileMime = m.fileMetadata?.mimeType;
|
||||
|
||||
// Thumbnail workaround for Android
|
||||
if (Platform.isAndroid &&
|
||||
(m.fileMetadata?.mimeType?.startsWith('video/') ?? false) &&
|
||||
m.fileMetadata?.path != null) {
|
||||
final thumbnailPath = await getVideoThumbnailPath(m.fileMetadata!.path!);
|
||||
if (File(thumbnailPath).existsSync()) {
|
||||
// Workaround for Android to show the thumbnail in the notification
|
||||
filePath = thumbnailPath;
|
||||
fileMime = 'image/jpeg';
|
||||
}
|
||||
}
|
||||
|
||||
// Add to the database
|
||||
final newNotification = modeln.Notification(
|
||||
id,
|
||||
c.jid,
|
||||
accountJid,
|
||||
senderTitle,
|
||||
senderJid.toString(),
|
||||
(avatarPath?.isEmpty ?? false) ? null : avatarPath,
|
||||
body,
|
||||
fileMime,
|
||||
filePath,
|
||||
m.timestamp,
|
||||
);
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
notificationsTable,
|
||||
newNotification.toJson(),
|
||||
conflictAlgorithm: shouldOverride ? ConflictAlgorithm.replace : null,
|
||||
);
|
||||
return newNotification;
|
||||
}
|
||||
|
||||
/// Indicates whether we're allowed to show notifications on devices >= Android 13.
|
||||
Future<bool> _canDoNotifications() async {
|
||||
return Permission.notification.isGranted;
|
||||
}
|
||||
|
||||
/// When a notification is already visible, then build a new notification based on [c] and [m],
|
||||
/// update the database state and tell the OS to show the notification again.
|
||||
// TODO(Unknown): What about systems that cannot do this (Linux, OS X, Windows)?
|
||||
Future<void> updateOrShowNotification(
|
||||
modelc.Conversation c,
|
||||
modelm.Message m,
|
||||
String accountJid,
|
||||
) async {
|
||||
if (!(await _canDoNotifications())) {
|
||||
_log.warning(
|
||||
'updateNotification: Notifications permission not granted. Doing nothing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final notifications = await _getNotificationsForJid(c.jid, accountJid);
|
||||
final id = notifications.isNotEmpty
|
||||
? notifications.first.id
|
||||
: Random().nextInt(_maxNotificationId);
|
||||
// TODO(Unknown): Handle groupchat member avatars
|
||||
final notification = await _createNotification(
|
||||
c,
|
||||
m,
|
||||
accountJid,
|
||||
c.isGroupchat ? null : await c.avatarPathWithOptionalContactService,
|
||||
id,
|
||||
shouldOverride: true,
|
||||
);
|
||||
|
||||
await _api.showMessagingNotification(
|
||||
native.MessagingNotification(
|
||||
title: await c.titleWithOptionalContactService,
|
||||
id: id,
|
||||
channelId: messageNotificationChannelId,
|
||||
jid: c.jid,
|
||||
messages: notifications.map((n) {
|
||||
// Based on the table's composite primary key
|
||||
if (n.id == notification.id &&
|
||||
n.conversationJid == notification.conversationJid &&
|
||||
n.senderJid == notification.senderJid &&
|
||||
n.timestamp == notification.timestamp) {
|
||||
return notification.toNotificationMessage();
|
||||
}
|
||||
|
||||
return n.toNotificationMessage();
|
||||
}).toList(),
|
||||
isGroupchat: c.isGroupchat,
|
||||
groupId: messageNotificationGroupId,
|
||||
extra: {
|
||||
_conversationJidKey: c.jid,
|
||||
_messageIdKey: m.id,
|
||||
_conversationTitleKey: await c.titleWithOptionalContactService,
|
||||
_conversationAvatarKey: await c.avatarPathWithOptionalContactService,
|
||||
},
|
||||
),
|
||||
actionButtons: [
|
||||
NotificationActionButton(
|
||||
key: _notificationActionKeyReply,
|
||||
label: t.notifications.message.reply,
|
||||
requireInputText: true,
|
||||
autoDismissible: false,
|
||||
),
|
||||
NotificationActionButton(
|
||||
key: _notificationActionKeyRead,
|
||||
label: t.notifications.message.markAsRead,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a notification for a message [m] grouped by its conversationJid
|
||||
/// attribute. If the message is a media message, i.e. mediaUrl != null and isMedia == true,
|
||||
/// then Android's BigPicture will be used.
|
||||
Future<void> showNotification(
|
||||
modelc.Conversation c,
|
||||
modelm.Message m,
|
||||
String accountJid,
|
||||
String title, {
|
||||
String? body,
|
||||
}) async {
|
||||
if (!(await _canDoNotifications())) {
|
||||
_log.warning(
|
||||
'showNotification: Notifications permission not granted. Doing nothing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final notifications = await _getNotificationsForJid(c.jid, accountJid);
|
||||
final id = notifications.isNotEmpty
|
||||
? notifications.first.id
|
||||
: Random().nextInt(_maxNotificationId);
|
||||
await _api.showMessagingNotification(
|
||||
native.MessagingNotification(
|
||||
title: title,
|
||||
id: id,
|
||||
channelId: messageNotificationChannelId,
|
||||
jid: c.jid,
|
||||
messages: [
|
||||
...notifications.map((n) => n.toNotificationMessage()),
|
||||
// TODO(Unknown): Handle groupchat member avatars
|
||||
(await _createNotification(
|
||||
c,
|
||||
m,
|
||||
accountJid,
|
||||
c.isGroupchat ? null : await c.avatarPathWithOptionalContactService,
|
||||
id,
|
||||
))
|
||||
.toNotificationMessage(),
|
||||
],
|
||||
isGroupchat: c.isGroupchat,
|
||||
groupId: messageNotificationGroupId,
|
||||
extra: {
|
||||
_conversationJidKey: c.jid,
|
||||
_messageIdKey: m.id,
|
||||
_conversationTitleKey: await c.titleWithOptionalContactService,
|
||||
_conversationAvatarKey: await c.avatarPathWithOptionalContactService,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a notification with the highest priority that uses [title] as the title
|
||||
/// and [body] as the body.
|
||||
// TODO(Unknown): Use the warning icon as the notification icon
|
||||
Future<void> showWarningNotification(String title, String body) async {
|
||||
await AwesomeNotifications().createNotification(
|
||||
content: NotificationContent(
|
||||
id: Random().nextInt(_maxNotificationId),
|
||||
if (!(await _canDoNotifications())) {
|
||||
_log.warning(
|
||||
'showWarningNotification: Notifications permission not granted. Doing nothing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await _api.showNotification(
|
||||
native.RegularNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
channelKey: _warningChannelKey,
|
||||
channelId: warningNotificationChannelId,
|
||||
id: Random().nextInt(_maxNotificationId),
|
||||
icon: native.NotificationIcon.warning,
|
||||
groupId: warningNotificationGroupId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Show a notification for a bounced message with erorr [type] for a
|
||||
/// message in the chat with [jid].
|
||||
Future<void> showMessageErrorNotification(
|
||||
String jid,
|
||||
String accountJid,
|
||||
MessageErrorType type,
|
||||
) async {
|
||||
if (!(await _canDoNotifications())) {
|
||||
_log.warning(
|
||||
'showMessageErrorNotification: Notifications permission not granted. Doing nothing.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only show the notification for certain errors
|
||||
if (![
|
||||
MessageErrorType.remoteServerTimeout,
|
||||
MessageErrorType.remoteServerNotFound,
|
||||
MessageErrorType.serviceUnavailable
|
||||
].contains(type)) {
|
||||
return;
|
||||
}
|
||||
|
||||
final conversation = await GetIt.I
|
||||
.get<ConversationService>()
|
||||
.getConversationByJid(jid, accountJid);
|
||||
await _api.showNotification(
|
||||
native.RegularNotification(
|
||||
title: t.notifications.errors.messageError.title,
|
||||
body: t.notifications.errors.messageError
|
||||
.body(conversationTitle: conversation!.title),
|
||||
channelId: warningNotificationChannelId,
|
||||
id: Random().nextInt(_maxNotificationId),
|
||||
icon: native.NotificationIcon.error,
|
||||
groupId: warningNotificationGroupId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Since all notifications are grouped by the conversation's JID, this function
|
||||
/// clears all notifications for [jid].
|
||||
Future<void> dismissNotificationsByJid(String jid) async {
|
||||
await AwesomeNotifications().dismissNotificationsByGroupKey(jid);
|
||||
Future<void> dismissNotificationsByJid(String jid, String accountJid) async {
|
||||
final id = await _clearNotificationsForJid(jid, accountJid);
|
||||
if (id != null) {
|
||||
await _api.dismissNotification(id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dismisses all notifications for the context of [accountJid].
|
||||
Future<void> dismissAllNotifications(String accountJid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final ids = await db.query(
|
||||
notificationsTable,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [accountJid],
|
||||
columns: ['id'],
|
||||
distinct: true,
|
||||
);
|
||||
|
||||
// Dismiss the notification
|
||||
for (final idRaw in ids) {
|
||||
await _api.dismissNotification(idRaw['id']! as int);
|
||||
}
|
||||
|
||||
// Remove database entries
|
||||
await db.delete(
|
||||
notificationsTable,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [accountJid],
|
||||
);
|
||||
}
|
||||
|
||||
/// Requests the avatar path from [XmppStateService] and configures the notification plugin
|
||||
/// accordingly, if the avatar path is not null. If it is null, this method does nothing.
|
||||
Future<void> maybeSetAvatarFromState() async {
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final avatarPath = (await xss.state).avatarUrl;
|
||||
if (avatarPath.isNotEmpty) {
|
||||
await _api.setNotificationSelfAvatar(avatarPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,213 +1,43 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:hex/hex.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:moxxyv2/service/omemo/implementations.dart';
|
||||
import 'package:moxxyv2/service/omemo/types.dart';
|
||||
import 'package:moxxyv2/service/omemo/persistence.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/omemo_device.dart' as model;
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class OmemoDoubleRatchetWrapper {
|
||||
OmemoDoubleRatchetWrapper(this.ratchet, this.id, this.jid);
|
||||
final OmemoDoubleRatchet ratchet;
|
||||
final int id;
|
||||
final String jid;
|
||||
}
|
||||
|
||||
class OmemoService {
|
||||
/// Logger.
|
||||
final Logger _log = Logger('OmemoService');
|
||||
|
||||
/// Flag indicating whether we are initialized.
|
||||
bool _initialized = false;
|
||||
|
||||
/// Flag indicating whether the initialization is currently running.
|
||||
bool _running = false;
|
||||
|
||||
/// Lock guarding access to [_waitingForInitialization], [_running], and [_initialized].
|
||||
final Lock _lock = Lock();
|
||||
|
||||
/// Queue for code that is waiting on the service initialization.
|
||||
final Queue<Completer<void>> _waitingForInitialization =
|
||||
Queue<Completer<void>>();
|
||||
final Map<String, Map<int, String>> _fingerprintCache = {};
|
||||
|
||||
late OmemoManager omemoManager;
|
||||
/// The manager to use for OMEMO.
|
||||
late OmemoManager _omemoManager;
|
||||
|
||||
Future<void> initializeIfNeeded(String jid) async {
|
||||
final done = await _lock.synchronized(() => _initialized);
|
||||
if (done) return;
|
||||
|
||||
final device = await _loadOmemoDevice(jid);
|
||||
final ratchetMap = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
final deviceList = <String, List<int>>{};
|
||||
if (device == null) {
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
} else {
|
||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||
for (final ratchet in await _loadRatchets()) {
|
||||
final key = RatchetMapKey(ratchet.jid, ratchet.id);
|
||||
ratchetMap[key] = ratchet.ratchet;
|
||||
}
|
||||
|
||||
deviceList.addAll(await _loadOmemoDeviceList());
|
||||
}
|
||||
|
||||
final om = GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
omemoManager = OmemoManager(
|
||||
device ?? await compute(generateNewIdentityImpl, jid),
|
||||
await loadTrustManager(),
|
||||
om.sendEmptyMessageImpl,
|
||||
om.fetchDeviceList,
|
||||
om.fetchDeviceBundle,
|
||||
om.subscribeToDeviceListImpl,
|
||||
);
|
||||
|
||||
if (device == null) {
|
||||
await commitDevice(await omemoManager.getDevice());
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
}
|
||||
|
||||
omemoManager.initialize(
|
||||
ratchetMap,
|
||||
deviceList,
|
||||
);
|
||||
|
||||
omemoManager.eventStream.listen((event) async {
|
||||
if (event is RatchetModifiedEvent) {
|
||||
await _saveRatchet(
|
||||
OmemoDoubleRatchetWrapper(
|
||||
event.ratchet,
|
||||
event.deviceId,
|
||||
event.jid,
|
||||
),
|
||||
);
|
||||
|
||||
if (event.added) {
|
||||
// Cache the fingerprint
|
||||
final fingerprint = await event.ratchet.getOmemoFingerprint();
|
||||
await _addFingerprintsToCache([
|
||||
OmemoCacheTriple(
|
||||
event.jid,
|
||||
event.deviceId,
|
||||
fingerprint,
|
||||
),
|
||||
]);
|
||||
|
||||
if (_fingerprintCache.containsKey(event.jid)) {
|
||||
_fingerprintCache[event.jid]![event.deviceId] = fingerprint;
|
||||
}
|
||||
|
||||
await addNewDeviceMessage(event.jid, event.deviceId);
|
||||
}
|
||||
} else if (event is DeviceListModifiedEvent) {
|
||||
await commitDeviceMap(event.list);
|
||||
} else if (event is DeviceModifiedEvent) {
|
||||
await commitDevice(event.device);
|
||||
|
||||
// Publish it
|
||||
await GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.publishBundle(await event.device.toBundle());
|
||||
}
|
||||
});
|
||||
|
||||
await _lock.synchronized(() {
|
||||
_initialized = true;
|
||||
|
||||
for (final c in _waitingForInitialization) {
|
||||
c.complete();
|
||||
}
|
||||
_waitingForInitialization.clear();
|
||||
});
|
||||
}
|
||||
|
||||
/// Adds a pseudo message saying that [jid] added a new device with id [deviceId].
|
||||
/// If, however, [jid] is our own JID, then nothing is done.
|
||||
Future<void> addNewDeviceMessage(String jid, int deviceId) async {
|
||||
// Add a pseudo message if it is not about our own devices
|
||||
final xmppState = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||
if (jid == xmppState.jid) return;
|
||||
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final message = await ms.addMessageFromData(
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
'',
|
||||
jid,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
pseudoMessageType: pseudoMessageTypeNewDevice,
|
||||
pseudoMessageData: <String, dynamic>{
|
||||
'deviceId': deviceId,
|
||||
'jid': jid,
|
||||
},
|
||||
);
|
||||
sendEvent(
|
||||
MessageAddedEvent(
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<model.OmemoDevice> regenerateDevice(String jid) async {
|
||||
// Prevent access to the session manager as it is (mostly) guarded ensureInitialized
|
||||
await _lock.synchronized(() {
|
||||
_initialized = false;
|
||||
});
|
||||
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
final oldId = await omemoManager.getDeviceId();
|
||||
|
||||
// Clear the database
|
||||
await _emptyOmemoSessionTables();
|
||||
|
||||
// Regenerate the identity in the background
|
||||
final device = await compute(generateNewIdentityImpl, jid);
|
||||
await omemoManager.replaceDevice(device);
|
||||
await commitDevice(device);
|
||||
await commitDeviceMap(<String, List<int>>{});
|
||||
await commitTrustManager(await omemoManager.trustManager.toJson());
|
||||
|
||||
// Remove the old device
|
||||
final omemo = GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
await omemo.deleteDevice(oldId);
|
||||
|
||||
// Publish the new one
|
||||
await omemo.publishBundle(await omemoManager.getDeviceBundle());
|
||||
|
||||
// Allow access again
|
||||
await _lock.synchronized(() {
|
||||
_initialized = true;
|
||||
|
||||
for (final c in _waitingForInitialization) {
|
||||
c.complete();
|
||||
}
|
||||
_waitingForInitialization.clear();
|
||||
});
|
||||
|
||||
// Return the OmemoDevice
|
||||
return model.OmemoDevice(
|
||||
await getDeviceFingerprint(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
await getDeviceId(),
|
||||
);
|
||||
/// Access the underlying [OmemoManager].
|
||||
Future<OmemoManager> getOmemoManager() async {
|
||||
await ensureInitialized();
|
||||
return _omemoManager;
|
||||
}
|
||||
|
||||
/// Ensures that the code following this *AWAITED* call can access every method
|
||||
@@ -228,27 +58,79 @@ class OmemoService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> commitDeviceMap(Map<String, List<int>> deviceMap) async {
|
||||
await _saveOmemoDeviceList(deviceMap);
|
||||
/// Creates or loads the [OmemoManager] for the JID [jid].
|
||||
Future<void> initializeIfNeeded(String jid) async {
|
||||
final done = await _lock.synchronized(() {
|
||||
// Do nothing if we're already initialized
|
||||
if (_initialized) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> commitDevice(OmemoDevice device) async {
|
||||
await _saveOmemoDevice(device);
|
||||
// Lock the execution if we're not yet running.
|
||||
if (_running) {
|
||||
return true;
|
||||
}
|
||||
_running = true;
|
||||
return false;
|
||||
});
|
||||
if (done) return;
|
||||
|
||||
final device = await loadOmemoDevice(jid);
|
||||
if (device == null) {
|
||||
_log.info('No OMEMO marker found. Generating OMEMO identity...');
|
||||
} else {
|
||||
_log.info('OMEMO marker found. Restoring OMEMO state...');
|
||||
}
|
||||
|
||||
/// Requests our device list and checks if the current device is in it. If not, then
|
||||
/// it will be published.
|
||||
Future<Object?> publishDeviceIfNeeded() async {
|
||||
final om = GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
|
||||
|
||||
_omemoManager = OmemoManager(
|
||||
device ?? await compute(generateNewIdentityImpl, jid),
|
||||
BlindTrustBeforeVerificationTrustManager(
|
||||
commit: commitTrust,
|
||||
loadData: loadTrust,
|
||||
removeTrust: removeTrust,
|
||||
),
|
||||
om.sendEmptyMessageImpl,
|
||||
om.fetchDeviceList,
|
||||
om.fetchDeviceBundle,
|
||||
om.subscribeToDeviceListImpl,
|
||||
om.publishDeviceImpl,
|
||||
commitDevice: commitDevice,
|
||||
commitRatchets: commitRatchets,
|
||||
commitDeviceList: commitDeviceList,
|
||||
removeRatchets: removeRatchets,
|
||||
loadRatchets: loadRatchets,
|
||||
);
|
||||
|
||||
if (device == null) {
|
||||
await commitDevice(await _omemoManager.getDevice());
|
||||
}
|
||||
|
||||
await _lock.synchronized(() {
|
||||
_running = false;
|
||||
_initialized = true;
|
||||
|
||||
for (final c in _waitingForInitialization) {
|
||||
c.complete();
|
||||
}
|
||||
_waitingForInitialization.clear();
|
||||
});
|
||||
}
|
||||
|
||||
Future<moxxmpp.OmemoError?> publishDeviceIfNeeded() async {
|
||||
_log.finest('publishDeviceIfNeeded: Waiting for initialization...');
|
||||
await ensureInitialized();
|
||||
_log.finest('publishDeviceIfNeeded: Done');
|
||||
|
||||
final conn = GetIt.I.get<moxxmpp.XmppConnection>();
|
||||
final omemo =
|
||||
conn.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!;
|
||||
conn.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!;
|
||||
final dm = conn.getManagerById<moxxmpp.DiscoManager>(moxxmpp.discoManager)!;
|
||||
final bareJid = conn.connectionSettings.jid.toBare();
|
||||
final device = await omemoManager.getDevice();
|
||||
final device = await _omemoManager.getDevice();
|
||||
|
||||
final bundlesRaw = await dm.discoItemsQuery(
|
||||
bareJid,
|
||||
@@ -256,7 +138,7 @@ class OmemoService {
|
||||
);
|
||||
if (bundlesRaw.isType<moxxmpp.DiscoError>()) {
|
||||
await omemo.publishBundle(await device.toBundle());
|
||||
return bundlesRaw.get<moxxmpp.DiscoError>();
|
||||
return null;
|
||||
}
|
||||
|
||||
final bundleIds = bundlesRaw
|
||||
@@ -285,469 +167,116 @@ class OmemoService {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _fetchFingerprintsAndCache(moxxmpp.JID jid) async {
|
||||
final bareJid = jid.toBare().toString();
|
||||
final allDevicesRaw = await GetIt.I
|
||||
Future<void> onNewConnection() async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.onNewConnection();
|
||||
}
|
||||
|
||||
Future<List<model.OmemoDevice>> getFingerprintsForJid(String jid) async {
|
||||
await ensureInitialized();
|
||||
final fingerprints = await _omemoManager.getFingerprintsForJid(jid) ?? [];
|
||||
var trust = <int, BTBVTrustData>{};
|
||||
|
||||
await _omemoManager.withTrustManager(
|
||||
jid,
|
||||
(tm) async {
|
||||
trust = await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||
.getDevicesTrust(jid);
|
||||
},
|
||||
);
|
||||
|
||||
return fingerprints.map((fp) {
|
||||
return model.OmemoDevice(
|
||||
fp.fingerprint,
|
||||
trust[fp.deviceId]?.trusted ?? false,
|
||||
trust[fp.deviceId]?.state == BTBVTrustState.verified,
|
||||
trust[fp.deviceId]?.enabled ?? false,
|
||||
fp.deviceId,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> setDeviceEnablement(String jid, int device, bool state) async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.withTrustManager(jid, (tm) async {
|
||||
await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||
.setEnabled(jid, device, state);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> setDeviceVerified(String jid, int device) async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.withTrustManager(jid, (tm) async {
|
||||
await (tm as BlindTrustBeforeVerificationTrustManager)
|
||||
.setDeviceTrust(jid, device, BTBVTrustState.verified);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeAllRatchets(String jid) async {
|
||||
await ensureInitialized();
|
||||
await _omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
Future<OmemoDevice> getDevice() async {
|
||||
await ensureInitialized();
|
||||
return _omemoManager.getDevice();
|
||||
}
|
||||
|
||||
Future<model.OmemoDevice> regenerateDevice() async {
|
||||
await ensureInitialized();
|
||||
|
||||
final oldDeviceId = (await getDevice()).id;
|
||||
|
||||
// Generate the new device
|
||||
final newDevice = await _omemoManager.regenerateDevice();
|
||||
|
||||
// Remove the old device
|
||||
unawaited(
|
||||
GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.BaseOmemoManager>(moxxmpp.omemoManager)!
|
||||
.retrieveDeviceBundles(jid);
|
||||
if (allDevicesRaw.isType<List<OmemoBundle>>()) {
|
||||
final allDevices = allDevicesRaw.get<List<OmemoBundle>>();
|
||||
final map = <int, String>{};
|
||||
final items = List<OmemoCacheTriple>.empty(growable: true);
|
||||
for (final device in allDevices) {
|
||||
final curveIk = await device.ik.toCurve25519();
|
||||
final fingerprint = HEX.encode(await curveIk.getBytes());
|
||||
map[device.id] = fingerprint;
|
||||
items.add(OmemoCacheTriple(bareJid, device.id, fingerprint));
|
||||
}
|
||||
|
||||
// Cache them in memory
|
||||
_fingerprintCache[bareJid] = map;
|
||||
|
||||
// Cache them in the database
|
||||
await _addFingerprintsToCache(items);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadOrFetchFingerprints(moxxmpp.JID jid) async {
|
||||
final bareJid = jid.toBare().toString();
|
||||
if (!_fingerprintCache.containsKey(bareJid)) {
|
||||
// First try to load it from the database
|
||||
final triples = await _getFingerprintsFromCache(bareJid);
|
||||
if (triples.isEmpty) {
|
||||
// We found no fingerprints in the database, so try to fetch them
|
||||
await _fetchFingerprintsAndCache(jid);
|
||||
} else {
|
||||
// We have fetched fingerprints from the database
|
||||
_fingerprintCache[bareJid] = Map<int, String>.fromEntries(
|
||||
triples.map((triple) {
|
||||
return MapEntry<int, String>(
|
||||
triple.deviceId,
|
||||
triple.fingerprint,
|
||||
.getManagerById<moxxmpp.OmemoManager>(moxxmpp.omemoManager)!
|
||||
.deleteDevice(oldDeviceId),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<model.OmemoDevice>> getOmemoKeysForJid(String jid) async {
|
||||
await ensureInitialized();
|
||||
|
||||
// Get finger prints if we have to
|
||||
await _loadOrFetchFingerprints(moxxmpp.JID.fromString(jid));
|
||||
|
||||
final keys = List<model.OmemoDevice>.empty(growable: true);
|
||||
final tm =
|
||||
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(jid);
|
||||
|
||||
if (!_fingerprintCache.containsKey(jid)) return [];
|
||||
for (final deviceId in _fingerprintCache[jid]!.keys) {
|
||||
keys.add(
|
||||
model.OmemoDevice(
|
||||
_fingerprintCache[jid]![deviceId]!,
|
||||
await tm.isTrusted(jid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
await tm.isEnabled(jid, deviceId),
|
||||
deviceId,
|
||||
),
|
||||
return model.OmemoDevice(
|
||||
await newDevice.getFingerprint(),
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
newDevice.id,
|
||||
);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
Future<void> commitTrustManager(Map<String, dynamic> json) async {
|
||||
await _saveTrustCache(
|
||||
json['trust']! as Map<String, int>,
|
||||
);
|
||||
await _saveTrustEnablementList(
|
||||
json['enable']! as Map<String, bool>,
|
||||
);
|
||||
await _saveTrustDeviceList(
|
||||
json['devices']! as Map<String, List<int>>,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MoxxyBTBVTrustManager> loadTrustManager() async {
|
||||
return MoxxyBTBVTrustManager(
|
||||
await _loadTrustCache(),
|
||||
await _loadTrustEnablementList(),
|
||||
await _loadTrustDeviceList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setOmemoKeyEnabled(
|
||||
String jid,
|
||||
int deviceId,
|
||||
bool enabled,
|
||||
/// Adds a pseudo-message of type [type] to the chat with [conversationJid].
|
||||
/// Also sends an event to the UI.
|
||||
Future<void> addPseudoMessage(
|
||||
String conversationJid,
|
||||
String accountJid,
|
||||
PseudoMessageType type,
|
||||
int ratchetsAdded,
|
||||
int ratchetsReplaced,
|
||||
) async {
|
||||
await ensureInitialized();
|
||||
await omemoManager.trustManager.setEnabled(jid, deviceId, enabled);
|
||||
}
|
||||
|
||||
Future<void> removeAllSessions(String jid) async {
|
||||
await ensureInitialized();
|
||||
await omemoManager.removeAllRatchets(jid);
|
||||
}
|
||||
|
||||
Future<int> getDeviceId() async {
|
||||
await ensureInitialized();
|
||||
return omemoManager.getDeviceId();
|
||||
}
|
||||
|
||||
Future<String> getDeviceFingerprint() => omemoManager.getDeviceFingerprint();
|
||||
|
||||
/// Returns a list of OmemoDevices for devices we have sessions with and other devices
|
||||
/// published on [ownJid]'s devices PubSub node.
|
||||
/// Note that the list is made so that the current device is excluded.
|
||||
Future<List<model.OmemoDevice>> getOwnFingerprints(moxxmpp.JID ownJid) async {
|
||||
final ownId = await getDeviceId();
|
||||
final keys = List<model.OmemoDevice>.from(
|
||||
await getOmemoKeysForJid(ownJid.toString()),
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final message = await ms.addMessageFromData(
|
||||
accountJid,
|
||||
'',
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
'',
|
||||
conversationJid,
|
||||
'',
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
pseudoMessageType: type,
|
||||
pseudoMessageData: {
|
||||
'ratchetsAdded': ratchetsAdded,
|
||||
'ratchetsReplaced': ratchetsReplaced,
|
||||
},
|
||||
);
|
||||
final bareJid = ownJid.toBare().toString();
|
||||
|
||||
// Get fingerprints if we have to
|
||||
await _loadOrFetchFingerprints(ownJid);
|
||||
|
||||
final tm =
|
||||
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
final trustMap = await tm.getDevicesTrust(bareJid);
|
||||
|
||||
for (final deviceId in _fingerprintCache[bareJid]!.keys) {
|
||||
if (deviceId == ownId) continue;
|
||||
if (keys.indexWhere((key) => key.deviceId == deviceId) != -1) continue;
|
||||
|
||||
final fingerprint = _fingerprintCache[bareJid]![deviceId]!;
|
||||
keys.add(
|
||||
model.OmemoDevice(
|
||||
fingerprint,
|
||||
await tm.isTrusted(bareJid, deviceId),
|
||||
trustMap[deviceId] == BTBVTrustState.verified,
|
||||
await tm.isEnabled(bareJid, deviceId),
|
||||
deviceId,
|
||||
hasSessionWith: false,
|
||||
sendEvent(
|
||||
MessageAddedEvent(
|
||||
message: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
Future<void> verifyDevice(int deviceId, String jid) async {
|
||||
final tm =
|
||||
omemoManager.trustManager as BlindTrustBeforeVerificationTrustManager;
|
||||
await tm.setDeviceTrust(
|
||||
jid,
|
||||
deviceId,
|
||||
BTBVTrustState.verified,
|
||||
);
|
||||
}
|
||||
|
||||
/// Tells omemo_dart, that certain caches are to be seen as invalidated.
|
||||
void onNewConnection() {
|
||||
if (_initialized) {
|
||||
omemoManager.onNewConnection();
|
||||
}
|
||||
}
|
||||
|
||||
/// Database methods
|
||||
|
||||
Future<List<OmemoDoubleRatchetWrapper>> _loadRatchets() async {
|
||||
final results =
|
||||
await GetIt.I.get<DatabaseService>().database.query(omemoRatchetsTable);
|
||||
|
||||
return results.map((ratchet) {
|
||||
final json = jsonDecode(ratchet['mkskipped']! as String) as List<dynamic>;
|
||||
final mkskipped = List<Map<String, dynamic>>.empty(growable: true);
|
||||
for (final i in json) {
|
||||
final element = i as Map<String, dynamic>;
|
||||
mkskipped.add({
|
||||
'key': element['key']! as String,
|
||||
'public': element['public']! as String,
|
||||
'n': element['n']! as int,
|
||||
});
|
||||
}
|
||||
|
||||
return OmemoDoubleRatchetWrapper(
|
||||
OmemoDoubleRatchet.fromJson(
|
||||
{
|
||||
...ratchet,
|
||||
'acknowledged': intToBool(ratchet['acknowledged']! as int),
|
||||
'mkskipped': mkskipped,
|
||||
},
|
||||
),
|
||||
ratchet['id']! as int,
|
||||
ratchet['jid']! as String,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> _saveRatchet(OmemoDoubleRatchetWrapper ratchet) async {
|
||||
final json = await ratchet.ratchet.toJson();
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
omemoRatchetsTable,
|
||||
{
|
||||
...json,
|
||||
'mkskipped': jsonEncode(json['mkskipped']),
|
||||
'acknowledged': boolToInt(json['acknowledged']! as bool),
|
||||
'jid': ratchet.jid,
|
||||
'id': ratchet.id,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<RatchetMapKey, BTBVTrustState>> _loadTrustCache() async {
|
||||
final entries = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoTrustCacheTable);
|
||||
|
||||
final mapEntries =
|
||||
entries.map<MapEntry<RatchetMapKey, BTBVTrustState>>((entry) {
|
||||
// TODO(PapaTutuWawa): Expose this from omemo_dart
|
||||
BTBVTrustState state;
|
||||
final value = entry['trust']! as int;
|
||||
if (value == 1) {
|
||||
state = BTBVTrustState.notTrusted;
|
||||
} else if (value == 2) {
|
||||
state = BTBVTrustState.blindTrust;
|
||||
} else if (value == 3) {
|
||||
state = BTBVTrustState.verified;
|
||||
} else {
|
||||
state = BTBVTrustState.notTrusted;
|
||||
}
|
||||
|
||||
return MapEntry(
|
||||
RatchetMapKey.fromJsonKey(entry['key']! as String),
|
||||
state,
|
||||
);
|
||||
});
|
||||
|
||||
return Map.fromEntries(mapEntries);
|
||||
}
|
||||
|
||||
Future<void> _saveTrustCache(Map<String, int> cache) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoTrustCacheTable);
|
||||
for (final entry in cache.entries) {
|
||||
batch.insert(
|
||||
omemoTrustCacheTable,
|
||||
{
|
||||
'key': entry.key,
|
||||
'trust': entry.value,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<Map<RatchetMapKey, bool>> _loadTrustEnablementList() async {
|
||||
final entries = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoTrustEnableListTable);
|
||||
|
||||
final mapEntries = entries.map<MapEntry<RatchetMapKey, bool>>((entry) {
|
||||
return MapEntry(
|
||||
RatchetMapKey.fromJsonKey(entry['key']! as String),
|
||||
intToBool(entry['enabled']! as int),
|
||||
);
|
||||
});
|
||||
|
||||
return Map.fromEntries(mapEntries);
|
||||
}
|
||||
|
||||
Future<void> _saveTrustEnablementList(Map<String, bool> list) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoTrustEnableListTable);
|
||||
for (final entry in list.entries) {
|
||||
batch.insert(
|
||||
omemoTrustEnableListTable,
|
||||
{
|
||||
'key': entry.key,
|
||||
'enabled': boolToInt(entry.value),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> _loadTrustDeviceList() async {
|
||||
final entries = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoTrustDeviceListTable);
|
||||
|
||||
final map = <String, List<int>>{};
|
||||
for (final entry in entries) {
|
||||
final key = entry['jid']! as String;
|
||||
final device = entry['device']! as int;
|
||||
|
||||
if (map.containsKey(key)) {
|
||||
map[key]!.add(device);
|
||||
} else {
|
||||
map[key] = [device];
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<void> _saveTrustDeviceList(Map<String, List<int>> list) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoTrustDeviceListTable);
|
||||
for (final entry in list.entries) {
|
||||
for (final device in entry.value) {
|
||||
batch.insert(
|
||||
omemoTrustDeviceListTable,
|
||||
{
|
||||
'jid': entry.key,
|
||||
'device': device,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> _saveOmemoDevice(OmemoDevice device) async {
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
omemoDeviceTable,
|
||||
{
|
||||
'jid': device.jid,
|
||||
'id': device.id,
|
||||
'data': jsonEncode(await device.toJson()),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<OmemoDevice?> _loadOmemoDevice(String jid) async {
|
||||
final data = await GetIt.I.get<DatabaseService>().database.query(
|
||||
omemoDeviceTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
limit: 1,
|
||||
);
|
||||
if (data.isEmpty) return null;
|
||||
|
||||
final deviceJson =
|
||||
jsonDecode(data.first['data']! as String) as Map<String, dynamic>;
|
||||
// NOTE: We need to do this because Dart otherwise complains about not being able
|
||||
// to cast dynamic to List<int>.
|
||||
final opks = List<Map<String, dynamic>>.empty(growable: true);
|
||||
final opksIter = deviceJson['opks']! as List<dynamic>;
|
||||
for (final tmpOpk in opksIter) {
|
||||
final opk = tmpOpk as Map<String, dynamic>;
|
||||
opks.add(<String, dynamic>{
|
||||
'id': opk['id']! as int,
|
||||
'public': opk['public']! as String,
|
||||
'private': opk['private']! as String,
|
||||
});
|
||||
}
|
||||
deviceJson['opks'] = opks;
|
||||
return OmemoDevice.fromJson(deviceJson);
|
||||
}
|
||||
|
||||
Future<Map<String, List<int>>> _loadOmemoDeviceList() async {
|
||||
final list = await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(omemoDeviceListTable);
|
||||
final map = <String, List<int>>{};
|
||||
for (final entry in list) {
|
||||
final key = entry['jid']! as String;
|
||||
final id = entry['id']! as int;
|
||||
|
||||
if (map.containsKey(key)) {
|
||||
map[key]!.add(id);
|
||||
} else {
|
||||
map[key] = [id];
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
Future<void> _saveOmemoDeviceList(Map<String, List<int>> list) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch.delete(omemoDeviceListTable);
|
||||
for (final entry in list.entries) {
|
||||
for (final id in entry.value) {
|
||||
batch.insert(
|
||||
omemoDeviceListTable,
|
||||
{
|
||||
'jid': entry.key,
|
||||
'id': id,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> _emptyOmemoSessionTables() async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
|
||||
// ignore: cascade_invocations
|
||||
batch
|
||||
..delete(omemoRatchetsTable)
|
||||
..delete(omemoTrustCacheTable)
|
||||
..delete(omemoTrustEnableListTable);
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> _addFingerprintsToCache(List<OmemoCacheTriple> items) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
for (final item in items) {
|
||||
batch.insert(
|
||||
omemoFingerprintCache,
|
||||
<String, dynamic>{
|
||||
'jid': item.jid,
|
||||
'id': item.deviceId,
|
||||
'fingerprint': item.fingerprint,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<List<OmemoCacheTriple>> _getFingerprintsFromCache(String jid) async {
|
||||
final rawItems = await GetIt.I.get<DatabaseService>().database.query(
|
||||
omemoFingerprintCache,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
|
||||
return rawItems.map((item) {
|
||||
return OmemoCacheTriple(
|
||||
jid,
|
||||
item['id']! as int,
|
||||
item['fingerprint']! as String,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
318
lib/service/omemo/persistence.dart
Normal file
318
lib/service/omemo/persistence.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
import 'dart:convert';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
import 'package:sqflite_common/sql.dart';
|
||||
|
||||
extension ByteListHelpers on List<int> {
|
||||
String toBase64() {
|
||||
return base64Encode(this);
|
||||
}
|
||||
|
||||
OmemoPublicKey toPublicKey(KeyPairType type) {
|
||||
return OmemoPublicKey.fromBytes(this, type);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> commitDevice(OmemoDevice device) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final serializedOpks = <String, Map<String, String>>{};
|
||||
for (final entry in device.opks.entries) {
|
||||
serializedOpks[entry.key.toString()] = {
|
||||
'public': base64Encode(await entry.value.pk.getBytes()),
|
||||
'private': base64Encode(await entry.value.sk.getBytes()),
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(
|
||||
omemoDevicesTable,
|
||||
{
|
||||
'jid': device.jid,
|
||||
'id': device.id,
|
||||
'ikPub': base64Encode(await device.ik.pk.getBytes()),
|
||||
'ik': base64Encode(await device.ik.sk.getBytes()),
|
||||
'spkPub': base64Encode(await device.spk.pk.getBytes()),
|
||||
'spk': base64Encode(await device.spk.sk.getBytes()),
|
||||
'spkId': device.spkId,
|
||||
'spkSig': base64Encode(device.spkSignature),
|
||||
'oldSpkPub': (await device.oldSpk?.pk.getBytes())?.toBase64(),
|
||||
'oldSpk': (await device.oldSpk?.sk.getBytes())?.toBase64(),
|
||||
'oldSpkId': device.oldSpkId,
|
||||
'opks': jsonEncode(serializedOpks),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<OmemoDevice?> loadOmemoDevice(String jid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final rawDevice = await db.query(
|
||||
omemoDevicesTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
limit: 1,
|
||||
);
|
||||
if (rawDevice.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final deviceJson = rawDevice.first;
|
||||
|
||||
// Deserialize the OPKs first
|
||||
final deserializedOpks = <int, OmemoKeyPair>{};
|
||||
final opks =
|
||||
(jsonDecode(rawDevice.first['opks']! as String) as Map<dynamic, dynamic>)
|
||||
.cast<String, dynamic>();
|
||||
for (final opk in opks.entries) {
|
||||
final opkValue = (opk.value as Map<String, dynamic>).cast<String, String>();
|
||||
deserializedOpks[int.parse(opk.key)] = OmemoKeyPair.fromBytes(
|
||||
base64Decode(opkValue['public']!),
|
||||
base64Decode(opkValue['private']!),
|
||||
KeyPairType.x25519,
|
||||
);
|
||||
}
|
||||
|
||||
OmemoKeyPair? oldSpk;
|
||||
if (deviceJson['oldSpkPub'] != null && deviceJson['oldSpk'] != null) {
|
||||
oldSpk = OmemoKeyPair.fromBytes(
|
||||
base64Decode(deviceJson['oldSpkPub']! as String),
|
||||
base64Decode(deviceJson['oldSpk']! as String),
|
||||
KeyPairType.x25519,
|
||||
);
|
||||
}
|
||||
|
||||
return OmemoDevice(
|
||||
jid,
|
||||
deviceJson['id']! as int,
|
||||
OmemoKeyPair.fromBytes(
|
||||
base64Decode(deviceJson['ikPub']! as String),
|
||||
base64Decode(deviceJson['ik']! as String),
|
||||
KeyPairType.ed25519,
|
||||
),
|
||||
OmemoKeyPair.fromBytes(
|
||||
base64Decode(deviceJson['spkPub']! as String),
|
||||
base64Decode(deviceJson['spk']! as String),
|
||||
KeyPairType.x25519,
|
||||
),
|
||||
deviceJson['spkId']! as int,
|
||||
base64Decode(deviceJson['spkSig']! as String),
|
||||
oldSpk,
|
||||
deviceJson['oldSpkId'] as int?,
|
||||
deserializedOpks,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> commitRatchets(List<OmemoRatchetData> ratchets) async {
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final batch = db.batch();
|
||||
for (final ratchet in ratchets) {
|
||||
// Serialize the skipped keys
|
||||
final serializedSkippedKeys = <Map<String, Object>>[];
|
||||
for (final sk in ratchet.ratchet.mkSkipped.entries) {
|
||||
serializedSkippedKeys.add({
|
||||
'dhPub': (await sk.key.dh.getBytes()).toBase64(),
|
||||
'n': sk.key.n,
|
||||
'mk': sk.value.toBase64(),
|
||||
});
|
||||
}
|
||||
|
||||
// Serialize the KEX
|
||||
final kex = {
|
||||
'pkId': ratchet.ratchet.kex.pkId,
|
||||
'spkId': ratchet.ratchet.kex.spkId,
|
||||
'ek': (await ratchet.ratchet.kex.ek.getBytes()).toBase64(),
|
||||
'ik': (await ratchet.ratchet.kex.ik.getBytes()).toBase64(),
|
||||
};
|
||||
|
||||
batch.insert(
|
||||
omemoRatchetsTable,
|
||||
{
|
||||
'jid': ratchet.jid,
|
||||
'device': ratchet.id,
|
||||
'dhsPub': base64Encode(await ratchet.ratchet.dhs.pk.getBytes()),
|
||||
'dhs': base64Encode(await ratchet.ratchet.dhs.sk.getBytes()),
|
||||
'dhrPub': (await ratchet.ratchet.dhr?.getBytes())?.toBase64(),
|
||||
'rk': base64Encode(ratchet.ratchet.rk),
|
||||
'cks': ratchet.ratchet.cks?.toBase64(),
|
||||
'ckr': ratchet.ratchet.ckr?.toBase64(),
|
||||
'ns': ratchet.ratchet.ns,
|
||||
'nr': ratchet.ratchet.nr,
|
||||
'pn': ratchet.ratchet.pn,
|
||||
'ik': (await ratchet.ratchet.ik.getBytes()).toBase64(),
|
||||
'ad': ratchet.ratchet.sessionAd.toBase64(),
|
||||
'skipped': jsonEncode(serializedSkippedKeys),
|
||||
'kex': jsonEncode(kex),
|
||||
'acked': boolToInt(ratchet.ratchet.acknowledged),
|
||||
'accountJid': accountJid,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> commitDeviceList(String jid, List<int> devices) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
await db.insert(
|
||||
omemoDeviceListTable,
|
||||
{
|
||||
'jid': jid,
|
||||
'devices': jsonEncode(devices),
|
||||
'accountJid': await GetIt.I.get<XmppStateService>().getAccountJid(),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> removeRatchets(List<RatchetMapKey> ratchets) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final batch = db.batch();
|
||||
|
||||
for (final key in ratchets) {
|
||||
batch.delete(
|
||||
omemoRatchetsTable,
|
||||
where: 'jid = ? AND device = ? AND accountJid = ?',
|
||||
whereArgs: [
|
||||
key.jid,
|
||||
key.deviceId,
|
||||
await GetIt.I.get<XmppStateService>().getAccountJid(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<OmemoDataPackage?> loadRatchets(String jid) async {
|
||||
final accountJid = await GetIt.I.get<XmppStateService>().getAccountJid();
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final ratchetsRaw = await db.query(
|
||||
omemoRatchetsTable,
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
final deviceListRaw = await db.query(
|
||||
omemoDeviceListTable,
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
limit: 1,
|
||||
);
|
||||
if (ratchetsRaw.isEmpty || deviceListRaw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deserialize the ratchets
|
||||
final ratchets = <RatchetMapKey, OmemoDoubleRatchet>{};
|
||||
for (final ratchetRaw in ratchetsRaw) {
|
||||
final key = RatchetMapKey(
|
||||
jid,
|
||||
ratchetRaw['device']! as int,
|
||||
);
|
||||
|
||||
// Deserialize skipped keys
|
||||
final mkSkipped = <SkippedKey, List<int>>{};
|
||||
final skippedKeysRaw =
|
||||
(jsonDecode(ratchetRaw['skipped']! as String) as List<dynamic>)
|
||||
.cast<Map<dynamic, dynamic>>();
|
||||
for (final skippedRaw in skippedKeysRaw) {
|
||||
final key = SkippedKey(
|
||||
(skippedRaw['dhPub']! as String)
|
||||
.fromBase64()
|
||||
.toPublicKey(KeyPairType.x25519),
|
||||
skippedRaw['n']! as int,
|
||||
);
|
||||
mkSkipped[key] = (skippedRaw['mk']! as String).fromBase64();
|
||||
}
|
||||
|
||||
// Deserialize the KEX
|
||||
final kexRaw =
|
||||
(jsonDecode(ratchetRaw['kex']! as String) as Map<dynamic, dynamic>)
|
||||
.cast<String, Object>();
|
||||
final kex = KeyExchangeData(
|
||||
kexRaw['pkId']! as int,
|
||||
kexRaw['spkId']! as int,
|
||||
(kexRaw['ek']! as String).fromBase64().toPublicKey(KeyPairType.x25519),
|
||||
(kexRaw['ik']! as String).fromBase64().toPublicKey(KeyPairType.ed25519),
|
||||
);
|
||||
|
||||
// Deserialize the entire ratchet
|
||||
ratchets[key] = OmemoDoubleRatchet(
|
||||
OmemoKeyPair.fromBytes(
|
||||
base64Decode(ratchetRaw['dhsPub']! as String),
|
||||
base64Decode(ratchetRaw['dhs']! as String),
|
||||
KeyPairType.x25519,
|
||||
),
|
||||
(ratchetRaw['dhrPub'] as String?)
|
||||
?.fromBase64()
|
||||
.toPublicKey(KeyPairType.x25519),
|
||||
base64Decode(ratchetRaw['rk']! as String),
|
||||
(ratchetRaw['cks'] as String?)?.fromBase64(),
|
||||
(ratchetRaw['ckr'] as String?)?.fromBase64(),
|
||||
ratchetRaw['ns']! as int,
|
||||
ratchetRaw['nr']! as int,
|
||||
ratchetRaw['pn']! as int,
|
||||
(ratchetRaw['ik']! as String)
|
||||
.fromBase64()
|
||||
.toPublicKey(KeyPairType.ed25519),
|
||||
(ratchetRaw['ad']! as String).fromBase64(),
|
||||
mkSkipped,
|
||||
intToBool(ratchetRaw['acked']! as int),
|
||||
kex,
|
||||
);
|
||||
}
|
||||
|
||||
return OmemoDataPackage(
|
||||
(jsonDecode(deviceListRaw.first['devices']! as String) as List<dynamic>)
|
||||
.cast<int>(),
|
||||
ratchets,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> commitTrust(BTBVTrustData trust) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
await db.insert(
|
||||
omemoTrustTable,
|
||||
{
|
||||
'jid': trust.jid,
|
||||
'device': trust.device,
|
||||
'trust': trust.state.value,
|
||||
'enabled': boolToInt(trust.enabled),
|
||||
'accountJid': await GetIt.I.get<XmppStateService>().getAccountJid(),
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<BTBVTrustData>> loadTrust(String jid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final rawTrust = await db.query(
|
||||
omemoTrustTable,
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, await GetIt.I.get<XmppStateService>().getAccountJid()],
|
||||
);
|
||||
|
||||
return rawTrust.map((trust) {
|
||||
return BTBVTrustData(
|
||||
jid,
|
||||
trust['device']! as int,
|
||||
BTBVTrustState.fromInt(trust['trust']! as int),
|
||||
intToBool(trust['enabled']! as int),
|
||||
false,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Future<void> removeTrust(String jid) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
await db.delete(
|
||||
omemoTrustTable,
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, await GetIt.I.get<XmppStateService>().getAccountJid()],
|
||||
);
|
||||
}
|
||||
41
lib/service/permissions.dart
Normal file
41
lib/service/permissions.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
|
||||
class PermissionsService {
|
||||
/// Returns true if the UI should request the notification permission. If not,
|
||||
/// returns false.
|
||||
/// If the permission should be requested, this method also sets the `XmppState`'s
|
||||
/// `askedNotificationPermission` to true.
|
||||
Future<bool> shouldRequestNotificationPermission() async {
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final retValue = !(await xss.state).askedNotificationPermission;
|
||||
if (retValue) {
|
||||
await xss.modifyXmppState(
|
||||
(state) => state.copyWith(askedNotificationPermission: true),
|
||||
);
|
||||
}
|
||||
|
||||
return retValue;
|
||||
}
|
||||
|
||||
/// Returns true if the UI should request to not be battery-optimised. If not,
|
||||
/// returns false. Also returns false if the app is already ignoring battery optimisations.
|
||||
/// If the excemption should be requested, this method also sets the `XmppState`'s
|
||||
/// `askedBatteryOptimizationExcemption` to true.
|
||||
Future<bool> shouldRequestBatteryOptimisationExcemption() async {
|
||||
if (await MoxplatformPlugin.platform.isIgnoringBatteryOptimizations()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final retValue = !(await xss.state).askedBatteryOptimizationExcemption;
|
||||
if (retValue) {
|
||||
await xss.modifyXmppState(
|
||||
(state) => state.copyWith(askedBatteryOptimizationExcemption: true),
|
||||
);
|
||||
}
|
||||
|
||||
return retValue;
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,14 @@ class ReactionsService {
|
||||
|
||||
/// Query the database for 6 distinct emoji reactions associated with the message id
|
||||
/// [id].
|
||||
Future<List<String>> getPreviewReactionsForMessage(int id) async {
|
||||
Future<List<String>> getPreviewReactionsForMessage(
|
||||
String id,
|
||||
String accountJid,
|
||||
) async {
|
||||
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||
reactionsTable,
|
||||
where: 'message_id = ?',
|
||||
whereArgs: [id],
|
||||
where: 'message_id = ? AND accountJid = ?',
|
||||
whereArgs: [id, accountJid],
|
||||
columns: ['emoji'],
|
||||
distinct: true,
|
||||
limit: 6,
|
||||
@@ -36,135 +39,134 @@ class ReactionsService {
|
||||
return reactions.map((r) => r['emoji']! as String).toList();
|
||||
}
|
||||
|
||||
Future<List<Reaction>> getReactionsForMessage(int id) async {
|
||||
Future<List<Reaction>> getReactionsForMessage(
|
||||
String id,
|
||||
String accountJid,
|
||||
) async {
|
||||
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||
reactionsTable,
|
||||
where: 'message_id = ?',
|
||||
whereArgs: [id],
|
||||
where: 'message_id = ? AND accountJid = ?',
|
||||
whereArgs: [id, accountJid],
|
||||
);
|
||||
|
||||
return reactions.map(Reaction.fromJson).toList();
|
||||
}
|
||||
|
||||
Future<List<String>> getReactionsForMessageByJid(int id, String jid) async {
|
||||
Future<List<String>> getReactionsForMessageByJid(
|
||||
String id,
|
||||
String accountJid,
|
||||
String jid,
|
||||
) async {
|
||||
final reactions = await GetIt.I.get<DatabaseService>().database.query(
|
||||
reactionsTable,
|
||||
where: 'message_id = ? AND senderJid = ?',
|
||||
whereArgs: [id, jid],
|
||||
where: 'message_id = ? AND accountJid = ? AND senderJid = ?',
|
||||
whereArgs: [id, accountJid, jid],
|
||||
);
|
||||
|
||||
return reactions.map((r) => r['emoji']! as String).toList();
|
||||
}
|
||||
|
||||
Future<int> _countReactions(int messageId, String emoji) async {
|
||||
Future<int> _countReactions(
|
||||
String id,
|
||||
String accountJid,
|
||||
String emoji,
|
||||
) async {
|
||||
return GetIt.I.get<DatabaseService>().database.count(
|
||||
reactionsTable,
|
||||
'message_id = ? AND emoji = ?',
|
||||
[messageId, emoji],
|
||||
'message_id = ? AND accountJid = ? AND emoji = ?',
|
||||
[id, accountJid, emoji],
|
||||
);
|
||||
}
|
||||
|
||||
/// Adds a new reaction [emoji], if possible, to [messageId] and returns the
|
||||
/// Adds a new reaction [emoji], if possible, to the message with id [id] and returns the
|
||||
/// new message reaction preview.
|
||||
Future<Message?> addNewReaction(
|
||||
int messageId,
|
||||
String conversationJid,
|
||||
String id,
|
||||
String accountJid,
|
||||
String senderJid,
|
||||
String emoji,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final msg = await ms.getMessageById(messageId, conversationJid);
|
||||
var msg = await ms.getMessageById(id, accountJid);
|
||||
if (msg == null) {
|
||||
_log.warning('Failed to get message $messageId');
|
||||
_log.warning(
|
||||
'Failed to get message ($id, $accountJid)',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!msg.reactionsPreview.contains(emoji) &&
|
||||
msg.reactionsPreview.length < 6) {
|
||||
final newPreview = [
|
||||
...msg.reactionsPreview,
|
||||
emoji,
|
||||
];
|
||||
|
||||
try {
|
||||
final jid = (await GetIt.I.get<XmppStateService>().getXmppState()).jid!;
|
||||
_log.finest('Message reaction preview: ${msg.reactionsPreview}');
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
reactionsTable,
|
||||
Reaction(
|
||||
messageId,
|
||||
jid,
|
||||
id,
|
||||
accountJid,
|
||||
senderJid,
|
||||
emoji,
|
||||
).toJson(),
|
||||
conflictAlgorithm: ConflictAlgorithm.fail,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
|
||||
final newMsg = msg.copyWith(
|
||||
reactionsPreview: newPreview,
|
||||
if (msg.reactionsPreview.length < 6 &&
|
||||
!msg.reactionsPreview.contains(emoji)) {
|
||||
msg = msg.copyWith(
|
||||
reactionsPreview: [
|
||||
...msg.reactionsPreview,
|
||||
emoji,
|
||||
],
|
||||
);
|
||||
await ms.replaceMessageInCache(newMsg);
|
||||
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: newMsg,
|
||||
),
|
||||
);
|
||||
|
||||
return newMsg;
|
||||
} catch (ex) {
|
||||
// The reaction already exists
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
Future<Message?> removeReaction(
|
||||
int messageId,
|
||||
String conversationJid,
|
||||
String id,
|
||||
String accountJid,
|
||||
String senderJid,
|
||||
String emoji,
|
||||
) async {
|
||||
final ms = GetIt.I.get<MessageService>();
|
||||
final msg = await ms.getMessageById(messageId, conversationJid);
|
||||
final msg = await ms.getMessageById(id, accountJid);
|
||||
if (msg == null) {
|
||||
_log.warning('Failed to get message $messageId');
|
||||
_log.warning(
|
||||
'Failed to get message ($id, $accountJid)',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
reactionsTable,
|
||||
where: 'message_id = ? AND emoji = ? AND senderJid = ?',
|
||||
where:
|
||||
'message_id = ? AND accountJid = ? AND emoji = ? AND senderJid = ?',
|
||||
whereArgs: [
|
||||
messageId,
|
||||
id,
|
||||
accountJid,
|
||||
emoji,
|
||||
(await GetIt.I.get<XmppStateService>().getXmppState()).jid,
|
||||
(await xss.state).jid,
|
||||
],
|
||||
);
|
||||
final count = await _countReactions(messageId, emoji);
|
||||
final count = await _countReactions(id, accountJid, emoji);
|
||||
|
||||
if (count > 0) {
|
||||
return msg;
|
||||
}
|
||||
|
||||
final newPreview = List<String>.from(msg.reactionsPreview)..remove(emoji);
|
||||
final newMsg = msg.copyWith(
|
||||
return msg.copyWith(
|
||||
reactionsPreview: newPreview,
|
||||
);
|
||||
await ms.replaceMessageInCache(newMsg);
|
||||
sendEvent(
|
||||
MessageUpdatedEvent(
|
||||
message: newMsg,
|
||||
),
|
||||
);
|
||||
return newMsg;
|
||||
}
|
||||
|
||||
Future<void> processNewReactions(
|
||||
Message msg,
|
||||
String accountJid,
|
||||
String senderJid,
|
||||
List<String> emojis,
|
||||
) async {
|
||||
// Get all reactions know for this message
|
||||
final allReactions = await getReactionsForMessage(msg.id);
|
||||
final allReactions = await getReactionsForMessage(msg.id, accountJid);
|
||||
final userEmojis =
|
||||
allReactions.where((r) => r.senderJid == senderJid).map((r) => r.emoji);
|
||||
final removedReactions = userEmojis.where((e) => !emojis.contains(e));
|
||||
@@ -175,8 +177,9 @@ class ReactionsService {
|
||||
for (final emoji in removedReactions) {
|
||||
final rows = await db.delete(
|
||||
reactionsTable,
|
||||
where: 'message_id = ? AND senderJid = ? AND emoji = ?',
|
||||
whereArgs: [msg.id, senderJid, emoji],
|
||||
where:
|
||||
'message_id = ? AND accountJid = ? AND senderJid = ? AND emoji = ?',
|
||||
whereArgs: [msg.id, accountJid, senderJid, emoji],
|
||||
);
|
||||
assert(rows == 1, 'Only one row should be removed');
|
||||
}
|
||||
@@ -186,6 +189,7 @@ class ReactionsService {
|
||||
reactionsTable,
|
||||
Reaction(
|
||||
msg.id,
|
||||
accountJid,
|
||||
senderJid,
|
||||
emoji,
|
||||
).toJson(),
|
||||
@@ -193,10 +197,10 @@ class ReactionsService {
|
||||
}
|
||||
|
||||
final newMessage = msg.copyWith(
|
||||
reactionsPreview: await getPreviewReactionsForMessage(msg.id),
|
||||
);
|
||||
await GetIt.I.get<MessageService>().replaceMessageInCache(
|
||||
newMessage,
|
||||
reactionsPreview: await getPreviewReactionsForMessage(
|
||||
msg.id,
|
||||
accountJid,
|
||||
),
|
||||
);
|
||||
sendEvent(MessageUpdatedEvent(message: newMessage));
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/not_specified.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
|
||||
@@ -19,20 +18,21 @@ class RosterService {
|
||||
/// Logger.
|
||||
final Logger _log = Logger('RosterService');
|
||||
|
||||
Future<void> _loadRosterIfNeeded() async {
|
||||
Future<void> _loadRosterIfNeeded(String accountJid) async {
|
||||
if (_rosterCache == null) {
|
||||
await loadRosterFromDatabase();
|
||||
await loadRosterFromDatabase(accountJid);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> isInRoster(String jid) async {
|
||||
await _loadRosterIfNeeded();
|
||||
Future<bool> isInRoster(String jid, String accountJid) async {
|
||||
await _loadRosterIfNeeded(accountJid);
|
||||
return _rosterCache!.containsKey(jid);
|
||||
}
|
||||
|
||||
/// Wrapper around [DatabaseService]'s addRosterItemFromData that updates the cache.
|
||||
Future<RosterItem> addRosterItemFromData(
|
||||
String avatarUrl,
|
||||
String accountJid,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@@ -45,9 +45,9 @@ class RosterService {
|
||||
List<String> groups = const [],
|
||||
}) async {
|
||||
// TODO(PapaTutuWawa): Handle groups
|
||||
final i = RosterItem(
|
||||
-1,
|
||||
avatarUrl,
|
||||
final item = RosterItem(
|
||||
accountJid,
|
||||
avatarPath,
|
||||
avatarHash,
|
||||
jid,
|
||||
title,
|
||||
@@ -59,13 +59,10 @@ class RosterService {
|
||||
contactAvatarPath: contactAvatarPath,
|
||||
contactDisplayName: contactDisplayName,
|
||||
);
|
||||
|
||||
final item = i.copyWith(
|
||||
id: await GetIt.I
|
||||
await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.insert(rosterTable, i.toDatabaseJson()),
|
||||
);
|
||||
.insert(rosterTable, item.toDatabaseJson());
|
||||
|
||||
// Update the cache
|
||||
_rosterCache![item.jid] = item;
|
||||
@@ -75,8 +72,9 @@ class RosterService {
|
||||
|
||||
/// Wrapper around [DatabaseService]'s updateRosterItem that updates the cache.
|
||||
Future<RosterItem> updateRosterItem(
|
||||
int id, {
|
||||
String? avatarUrl,
|
||||
String jid,
|
||||
String accountJid, {
|
||||
String? avatarPath,
|
||||
String? avatarHash,
|
||||
String? title,
|
||||
String? subscription,
|
||||
@@ -89,8 +87,8 @@ class RosterService {
|
||||
}) async {
|
||||
final i = <String, dynamic>{};
|
||||
|
||||
if (avatarUrl != null) {
|
||||
i['avatarUrl'] = avatarUrl;
|
||||
if (avatarPath != null) {
|
||||
i['avatarPath'] = avatarPath;
|
||||
}
|
||||
if (avatarHash != null) {
|
||||
i['avatarHash'] = avatarHash;
|
||||
@@ -126,8 +124,8 @@ class RosterService {
|
||||
await GetIt.I.get<DatabaseService>().database.updateAndReturn(
|
||||
rosterTable,
|
||||
i,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
final newItem = RosterItem.fromDatabaseJson(result);
|
||||
|
||||
@@ -138,40 +136,28 @@ class RosterService {
|
||||
}
|
||||
|
||||
/// Removes a roster item from the database and cache
|
||||
Future<void> removeRosterItem(int id) async {
|
||||
Future<void> removeRosterItem(String jid, String accountJid) async {
|
||||
// NOTE: This call ensures that _rosterCache != null
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
rosterTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
where: 'jid = ? AND accountJid = ?',
|
||||
whereArgs: [jid, accountJid],
|
||||
);
|
||||
assert(_rosterCache != null, '_rosterCache must be non-null');
|
||||
|
||||
/// Update cache
|
||||
_rosterCache!.removeWhere((_, value) => value.id == id);
|
||||
}
|
||||
|
||||
/// Removes a roster item from the database based on its JID.
|
||||
Future<void> removeRosterItemByJid(String jid) async {
|
||||
await _loadRosterIfNeeded();
|
||||
|
||||
for (final item in _rosterCache!.values) {
|
||||
if (item.jid == jid) {
|
||||
await removeRosterItem(item.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_rosterCache!.removeWhere((_, value) => value.jid == jid);
|
||||
}
|
||||
|
||||
/// Returns the entire roster
|
||||
Future<List<RosterItem>> getRoster() async {
|
||||
await _loadRosterIfNeeded();
|
||||
Future<List<RosterItem>> getRoster(String accountJid) async {
|
||||
await _loadRosterIfNeeded(accountJid);
|
||||
return _rosterCache!.values.toList();
|
||||
}
|
||||
|
||||
/// Returns the roster item with jid [jid] if it exists. Null otherwise.
|
||||
Future<RosterItem?> getRosterItemByJid(String jid) async {
|
||||
if (await isInRoster(jid)) {
|
||||
Future<RosterItem?> getRosterItemByJid(String jid, String accountJid) async {
|
||||
if (await isInRoster(jid, accountJid)) {
|
||||
return _rosterCache![jid];
|
||||
}
|
||||
|
||||
@@ -180,9 +166,12 @@ class RosterService {
|
||||
|
||||
/// Load the roster from the database. This function is guarded against loading the
|
||||
/// roster multiple times and thus creating too many "RosterDiff" actions.
|
||||
Future<List<RosterItem>> loadRosterFromDatabase() async {
|
||||
final itemsRaw =
|
||||
await GetIt.I.get<DatabaseService>().database.query(rosterTable);
|
||||
Future<List<RosterItem>> loadRosterFromDatabase(String accountJid) async {
|
||||
final itemsRaw = await GetIt.I.get<DatabaseService>().database.query(
|
||||
rosterTable,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [accountJid],
|
||||
);
|
||||
final items = itemsRaw.map(RosterItem.fromDatabaseJson);
|
||||
|
||||
_rosterCache = <String, RosterItem>{};
|
||||
@@ -197,7 +186,8 @@ class RosterService {
|
||||
/// and, if it was successful, create the database entry. Returns the
|
||||
/// [RosterItem] model object.
|
||||
Future<RosterItem> addToRosterWrapper(
|
||||
String avatarUrl,
|
||||
String accountJid,
|
||||
String avatarPath,
|
||||
String avatarHash,
|
||||
String jid,
|
||||
String title,
|
||||
@@ -205,7 +195,8 @@ class RosterService {
|
||||
final css = GetIt.I.get<ContactsService>();
|
||||
final contactId = await css.getContactIdForJid(jid);
|
||||
final item = await addRosterItemFromData(
|
||||
avatarUrl,
|
||||
accountJid,
|
||||
avatarPath,
|
||||
avatarHash,
|
||||
jid,
|
||||
title,
|
||||
@@ -217,14 +208,19 @@ class RosterService {
|
||||
await css.getContactDisplayName(contactId),
|
||||
);
|
||||
|
||||
final result = await GetIt.I
|
||||
.get<XmppConnection>()
|
||||
.getRosterManager()!
|
||||
.addToRoster(jid, title);
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final result = await conn.getRosterManager()!.addToRoster(jid, title);
|
||||
if (!result) {
|
||||
// TODO(Unknown): Signal error?
|
||||
}
|
||||
|
||||
final to = JID.fromString(jid);
|
||||
final preApproval =
|
||||
await conn.getPresenceManager()!.preApproveSubscription(to);
|
||||
if (!preApproval) {
|
||||
await conn.getPresenceManager()!.requestSubscription(to);
|
||||
}
|
||||
|
||||
sendEvent(RosterDiffEvent(added: [item]));
|
||||
return item;
|
||||
}
|
||||
@@ -233,24 +229,46 @@ class RosterService {
|
||||
/// successful, from the database. If [unsubscribe] is true, then [jid] won't receive
|
||||
/// our presence anymore.
|
||||
Future<bool> removeFromRosterWrapper(
|
||||
String jid, {
|
||||
String jid,
|
||||
String accountJid, {
|
||||
bool unsubscribe = true,
|
||||
}) async {
|
||||
final roster = GetIt.I.get<XmppConnection>().getRosterManager()!;
|
||||
final conn = GetIt.I.get<XmppConnection>();
|
||||
final roster = conn.getRosterManager()!;
|
||||
final pm = conn.getManagerById<PresenceManager>(presenceManager)!;
|
||||
final result = await roster.removeFromRoster(jid);
|
||||
if (result == RosterRemovalResult.okay ||
|
||||
result == RosterRemovalResult.itemNotFound) {
|
||||
if (unsubscribe) {
|
||||
GetIt.I
|
||||
.get<SubscriptionRequestService>()
|
||||
.sendUnsubscriptionRequest(jid);
|
||||
await pm.unsubscribe(JID.fromString(jid));
|
||||
}
|
||||
|
||||
_log.finest('Removing from roster maybe worked. Removing from database');
|
||||
await removeRosterItemByJid(jid);
|
||||
await removeRosterItem(jid, accountJid);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Removes all roster items that are pseudo roster items.
|
||||
Future<void> removePseudoRosterItems(String accountJid) async {
|
||||
final items = await getRoster(accountJid);
|
||||
final removed = List<String>.empty(growable: true);
|
||||
for (final item in items) {
|
||||
if (!item.pseudoRosterItem) continue;
|
||||
|
||||
assert(
|
||||
item.contactId != null,
|
||||
'Only pseudo roster items that are for the contact integration should ge removed',
|
||||
);
|
||||
|
||||
removed.add(item.jid);
|
||||
await removeRosterItem(item.jid, accountJid);
|
||||
}
|
||||
|
||||
sendEvent(
|
||||
RosterDiffEvent(removed: removed),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxplatform_platform_interface/moxplatform_platform_interface.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/avatars.dart';
|
||||
@@ -17,23 +17,28 @@ import 'package:moxxyv2/service/contacts.dart';
|
||||
import 'package:moxxyv2/service/conversation.dart';
|
||||
import 'package:moxxyv2/service/cryptography/cryptography.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/database/migration.dart';
|
||||
import 'package:moxxyv2/service/events.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
import 'package:moxxyv2/service/groupchat.dart';
|
||||
import 'package:moxxyv2/service/httpfiletransfer/httpfiletransfer.dart';
|
||||
import 'package:moxxyv2/service/language.dart';
|
||||
import 'package:moxxyv2/service/lifecycle.dart';
|
||||
import 'package:moxxyv2/service/message.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/connectivity.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/omemo.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/roster.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/socket.dart';
|
||||
import 'package:moxxyv2/service/moxxmpp/stream.dart';
|
||||
import 'package:moxxyv2/service/non_database_migrations/0000_notification_channels.dart';
|
||||
import 'package:moxxyv2/service/notifications.dart';
|
||||
import 'package:moxxyv2/service/omemo/omemo.dart';
|
||||
import 'package:moxxyv2/service/permissions.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/reactions.dart';
|
||||
import 'package:moxxyv2/service/roster.dart';
|
||||
import 'package:moxxyv2/service/share.dart';
|
||||
import 'package:moxxyv2/service/stickers.dart';
|
||||
import 'package:moxxyv2/service/subscription.dart';
|
||||
import 'package:moxxyv2/service/storage.dart';
|
||||
import 'package:moxxyv2/service/xmpp.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/commands.dart';
|
||||
@@ -69,10 +74,30 @@ Future<void> initializeServiceIfNeeded() async {
|
||||
);
|
||||
} else {
|
||||
logger.info('Service is not running. Initializing service... ');
|
||||
|
||||
// Run non-db migrations
|
||||
const storage = FlutterSecureStorage();
|
||||
const versionKey = 'non_database_migrations_version';
|
||||
final currentVersion = int.parse(
|
||||
await storage.read(key: versionKey) ?? '0',
|
||||
);
|
||||
await runMigrations(
|
||||
logger,
|
||||
42,
|
||||
const [
|
||||
Migration(2, upgradeV1ToV2NonDb),
|
||||
],
|
||||
currentVersion,
|
||||
'non-database',
|
||||
commitVersion: (version) async =>
|
||||
storage.write(key: versionKey, value: version.toString()),
|
||||
);
|
||||
|
||||
await handler.start(
|
||||
entrypoint,
|
||||
receiveUIEvent,
|
||||
ui_events.handleIsolateEvent,
|
||||
WidgetsBinding.instance.platformDispatcher.locale.toLanguageTag(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -144,7 +169,7 @@ Future<void> initUDPLogger() async {
|
||||
|
||||
/// The entrypoint for all platforms after the platform specific initilization is done.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> entrypoint() async {
|
||||
Future<void> entrypoint(String initialLocale) async {
|
||||
setupLogging();
|
||||
setupBackgroundEventHandler();
|
||||
|
||||
@@ -154,9 +179,13 @@ Future<void> entrypoint() async {
|
||||
GetIt.I.registerSingleton<LanguageService>(LanguageService());
|
||||
|
||||
// Initialize the database
|
||||
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
|
||||
GetIt.I.registerSingleton<DatabaseService>(DatabaseService());
|
||||
await GetIt.I.get<DatabaseService>().initialize();
|
||||
|
||||
// Initialize the account state
|
||||
await GetIt.I.get<XmppStateService>().initializeXmppState();
|
||||
|
||||
// Initialize services
|
||||
GetIt.I.registerSingleton<ConnectivityWatcherService>(
|
||||
ConnectivityWatcherService(),
|
||||
@@ -174,15 +203,33 @@ Future<void> entrypoint() async {
|
||||
GetIt.I.registerSingleton<CryptographyService>(CryptographyService());
|
||||
GetIt.I.registerSingleton<ContactsService>(ContactsService());
|
||||
GetIt.I.registerSingleton<StickersService>(StickersService());
|
||||
GetIt.I.registerSingleton<XmppStateService>(XmppStateService());
|
||||
GetIt.I.registerSingleton<SubscriptionRequestService>(
|
||||
SubscriptionRequestService(),
|
||||
);
|
||||
GetIt.I.registerSingleton<FilesService>(FilesService());
|
||||
GetIt.I.registerSingleton<ReactionsService>(ReactionsService());
|
||||
GetIt.I.registerSingleton<GroupchatService>(GroupchatService());
|
||||
GetIt.I.registerSingleton<StorageService>(StorageService());
|
||||
GetIt.I.registerSingleton<ShareService>(ShareService());
|
||||
GetIt.I.registerSingleton<PermissionsService>(PermissionsService());
|
||||
GetIt.I.registerSingleton<LifecycleService>(LifecycleService());
|
||||
final xmpp = XmppService();
|
||||
GetIt.I.registerSingleton<XmppService>(xmpp);
|
||||
|
||||
// Set the locale before we initialize the notigications service to ensure
|
||||
// the correct locale is used for the notification channels.
|
||||
final preferredLocale =
|
||||
(await GetIt.I.get<PreferencesService>().getPreferences())
|
||||
.languageLocaleCode;
|
||||
if (preferredLocale == 'default') {
|
||||
LocaleSettings.setLocaleRaw(initialLocale);
|
||||
GetIt.I.get<Logger>().finest(
|
||||
'Setting locale to system locale ($initialLocale) per preferences',
|
||||
);
|
||||
} else {
|
||||
LocaleSettings.setLocaleRaw(preferredLocale);
|
||||
GetIt.I.get<Logger>().finest(
|
||||
'Setting locale to configured locale ($preferredLocale) per preferences',
|
||||
);
|
||||
}
|
||||
|
||||
await GetIt.I.get<NotificationsService>().initialize();
|
||||
await GetIt.I.get<ContactsService>().initialize();
|
||||
await GetIt.I.get<ConnectivityService>().initialize();
|
||||
@@ -211,10 +258,14 @@ Future<void> entrypoint() async {
|
||||
StreamManagementNegotiator(),
|
||||
CSINegotiator(),
|
||||
RosterFeatureNegotiator(),
|
||||
PresenceNegotiator(),
|
||||
SaslScramNegotiator(10, '', '', ScramHashType.sha512),
|
||||
SaslScramNegotiator(9, '', '', ScramHashType.sha256),
|
||||
SaslScramNegotiator(8, '', '', ScramHashType.sha1),
|
||||
SaslPlainNegotiator(),
|
||||
Sasl2Negotiator(),
|
||||
Bind2Negotiator(),
|
||||
FASTSaslNegotiator(),
|
||||
]);
|
||||
await connection.registerManagers([
|
||||
MoxxyStreamManagementManager(),
|
||||
@@ -222,7 +273,14 @@ Future<void> entrypoint() async {
|
||||
const Identity(category: 'client', type: 'phone', name: 'Moxxy'),
|
||||
]),
|
||||
RosterManager(MoxxyRosterStateManager()),
|
||||
MoxxyOmemoManager(),
|
||||
OmemoManager(
|
||||
GetIt.I.get<OmemoService>().getOmemoManager,
|
||||
(toJid, _) async =>
|
||||
GetIt.I.get<ConversationService>().shouldEncryptForConversation(
|
||||
toJid,
|
||||
(await GetIt.I.get<XmppStateService>().getAccountJid())!,
|
||||
),
|
||||
),
|
||||
PingManager(const Duration(minutes: 3)),
|
||||
MessageManager(),
|
||||
PresenceManager(),
|
||||
@@ -230,7 +288,6 @@ Future<void> entrypoint() async {
|
||||
CSIManager(),
|
||||
CarbonsManager(),
|
||||
PubSubManager(),
|
||||
VCardManager(),
|
||||
UserAvatarManager(),
|
||||
StableIdManager(),
|
||||
MessageDeliveryReceiptManager(),
|
||||
@@ -249,13 +306,24 @@ Future<void> entrypoint() async {
|
||||
LastMessageCorrectionManager(),
|
||||
MessageReactionsManager(),
|
||||
StickersManager(),
|
||||
MessageProcessingHintManager(),
|
||||
MUCManager(),
|
||||
OccupantIdManager(),
|
||||
]);
|
||||
|
||||
GetIt.I.registerSingleton<XmppConnection>(connection);
|
||||
|
||||
GetIt.I.get<Logger>().finest('Done with xmpp');
|
||||
|
||||
final settings = await xmpp.getConnectionSettings();
|
||||
// Ensure our data directory exists
|
||||
final dir = Directory(
|
||||
await MoxplatformPlugin.platform.getPersistentDataPath(),
|
||||
);
|
||||
if (!dir.existsSync()) {
|
||||
GetIt.I
|
||||
.get<Logger>()
|
||||
.finest('Data dir ${dir.path} does not exist. Creating...');
|
||||
await dir.create(recursive: true);
|
||||
GetIt.I.get<Logger>().finest('Done');
|
||||
}
|
||||
|
||||
// Ensure we can access translations here
|
||||
// TODO(Unknown): This does *NOT* allow us to get the system's locale as we have no
|
||||
@@ -263,6 +331,7 @@ Future<void> entrypoint() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
LocaleSettings.useDeviceLocale();
|
||||
|
||||
final settings = await xmpp.getConnectionSettings();
|
||||
GetIt.I.get<Logger>().finest('Got settings');
|
||||
if (settings != null) {
|
||||
unawaited(
|
||||
@@ -271,6 +340,9 @@ Future<void> entrypoint() async {
|
||||
.initializeIfNeeded(settings.jid.toBare().toString()),
|
||||
);
|
||||
|
||||
// Potentially set the notification avatar
|
||||
await GetIt.I.get<NotificationsService>().maybeSetAvatarFromState();
|
||||
|
||||
// The title of the notification will be changed as soon as the connection state
|
||||
// of [XmppConnection] changes.
|
||||
await connection
|
||||
|
||||
55
lib/service/share.dart
Normal file
55
lib/service/share.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
|
||||
/// The service responsible for handling the direct share feature.
|
||||
class ShareService {
|
||||
/// Logging.
|
||||
final Logger _log = Logger('ShareService');
|
||||
|
||||
/// Updates the share shortcuts for [conversation]. If a message was received or
|
||||
/// sent in [conversation], this method should be called.
|
||||
Future<void> recordSentMessage(
|
||||
Conversation conversation,
|
||||
) async {
|
||||
assert(
|
||||
implies(!conversation.isSelfChat, conversation.jid.isNotEmpty),
|
||||
'Only self-chats can have an empty JID',
|
||||
);
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
|
||||
// Use the correct title if we share to the note-to-self chat.
|
||||
final conversationName = conversation.isSelfChat
|
||||
? t.pages.conversations.speeddialAddNoteToSelf
|
||||
: conversation.getTitleWithOptionalContact(
|
||||
prefs.enableContactIntegration,
|
||||
);
|
||||
final conversationImageFilePath =
|
||||
conversation.getAvatarPathWithOptionalContact(
|
||||
prefs.enableContactIntegration,
|
||||
);
|
||||
// Prevent empty JIDs as that messes with share_handler
|
||||
final conversationJid =
|
||||
conversation.isSelfChat ? selfChatShareFakeJid : conversation.jid;
|
||||
|
||||
_log.finest(
|
||||
'Creating direct share target "$conversationName" (jid=$conversationJid, avatarPath=$conversationImageFilePath)',
|
||||
);
|
||||
|
||||
// Tell the system to create a direct share shortcut
|
||||
await MoxplatformPlugin.contacts.recordSentMessage(
|
||||
conversationName,
|
||||
conversationJid,
|
||||
avatarPath:
|
||||
conversationImageFilePath.isEmpty ? null : conversationImageFilePath,
|
||||
fallbackIcon: conversation.isSelfChat
|
||||
? FallbackIconType.notes
|
||||
: FallbackIconType.person,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'dart:ui';
|
||||
import 'package:archive/archive.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart' as moxxmpp;
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
@@ -16,20 +17,48 @@ import 'package:moxxyv2/service/httpfiletransfer/location.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/service/service.dart';
|
||||
import 'package:moxxyv2/service/xmpp_state.dart';
|
||||
import 'package:moxxyv2/shared/constants.dart';
|
||||
import 'package:moxxyv2/shared/events.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
import 'package:moxxyv2/shared/models/file_metadata.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class StickersService {
|
||||
final Map<String, StickerPack> _stickerPacks = {};
|
||||
final Logger _log = Logger('StickersService');
|
||||
|
||||
Future<StickerPack?> getStickerPackById(String id) async {
|
||||
if (_stickerPacks.containsKey(id)) return _stickerPacks[id];
|
||||
/// Computes the total amount of storage occupied by the stickers in the sticker
|
||||
/// pack identified by id [id].
|
||||
/// NOTE that if a sticker does not indicate a file size, i.e. the "size" column is
|
||||
/// NULL, then a size of 0 is assumed.
|
||||
Future<int> getStickerPackSizeById(String id) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final result = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
SUM(size) AS size
|
||||
FROM
|
||||
$fileMetadataTable as fmt
|
||||
WHERE
|
||||
path IS NOT NULL AND
|
||||
EXISTS (
|
||||
SELECT
|
||||
id
|
||||
FROM
|
||||
$stickersTable
|
||||
WHERE
|
||||
file_metadata_id = fmt.id AND
|
||||
stickerPackId = ?
|
||||
)
|
||||
''',
|
||||
[id],
|
||||
);
|
||||
|
||||
_log.finest('Cumulative size for $id: $result');
|
||||
return result.first['size'] as int? ?? 0;
|
||||
}
|
||||
|
||||
Future<StickerPack?> getStickerPackById(String id) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final rawPack = await db.query(
|
||||
stickerPacksTable,
|
||||
@@ -59,13 +88,23 @@ SELECT
|
||||
fm.cipherTextHashes AS fm_cipherTextHashes,
|
||||
fm.filename AS fm_filename,
|
||||
fm.size AS fm_size
|
||||
FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
JOIN $fileMetadataTable fm ON sticker.file_metadata_id = fm.id;
|
||||
FROM
|
||||
(SELECT
|
||||
*
|
||||
FROM
|
||||
$stickersTable
|
||||
WHERE
|
||||
stickerPackId = ?
|
||||
) AS sticker
|
||||
JOIN
|
||||
$fileMetadataTable fm
|
||||
ON
|
||||
sticker.file_metadata_id = fm.id;
|
||||
''',
|
||||
[id],
|
||||
);
|
||||
|
||||
_stickerPacks[id] = StickerPack.fromDatabaseJson(
|
||||
final stickerPack = StickerPack.fromDatabaseJson(
|
||||
rawPack.first,
|
||||
rawStickers.map((sticker) {
|
||||
return Sticker.fromDatabaseJson(
|
||||
@@ -75,28 +114,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
).copyWith(
|
||||
size: await getStickerPackSizeById(id),
|
||||
);
|
||||
|
||||
return _stickerPacks[id]!;
|
||||
}
|
||||
|
||||
Future<List<StickerPack>> getStickerPacks() async {
|
||||
if (_stickerPacks.isEmpty) {
|
||||
final rawPackIds = await GetIt.I.get<DatabaseService>().database.query(
|
||||
stickerPacksTable,
|
||||
columns: ['id'],
|
||||
);
|
||||
for (final rawPack in rawPackIds) {
|
||||
final id = rawPack['id']! as String;
|
||||
await getStickerPackById(id);
|
||||
}
|
||||
}
|
||||
|
||||
_log.finest('Got ${_stickerPacks.length} sticker packs');
|
||||
return _stickerPacks.values.toList();
|
||||
return stickerPack;
|
||||
}
|
||||
|
||||
Future<void> removeStickerPack(String id) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final pack = await getStickerPackById(id);
|
||||
assert(pack != null, 'The sticker pack must exist');
|
||||
|
||||
@@ -117,17 +143,20 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
}
|
||||
|
||||
// Remove from the database
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
await db.delete(
|
||||
stickersTable,
|
||||
where: 'stickerPackId = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
await db.delete(
|
||||
stickerPacksTable,
|
||||
where: 'id = ?',
|
||||
whereArgs: [id],
|
||||
);
|
||||
|
||||
// Remove from the cache
|
||||
_stickerPacks.remove(id);
|
||||
|
||||
// Retract from PubSub
|
||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.state;
|
||||
final result = await GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
|
||||
@@ -140,7 +169,8 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
|
||||
Future<void> _publishStickerPack(moxxmpp.StickerPack pack) async {
|
||||
final prefs = await GetIt.I.get<PreferencesService>().getPreferences();
|
||||
final state = await GetIt.I.get<XmppStateService>().getXmppState();
|
||||
final xss = GetIt.I.get<XmppStateService>();
|
||||
final state = await xss.state;
|
||||
final result = await GetIt.I
|
||||
.get<moxxmpp.XmppConnection>()
|
||||
.getManagerById<moxxmpp.StickersManager>(moxxmpp.stickersManager)!
|
||||
@@ -238,8 +268,8 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
);
|
||||
|
||||
// Get file metadata
|
||||
final fileMetadataRaw =
|
||||
await GetIt.I.get<FilesService>().createFileMetadataIfRequired(
|
||||
final fs = GetIt.I.get<FilesService>();
|
||||
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
sticker.fileMetadata.sourceUrls!,
|
||||
p.basename(stickerPath),
|
||||
@@ -265,7 +295,8 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
path: stickerPath,
|
||||
);
|
||||
|
||||
if (!fileMetadataRaw.retrieved) {
|
||||
if (!fileMetadataRaw.retrieved &&
|
||||
fileMetadataRaw.fileMetadata.path == null) {
|
||||
final downloadStatusCode = await downloadFile(
|
||||
Uri.parse(sticker.fileMetadata.sourceUrls!.first),
|
||||
stickerPath,
|
||||
@@ -279,13 +310,22 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
}
|
||||
}
|
||||
|
||||
var fm = fileMetadataRaw.fileMetadata;
|
||||
if (fileMetadataRaw.fileMetadata.size == null) {
|
||||
// Determine the file size of the sticker.
|
||||
fm = await fs.updateFileMetadata(
|
||||
fileMetadataRaw.fileMetadata.id,
|
||||
size: File(stickerPath).lengthSync(),
|
||||
);
|
||||
}
|
||||
|
||||
stickers[i] = await _addStickerFromData(
|
||||
getStrongestHashFromMap(sticker.fileMetadata.plaintextHashes) ??
|
||||
DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
remotePack.hashValue,
|
||||
sticker.desc,
|
||||
sticker.suggests,
|
||||
fileMetadataRaw.fileMetadata,
|
||||
fm,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -370,9 +410,10 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
return null;
|
||||
}
|
||||
|
||||
final stickerDirPath = await getStickerPackPath(
|
||||
pack.hashAlgorithm.toName(),
|
||||
pack.hashValue,
|
||||
final stickerDirPath = p.join(
|
||||
await MoxplatformPlugin.platform.getPersistentDataPath(),
|
||||
'stickers',
|
||||
'${pack.hashAlgorithm.toName()}_${pack.hashValue}',
|
||||
);
|
||||
final stickerDir = Directory(stickerDirPath);
|
||||
if (!stickerDir.existsSync()) await stickerDir.create(recursive: true);
|
||||
@@ -387,11 +428,15 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
pack.hashValue,
|
||||
pack.restricted,
|
||||
true,
|
||||
DateTime.now().millisecondsSinceEpoch,
|
||||
0,
|
||||
);
|
||||
await _addStickerPackFromData(stickerPack);
|
||||
|
||||
// Add all stickers
|
||||
var size = 0;
|
||||
final stickers = List<Sticker>.empty(growable: true);
|
||||
final fs = GetIt.I.get<FilesService>();
|
||||
for (final sticker in pack.stickers) {
|
||||
// Get the "path" to the sticker
|
||||
final stickerPath = await computeCachedPathForFile(
|
||||
@@ -404,9 +449,7 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
.whereType<moxxmpp.StatelessFileSharingUrlSource>()
|
||||
.map((src) => src.url)
|
||||
.toList();
|
||||
final fileMetadataRaw = await GetIt.I
|
||||
.get<FilesService>()
|
||||
.createFileMetadataIfRequired(
|
||||
final fileMetadataRaw = await fs.createFileMetadataIfRequired(
|
||||
MediaFileLocation(
|
||||
urlSources,
|
||||
p.basename(stickerPath),
|
||||
@@ -432,11 +475,43 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
);
|
||||
|
||||
// Only copy the sticker to storage if we don't already have it
|
||||
if (!fileMetadataRaw.retrieved) {
|
||||
var fm = fileMetadataRaw.fileMetadata;
|
||||
if (!fileMetadataRaw.retrieved ||
|
||||
fileMetadataRaw.fileMetadata.path == null) {
|
||||
_log.finest(
|
||||
'Copying sticker ${sticker.metadata.name!} to media storage',
|
||||
);
|
||||
final stickerFile = archive.findFile(sticker.metadata.name!)!;
|
||||
await File(stickerPath).writeAsBytes(
|
||||
final file = File(stickerPath);
|
||||
await file.writeAsBytes(
|
||||
stickerFile.content as List<int>,
|
||||
);
|
||||
|
||||
// Update the File Metadata entry
|
||||
fm = await fs.updateFileMetadata(
|
||||
fm.id,
|
||||
size: file.lengthSync(),
|
||||
path: stickerPath,
|
||||
);
|
||||
size += file.lengthSync();
|
||||
} else {
|
||||
_log.finest(
|
||||
'Not copying sticker ${sticker.metadata.name!} as we already have it',
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the sticker has size
|
||||
if (fm.size == null) {
|
||||
_log.finest(
|
||||
'Sticker ${sticker.metadata.name!} has no size. Calculating it',
|
||||
);
|
||||
|
||||
// Update the File Metadata entry
|
||||
fm = await fs.updateFileMetadata(
|
||||
fm.id,
|
||||
size: File(stickerPath).lengthSync(),
|
||||
);
|
||||
size += fm.size!;
|
||||
}
|
||||
|
||||
stickers.add(
|
||||
@@ -446,18 +521,16 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
pack.hashValue,
|
||||
sticker.metadata.desc!,
|
||||
sticker.suggests,
|
||||
fileMetadataRaw.fileMetadata,
|
||||
fm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final stickerPackWithStickers = stickerPack.copyWith(
|
||||
stickers: stickers,
|
||||
size: size,
|
||||
);
|
||||
|
||||
// Add it to the cache
|
||||
_stickerPacks[pack.hashValue] = stickerPackWithStickers;
|
||||
|
||||
_log.info(
|
||||
'Sticker pack ${stickerPack.id} successfully added to the database',
|
||||
);
|
||||
@@ -466,4 +539,110 @@ FROM (SELECT * FROM $stickersTable WHERE stickerPackId = ?) AS sticker
|
||||
unawaited(_publishStickerPack(pack));
|
||||
return stickerPackWithStickers;
|
||||
}
|
||||
|
||||
/// Returns a paginated list of sticker packs.
|
||||
/// [includeStickers] controls whether the stickers for a given sticker pack are
|
||||
/// fetched from the database. Setting this to false, i.e. not loading the stickers,
|
||||
/// can be useful, for example, when we're only interested in listing the sticker
|
||||
/// packs without the stickers being visible.
|
||||
Future<List<StickerPack>> getPaginatedStickerPacks(
|
||||
bool olderThan,
|
||||
int? timestamp,
|
||||
bool includeStickers,
|
||||
) async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final comparator = olderThan ? '<' : '>';
|
||||
final query = timestamp != null ? 'addedTimestamp $comparator ?' : null;
|
||||
|
||||
final stickerPacksRaw = await db.query(
|
||||
stickerPacksTable,
|
||||
where: query,
|
||||
orderBy: 'addedTimestamp DESC',
|
||||
limit: stickerPackPaginationSize,
|
||||
);
|
||||
|
||||
final stickerPacks = List<StickerPack>.empty(growable: true);
|
||||
for (final pack in stickerPacksRaw) {
|
||||
// Query the stickers
|
||||
List<Map<String, Object?>> stickersRaw;
|
||||
if (includeStickers) {
|
||||
stickersRaw = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
st.*,
|
||||
fm.id AS fm_id,
|
||||
fm.path AS fm_path,
|
||||
fm.sourceUrls AS fm_sourceUrls,
|
||||
fm.mimeType AS fm_mimeType,
|
||||
fm.thumbnailType AS fm_thumbnailType,
|
||||
fm.thumbnailData AS fm_thumbnailData,
|
||||
fm.width AS fm_width,
|
||||
fm.height AS fm_height,
|
||||
fm.plaintextHashes AS fm_plaintextHashes,
|
||||
fm.encryptionKey AS fm_encryptionKey,
|
||||
fm.encryptionIv AS fm_encryptionIv,
|
||||
fm.encryptionScheme AS fm_encryptionScheme,
|
||||
fm.cipherTextHashes AS fm_cipherTextHashes,
|
||||
fm.filename AS fm_filename,
|
||||
fm.size AS fm_size
|
||||
FROM
|
||||
$stickersTable AS st,
|
||||
$fileMetadataTable AS fm
|
||||
WHERE
|
||||
st.stickerPackId = ? AND
|
||||
st.file_metadata_id = fm.id
|
||||
''',
|
||||
[
|
||||
pack['id']! as String,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
stickersRaw = List<Map<String, Object?>>.empty();
|
||||
}
|
||||
|
||||
final stickerPack = StickerPack.fromDatabaseJson(
|
||||
pack,
|
||||
stickersRaw.map((sticker) {
|
||||
return Sticker.fromDatabaseJson(
|
||||
sticker,
|
||||
FileMetadata.fromDatabaseJson(
|
||||
getPrefixedSubMap(sticker, 'fm_'),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
/// If stickers were not requested, we still have to get the size of the
|
||||
/// sticker pack anyway.
|
||||
int size;
|
||||
if (includeStickers && stickerPack.stickers.isNotEmpty) {
|
||||
size = stickerPack.stickers
|
||||
.map((sticker) => sticker.fileMetadata.size ?? 0)
|
||||
.reduce((value, element) => value + element);
|
||||
} else {
|
||||
final sizeResult = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
SUM(fm.size) as size
|
||||
FROM
|
||||
$fileMetadataTable as fm,
|
||||
$stickersTable as st
|
||||
WHERE
|
||||
st.stickerPackId = ? AND
|
||||
st.file_metadata_id = fm.id
|
||||
''',
|
||||
[pack['id']! as String],
|
||||
);
|
||||
size = sizeResult.first['size'] as int? ?? 0;
|
||||
}
|
||||
|
||||
stickerPacks.add(
|
||||
stickerPack.copyWith(
|
||||
size: size,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return stickerPacks;
|
||||
}
|
||||
}
|
||||
|
||||
94
lib/service/storage.dart
Normal file
94
lib/service/storage.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'dart:io';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:moxxyv2/service/files.dart';
|
||||
|
||||
/// Service responsible for handling storage related queries, like how much storage
|
||||
/// are we currently using.
|
||||
class StorageService {
|
||||
/// Logger.
|
||||
final Logger _log = Logger('StorageService');
|
||||
|
||||
/// Compute the amount of storage all FileMetadata objects take, that both have
|
||||
/// their file size and path set to something other than null.
|
||||
/// Note that this usage does not include file metadata items that are stickers.
|
||||
Future<int> computeUsedMediaStorage() async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final result = await db.rawQuery(
|
||||
'''
|
||||
SELECT SUM(size) AS size FROM $fileMetadataTable AS fmt
|
||||
WHERE path IS NOT NULL
|
||||
AND size IS NOT NULL
|
||||
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
|
||||
''',
|
||||
);
|
||||
|
||||
_log.finest('computeUsedMediaStorage: SQL:: $result');
|
||||
return result.first['size'] as int? ?? 0;
|
||||
}
|
||||
|
||||
Future<int> computeUsedStickerStorage() async {
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final result = await db.rawQuery(
|
||||
'''
|
||||
SELECT SUM(size) AS size FROM $fileMetadataTable as fmt
|
||||
WHERE path IS NOT NULL
|
||||
AND size IS NOT NULL
|
||||
AND EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
|
||||
''',
|
||||
);
|
||||
|
||||
_log.finest('computeUsedStickerStorage: SQL:: $result');
|
||||
return result.first['size'] as int? ?? 0;
|
||||
}
|
||||
|
||||
/// Deletes shared media files for which the age of the newest attached message
|
||||
/// is at least [timeOffsetMilliseconds] milliseconds in the past from the moment
|
||||
/// of calling.
|
||||
Future<void> deleteOldMediaFiles(int timeOffsetMilliseconds) async {
|
||||
// The timestamp of the newest message referencing this
|
||||
final maxAge =
|
||||
DateTime.now().millisecondsSinceEpoch - timeOffsetMilliseconds;
|
||||
// The database
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
|
||||
// The query is pretty complicated because:
|
||||
// - We deduplicate media files, meaning that there may be > 1 messages that use a given
|
||||
// file metadata entry. To prevent deleting too many files, we have to find the newest
|
||||
// message that references the file metadata item and check if that message's timestamp
|
||||
// puts it in deletion range.
|
||||
// - We don't want to delete files that belong to a sticker pack because the storage of those
|
||||
// is managed differently.
|
||||
// - In case we have file metadata items that are dangling, we also remove those.
|
||||
// TODO(Unknown): It might be nice to merge the two subqueries
|
||||
final results = await db.rawQuery(
|
||||
'''
|
||||
SELECT
|
||||
path,
|
||||
id
|
||||
FROM
|
||||
$fileMetadataTable AS fmt
|
||||
WHERE (
|
||||
(SELECT MAX(timestamp) FROM $messagesTable WHERE file_metadata_id = fmt.id) <= $maxAge
|
||||
OR NOT EXISTS (SELECT id FROM $messagesTable WHERE file_metadata_id = fmt.id)
|
||||
)
|
||||
AND NOT EXISTS (SELECT id from $stickersTable WHERE file_metadata_id = fmt.id)
|
||||
AND path IS NOT NULL
|
||||
''',
|
||||
);
|
||||
_log.finest('Found ${results.length} matching files for deletion');
|
||||
|
||||
for (final result in results) {
|
||||
// Update the entry
|
||||
await GetIt.I.get<FilesService>().updateFileMetadata(
|
||||
result['id']! as String,
|
||||
path: null,
|
||||
);
|
||||
|
||||
final file = File(result['path']! as String);
|
||||
if (file.existsSync()) await file.delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/constants.dart';
|
||||
import 'package:moxxyv2/service/database/database.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
|
||||
class SubscriptionRequestService {
|
||||
List<String>? _subscriptionRequests;
|
||||
|
||||
final Lock _lock = Lock();
|
||||
|
||||
/// Only load data from the database into
|
||||
/// [SubscriptionRequestService._subscriptionRequests] when the cache has not yet
|
||||
/// been loaded.
|
||||
Future<void> _loadSubscriptionRequestsIfNeeded() async {
|
||||
await _lock.synchronized(() async {
|
||||
_subscriptionRequests ??= List<String>.from(
|
||||
(await GetIt.I
|
||||
.get<DatabaseService>()
|
||||
.database
|
||||
.query(subscriptionsTable))
|
||||
.map((m) => m['jid']! as String)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<String>> getSubscriptionRequests() async {
|
||||
await _loadSubscriptionRequestsIfNeeded();
|
||||
return _subscriptionRequests!;
|
||||
}
|
||||
|
||||
Future<void> addSubscriptionRequest(String jid) async {
|
||||
await _loadSubscriptionRequestsIfNeeded();
|
||||
|
||||
await _lock.synchronized(() async {
|
||||
if (!_subscriptionRequests!.contains(jid)) {
|
||||
_subscriptionRequests!.add(jid);
|
||||
|
||||
await GetIt.I.get<DatabaseService>().database.insert(
|
||||
subscriptionsTable,
|
||||
{
|
||||
'jid': jid,
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> removeSubscriptionRequest(String jid) async {
|
||||
await _loadSubscriptionRequestsIfNeeded();
|
||||
|
||||
await _lock.synchronized(() async {
|
||||
if (_subscriptionRequests!.contains(jid)) {
|
||||
_subscriptionRequests!.remove(jid);
|
||||
await GetIt.I.get<DatabaseService>().database.delete(
|
||||
subscriptionsTable,
|
||||
where: 'jid = ?',
|
||||
whereArgs: [jid],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> hasPendingSubscriptionRequest(String jid) async {
|
||||
return (await getSubscriptionRequests()).contains(jid);
|
||||
}
|
||||
|
||||
PresenceManager get _presence =>
|
||||
GetIt.I.get<XmppConnection>().getPresenceManager()!;
|
||||
|
||||
/// Accept a subscription request from [jid].
|
||||
Future<void> acceptSubscriptionRequest(String jid) async {
|
||||
_presence.sendSubscriptionRequestApproval(jid);
|
||||
await removeSubscriptionRequest(jid);
|
||||
}
|
||||
|
||||
/// Reject a subscription request from [jid].
|
||||
Future<void> rejectSubscriptionRequest(String jid) async {
|
||||
_presence.sendSubscriptionRequestRejection(jid);
|
||||
await removeSubscriptionRequest(jid);
|
||||
}
|
||||
|
||||
/// Send a subscription request to [jid].
|
||||
void sendSubscriptionRequest(String jid) {
|
||||
_presence.sendSubscriptionRequest(jid);
|
||||
}
|
||||
|
||||
/// Remove a presence subscription with [jid].
|
||||
void sendUnsubscriptionRequest(String jid) {
|
||||
_presence.sendUnsubscriptionRequest(jid);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,242 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
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/xmpp.dart';
|
||||
import 'package:moxxyv2/shared/models/xmpp_state.dart';
|
||||
import 'package:random_string/random_string.dart';
|
||||
import 'package:sqflite_sqlcipher/sqflite.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
extension UserAgentJson on UserAgent {
|
||||
Map<String, String?> toJson() => {
|
||||
'id': id,
|
||||
'software': software,
|
||||
'device': device,
|
||||
};
|
||||
}
|
||||
|
||||
const _userAgentKey = 'userAgent';
|
||||
|
||||
const _databasePasswordKey = 'database_encryption_password';
|
||||
const _accountJidKey = 'account_jid';
|
||||
|
||||
class XmppStateService {
|
||||
/// Persistent state around the connection, like the SM token, etc.
|
||||
XmppState? _state;
|
||||
/// Logger
|
||||
final Logger _log = Logger('XmppStateService');
|
||||
|
||||
Future<XmppState> getXmppState() async {
|
||||
if (_state != null) return _state!;
|
||||
/// Persistent state around the connection, like the SM token, etc.
|
||||
late XmppState _state;
|
||||
final Lock _stateLock = Lock();
|
||||
Future<XmppState> get state => _stateLock.synchronized(() => _state);
|
||||
|
||||
/// Cached account JID.
|
||||
String? _accountJid;
|
||||
|
||||
/// Cache the user agent
|
||||
UserAgent? _userAgent;
|
||||
final Lock _userAgentLock = Lock();
|
||||
|
||||
/// Secure storage for data we must have before the database is up.
|
||||
final FlutterSecureStorage _storage = const FlutterSecureStorage(
|
||||
// TODO(Unknown): Set other options
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
);
|
||||
|
||||
/// Either returns the database password from the secure storage or
|
||||
/// generates a new one and writes it to the secure storage.
|
||||
Future<String> getOrCreateDatabaseKey() async {
|
||||
final key = await _storage.read(key: _databasePasswordKey);
|
||||
if (key != null) {
|
||||
return key;
|
||||
}
|
||||
|
||||
// We have no database key yet, so generate, save, and return.
|
||||
_log.info('Found no database encryption password. Generating a new one...');
|
||||
final newKey = randomAlphaNumeric(
|
||||
40,
|
||||
provider: CoreRandomProvider.from(Random.secure()),
|
||||
);
|
||||
await _storage.write(key: _databasePasswordKey, value: newKey);
|
||||
_log.info('Key generation done');
|
||||
return newKey;
|
||||
}
|
||||
|
||||
/// The user agent used for SASL2 authentication. If cached, returns from cache.
|
||||
/// If not cached, loads from the database. If not in the database, creates a
|
||||
/// user agent and writes it to the database.
|
||||
Future<UserAgent> get userAgent async {
|
||||
return _userAgentLock.synchronized(() async {
|
||||
if (_userAgent != null) return _userAgent!;
|
||||
|
||||
final db = GetIt.I.get<DatabaseService>().database;
|
||||
final rowsRaw = await db.database.query(
|
||||
xmppStateTable,
|
||||
where: 'key = ?',
|
||||
whereArgs: [_userAgentKey],
|
||||
);
|
||||
if (rowsRaw.isEmpty) {
|
||||
// Generate a new user agent
|
||||
_userAgent = UserAgent(
|
||||
software: 'Moxxy',
|
||||
id: const Uuid().v4(),
|
||||
);
|
||||
|
||||
// Write it to the database
|
||||
await db.insert(
|
||||
xmppStateTable,
|
||||
{
|
||||
'accountJid': _accountJid,
|
||||
'key': _userAgentKey,
|
||||
'value': jsonEncode(_userAgent!.toJson()),
|
||||
},
|
||||
);
|
||||
|
||||
return _userAgent!;
|
||||
}
|
||||
|
||||
assert(rowsRaw.length == 1, 'Only one row must exist');
|
||||
|
||||
final data = rowsRaw.first['value']! as String;
|
||||
final json =
|
||||
(jsonDecode(data) as Map<dynamic, dynamic>).cast<String, String?>();
|
||||
final userAgent = UserAgent(
|
||||
device: json['device'],
|
||||
software: json['software'],
|
||||
id: json['id'],
|
||||
);
|
||||
_userAgent = userAgent;
|
||||
return _userAgent!;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> initializeXmppState() async {
|
||||
// NOTE: Called only once at the start so we don't have to worry about aquiring a lock
|
||||
await _loadAccountJid();
|
||||
final state = await _loadXmppState(_accountJid);
|
||||
if (_accountJid == null || state == null) {
|
||||
_log.finest(
|
||||
'No account JID or account state available. Creating default value',
|
||||
);
|
||||
_state = XmppState(jid: _accountJid);
|
||||
return;
|
||||
}
|
||||
|
||||
_state = state;
|
||||
}
|
||||
|
||||
Future<XmppState?> _loadXmppState(String? accountJid) async {
|
||||
if (accountJid == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final json = <String, String?>{};
|
||||
final rowsRaw =
|
||||
await GetIt.I.get<DatabaseService>().database.query(xmppStateTable);
|
||||
final rowsRaw = await GetIt.I.get<DatabaseService>().database.query(
|
||||
xmppStateTable,
|
||||
where: 'accountJid = ?',
|
||||
whereArgs: [accountJid],
|
||||
columns: ['key', 'value'],
|
||||
);
|
||||
if (rowsRaw.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final row in rowsRaw) {
|
||||
json[row['key']! as String] = row['value'] as String?;
|
||||
}
|
||||
|
||||
_state = XmppState.fromDatabaseTuples(json);
|
||||
return _state!;
|
||||
return XmppState.fromDatabaseTuples(json);
|
||||
}
|
||||
|
||||
/// A wrapper to modify the [XmppState] and commit it.
|
||||
Future<void> modifyXmppState(XmppState Function(XmppState) func) async {
|
||||
_state = func(_state!);
|
||||
|
||||
/// The same as [commitXmppState] but without aquiring [_stateLock].
|
||||
Future<void> _commitXmppState(String accountJid) async {
|
||||
final batch = GetIt.I.get<DatabaseService>().database.batch();
|
||||
for (final tuple in _state!.toDatabaseTuples().entries) {
|
||||
for (final tuple in _state.toDatabaseTuples().entries) {
|
||||
batch.insert(
|
||||
xmppStateTable,
|
||||
<String, String?>{'key': tuple.key, 'value': tuple.value},
|
||||
<String, String?>{
|
||||
'key': tuple.key,
|
||||
'value': tuple.value,
|
||||
'accountJid': accountJid
|
||||
},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
await batch.commit();
|
||||
}
|
||||
|
||||
Future<void> commitXmppState(String accountJid) async {
|
||||
await _stateLock.synchronized(
|
||||
() => _commitXmppState(accountJid),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> setXmppState(XmppState state, String accountJid) async {
|
||||
await _stateLock.synchronized(
|
||||
() async {
|
||||
_state = state;
|
||||
await _commitXmppState(accountJid);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// A wrapper to modify the [XmppState] and commit it.
|
||||
Future<void> modifyXmppState(
|
||||
XmppState Function(XmppState) func, {
|
||||
bool commit = true,
|
||||
}) async {
|
||||
final accountJid = await getAccountJid();
|
||||
assert(
|
||||
accountJid != null,
|
||||
'The accountJid must be not empty',
|
||||
);
|
||||
|
||||
await _stateLock.synchronized(
|
||||
() async {
|
||||
_state = func(_state);
|
||||
|
||||
if (commit) {
|
||||
await _commitXmppState(accountJid!);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Resets the current account JID to null.
|
||||
Future<void> resetAccountJid() async {
|
||||
_accountJid = null;
|
||||
await _storage.delete(key: _accountJidKey);
|
||||
}
|
||||
|
||||
/// Sets the current account JID to [jid] and stores it in the secure storage.
|
||||
Future<void> setAccountJid(String jid, {bool commit = true}) async {
|
||||
_accountJid = jid;
|
||||
|
||||
if (commit) {
|
||||
await _storage.write(key: _accountJidKey, value: jid);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _loadAccountJid() async {
|
||||
return _accountJid ??= await _storage.read(key: _accountJidKey);
|
||||
}
|
||||
|
||||
/// Gets the current account JID from the cache or from the secure storage.
|
||||
Future<String?> getAccountJid() async {
|
||||
return _accountJid ?? await _loadAccountJid();
|
||||
}
|
||||
|
||||
Future<bool> isLoggedIn(String? accountJid) async {
|
||||
final s = await state;
|
||||
if (accountJid == null || s.jid == null || s.password == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await GetIt.I.get<XmppService>().getConnectionSettings() != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'dart:io';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:path/path.dart' as pathlib;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Save the bytes [bytes] that represent the user's avatar under
|
||||
/// the [cache directory]/users/[jid]/avatar_[hash].png.
|
||||
@@ -11,7 +11,7 @@ Future<String> saveAvatarInCache(
|
||||
String jid,
|
||||
String oldPath,
|
||||
) async {
|
||||
final cacheDir = (await getApplicationDocumentsDirectory()).path;
|
||||
final cacheDir = await MoxplatformPlugin.platform.getPersistentDataPath();
|
||||
final avatarsDir = Directory(pathlib.join(cacheDir, 'avatars'));
|
||||
await avatarsDir.create(recursive: true);
|
||||
|
||||
@@ -29,6 +29,6 @@ Future<String> saveAvatarInCache(
|
||||
/// Returns the path where a user's avatar is saved. Note that this does not imply
|
||||
/// the existence of an avatar.
|
||||
Future<String> getAvatarPath(String jid, String hash) async {
|
||||
final cacheDir = (await getApplicationDocumentsDirectory()).path;
|
||||
final cacheDir = await MoxplatformPlugin.platform.getPersistentDataPath();
|
||||
return pathlib.join(cacheDir, 'avatars', '$hash.png');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:moxlib/awaitabledatasender.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
|
||||
@@ -14,3 +14,23 @@ const int maxSharedMediaPages = 3;
|
||||
|
||||
/// The amount of conversations for which we cache the first page.
|
||||
const int conversationMessagePageCacheSize = 4;
|
||||
|
||||
/// The amount of sticker packs we fetch per paginated request
|
||||
const stickerPackPaginationSize = 10;
|
||||
|
||||
/// The amount of sticker packs we can cache in memory.
|
||||
const maxStickerPackPages = 2;
|
||||
|
||||
/// An "invalid" fake JID to make share_handler happy when adding the self-chat
|
||||
/// to the direct share list.
|
||||
const selfChatShareFakeJid = '{{ self-chat }}';
|
||||
|
||||
/// Keys for grouping notifications
|
||||
const messageNotificationGroupId = 'message';
|
||||
const warningNotificationGroupId = 'warning';
|
||||
const foregroundServiceNotificationGroupId = 'service';
|
||||
|
||||
/// Notification channel ids
|
||||
const foregroundServiceNotificationChannelId = 'foreground_service';
|
||||
const messageNotificationChannelId = 'messages';
|
||||
const warningNotificationChannelId = 'warnings';
|
||||
|
||||
11
lib/shared/debug.dart
Normal file
11
lib/shared/debug.dart
Normal file
@@ -0,0 +1,11 @@
|
||||
enum DebugCommand {
|
||||
/// Clear the stream resumption state so that the next connection is fresh.
|
||||
clearStreamResumption(0),
|
||||
requestRoster(1),
|
||||
logAvailableMediaFiles(2);
|
||||
|
||||
const DebugCommand(this.id);
|
||||
|
||||
/// The id of the command
|
||||
final int id;
|
||||
}
|
||||
@@ -1,86 +1,253 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/i18n/strings.g.dart';
|
||||
import 'package:omemo_dart/omemo_dart.dart';
|
||||
|
||||
const unspecifiedError = -1;
|
||||
const noError = 0;
|
||||
const fileUploadFailedError = 1;
|
||||
const messageNotEncryptedForDevice = 2;
|
||||
const messageInvalidHMAC = 3;
|
||||
const messageNoDecryptionKey = 4;
|
||||
const messageInvalidAffixElements = 5;
|
||||
// const messageInvalidNumber = 6;
|
||||
const messageFailedToEncrypt = 7;
|
||||
const messageFailedToDecryptFile = 8;
|
||||
const messageContactDoesNotSupportOmemo = 9;
|
||||
const messageChatEncryptedButFileNot = 10;
|
||||
const messageFailedToEncryptFile = 11;
|
||||
const fileDownloadFailedError = 12;
|
||||
const messageServiceUnavailable = 13;
|
||||
const messageRemoteServerTimeout = 14;
|
||||
const messageRemoteServerNotFound = 15;
|
||||
enum ErrorType {
|
||||
unknown(-1),
|
||||
remoteServerNotFound(0),
|
||||
remoteServerTimeout(1);
|
||||
|
||||
int errorTypeFromException(dynamic exception) {
|
||||
if (exception == null) {
|
||||
return noError;
|
||||
const ErrorType(this.value);
|
||||
|
||||
factory ErrorType.fromValue(int value) {
|
||||
switch (value) {
|
||||
case 0:
|
||||
return ErrorType.remoteServerNotFound;
|
||||
case 1:
|
||||
return ErrorType.remoteServerTimeout;
|
||||
default:
|
||||
return ErrorType.unknown;
|
||||
}
|
||||
}
|
||||
|
||||
if (exception is NoDecryptionKeyException) {
|
||||
return messageNoDecryptionKey;
|
||||
} else if (exception is InvalidMessageHMACException) {
|
||||
return messageInvalidHMAC;
|
||||
} else if (exception is NotEncryptedForDeviceException) {
|
||||
return messageNoDecryptionKey;
|
||||
} else if (exception is InvalidAffixElementsException) {
|
||||
return messageInvalidAffixElements;
|
||||
} else if (exception is EncryptionFailedException) {
|
||||
return messageFailedToEncrypt;
|
||||
} else if (exception is OmemoNotSupportedForContactException) {
|
||||
return messageContactDoesNotSupportOmemo;
|
||||
}
|
||||
|
||||
return unspecifiedError;
|
||||
/// The identifier value of this error type.
|
||||
final int value;
|
||||
}
|
||||
|
||||
String errorToTranslatableString(int error) {
|
||||
enum MessageErrorType {
|
||||
unspecified(-1),
|
||||
// TODO(Unknown): Maybe remove
|
||||
noError(0),
|
||||
|
||||
/// The file upload failed.
|
||||
fileUploadFailed(1),
|
||||
|
||||
/// The received message was not encrypted for this device.
|
||||
notEncryptedForDevice(2),
|
||||
|
||||
/// The HMAC of the encrypted message is wrong.
|
||||
invalidHMAC(3),
|
||||
|
||||
/// We have no key available to decrypt the message.
|
||||
noDecryptionKey(4),
|
||||
|
||||
/// The sanity-checks on the included affix elements failed.
|
||||
invalidAffixElements(5),
|
||||
|
||||
/// The encryption of the message somehow failed.
|
||||
failedToEncrypt(7),
|
||||
|
||||
/// The decryption of the file failed.
|
||||
failedToDecryptFile(8),
|
||||
|
||||
/// The contact does not support OMEMO:2.
|
||||
omemoNotSupported(9),
|
||||
|
||||
/// The chat is set to use OMEMO, but the received file was sent in plaintext.
|
||||
chatEncryptedButPlaintextFile(10),
|
||||
|
||||
/// The encryption of the file somehow failed.
|
||||
failedToEncryptFile(11),
|
||||
|
||||
/// We were unable to download the file.
|
||||
fileDownloadFailed(12),
|
||||
|
||||
/// The message was bounced with a <service-unavailable />.
|
||||
serviceUnavailable(13),
|
||||
|
||||
/// The message was bounced with a <remote-server-timeout />.
|
||||
remoteServerTimeout(14),
|
||||
|
||||
/// The message was bounced with a <remote-server-not-found />.
|
||||
remoteServerNotFound(15);
|
||||
|
||||
const MessageErrorType(this.value);
|
||||
|
||||
static MessageErrorType? fromInt(int? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value == MessageErrorType.unspecified.value) {
|
||||
return MessageErrorType.unspecified;
|
||||
} else if (value == MessageErrorType.noError.value) {
|
||||
return MessageErrorType.noError;
|
||||
} else if (value == MessageErrorType.fileUploadFailed.value) {
|
||||
return MessageErrorType.fileUploadFailed;
|
||||
} else if (value == MessageErrorType.notEncryptedForDevice.value) {
|
||||
return MessageErrorType.notEncryptedForDevice;
|
||||
} else if (value == MessageErrorType.invalidHMAC.value) {
|
||||
return MessageErrorType.invalidHMAC;
|
||||
} else if (value == MessageErrorType.noDecryptionKey.value) {
|
||||
return MessageErrorType.noDecryptionKey;
|
||||
} else if (value == MessageErrorType.invalidAffixElements.value) {
|
||||
return MessageErrorType.invalidAffixElements;
|
||||
} else if (value == MessageErrorType.failedToEncrypt.value) {
|
||||
return MessageErrorType.failedToEncrypt;
|
||||
} else if (value == MessageErrorType.failedToDecryptFile.value) {
|
||||
return MessageErrorType.failedToDecryptFile;
|
||||
} else if (value == MessageErrorType.omemoNotSupported.value) {
|
||||
return MessageErrorType.omemoNotSupported;
|
||||
} else if (value == MessageErrorType.chatEncryptedButPlaintextFile.value) {
|
||||
return MessageErrorType.chatEncryptedButPlaintextFile;
|
||||
} else if (value == MessageErrorType.failedToEncryptFile.value) {
|
||||
return MessageErrorType.failedToEncryptFile;
|
||||
} else if (value == MessageErrorType.fileDownloadFailed.value) {
|
||||
return MessageErrorType.fileDownloadFailed;
|
||||
} else if (value == MessageErrorType.serviceUnavailable.value) {
|
||||
return MessageErrorType.serviceUnavailable;
|
||||
} else if (value == MessageErrorType.remoteServerTimeout.value) {
|
||||
return MessageErrorType.remoteServerTimeout;
|
||||
} else if (value == MessageErrorType.remoteServerNotFound.value) {
|
||||
return MessageErrorType.remoteServerNotFound;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static MessageErrorType? fromException(dynamic exception) {
|
||||
if (exception == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (exception is InvalidMessageHMACError) {
|
||||
return MessageErrorType.invalidHMAC;
|
||||
} else if (exception is NotEncryptedForDeviceError) {
|
||||
return MessageErrorType.noDecryptionKey;
|
||||
} else if (exception is InvalidAffixElementsException) {
|
||||
return MessageErrorType.invalidAffixElements;
|
||||
} else if (exception is EncryptionFailedException) {
|
||||
return MessageErrorType.failedToEncrypt;
|
||||
} else if (exception is OmemoNotSupportedForContactException) {
|
||||
return MessageErrorType.omemoNotSupported;
|
||||
}
|
||||
|
||||
return MessageErrorType.unspecified;
|
||||
}
|
||||
|
||||
/// The identifier representing the error.
|
||||
final int value;
|
||||
|
||||
String get translatableString {
|
||||
assert(
|
||||
error != noError,
|
||||
this != MessageErrorType.noError,
|
||||
'Calling errorToTranslatableString with noError makes no sense',
|
||||
);
|
||||
|
||||
switch (error) {
|
||||
case messageNotEncryptedForDevice:
|
||||
switch (this) {
|
||||
case MessageErrorType.notEncryptedForDevice:
|
||||
return t.errors.omemo.notEncryptedForDevice;
|
||||
case messageInvalidHMAC:
|
||||
case MessageErrorType.invalidHMAC:
|
||||
return t.errors.omemo.invalidHmac;
|
||||
case messageNoDecryptionKey:
|
||||
case MessageErrorType.noDecryptionKey:
|
||||
return t.errors.omemo.noDecryptionKey;
|
||||
case messageInvalidAffixElements:
|
||||
case MessageErrorType.invalidAffixElements:
|
||||
return t.errors.omemo.messageInvalidAfixElement;
|
||||
case fileUploadFailedError:
|
||||
case MessageErrorType.fileUploadFailed:
|
||||
return t.errors.message.fileUploadFailed;
|
||||
case messageContactDoesNotSupportOmemo:
|
||||
case MessageErrorType.omemoNotSupported:
|
||||
return t.errors.message.contactDoesntSupportOmemo;
|
||||
case fileDownloadFailedError:
|
||||
case MessageErrorType.fileDownloadFailed:
|
||||
return t.errors.message.fileDownloadFailed;
|
||||
case messageServiceUnavailable:
|
||||
case MessageErrorType.serviceUnavailable:
|
||||
return t.errors.message.serviceUnavailable;
|
||||
case messageRemoteServerTimeout:
|
||||
case MessageErrorType.remoteServerTimeout:
|
||||
return t.errors.message.remoteServerTimeout;
|
||||
case messageRemoteServerNotFound:
|
||||
case MessageErrorType.remoteServerNotFound:
|
||||
return t.errors.message.remoteServerNotFound;
|
||||
case messageFailedToEncrypt:
|
||||
case MessageErrorType.failedToEncrypt:
|
||||
return t.errors.message.failedToEncrypt;
|
||||
case messageFailedToDecryptFile:
|
||||
case MessageErrorType.failedToDecryptFile:
|
||||
return t.errors.message.failedToDecryptFile;
|
||||
case messageChatEncryptedButFileNot:
|
||||
case MessageErrorType.chatEncryptedButPlaintextFile:
|
||||
return t.errors.message.fileNotEncrypted;
|
||||
case messageFailedToEncryptFile:
|
||||
case MessageErrorType.failedToEncryptFile:
|
||||
return t.errors.message.failedToEncryptFile;
|
||||
case unspecifiedError:
|
||||
// NOTE: This fallthrough is just here to make Dart happy
|
||||
case MessageErrorType.noError:
|
||||
case MessageErrorType.unspecified:
|
||||
return t.errors.message.unspecified;
|
||||
}
|
||||
|
||||
assert(false, 'Invalid error code $error used');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// A converter for converting between [MessageErrorType] and [int].
|
||||
class MessageErrorTypeConverter
|
||||
implements JsonConverter<MessageErrorType, int> {
|
||||
const MessageErrorTypeConverter();
|
||||
|
||||
@override
|
||||
MessageErrorType fromJson(int json) {
|
||||
return MessageErrorType.fromInt(json)!;
|
||||
}
|
||||
|
||||
@override
|
||||
int toJson(MessageErrorType data) => data.value;
|
||||
}
|
||||
|
||||
enum GroupchatErrorType {
|
||||
unspecified(0),
|
||||
roomPasswordProtected(1),
|
||||
unableToJoinRoom(2),
|
||||
noNicknameSpecified(3),
|
||||
invalidStanzaFormat(4),
|
||||
invalidDiscoInfoResponse(5),
|
||||
roomNotJoinedError(6);
|
||||
|
||||
const GroupchatErrorType(this.value);
|
||||
|
||||
static GroupchatErrorType? fromInt(int? value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value == GroupchatErrorType.unspecified.value) {
|
||||
return GroupchatErrorType.unspecified;
|
||||
} else if (value == GroupchatErrorType.roomPasswordProtected.value) {
|
||||
return GroupchatErrorType.roomPasswordProtected;
|
||||
} else if (value == GroupchatErrorType.unableToJoinRoom.value) {
|
||||
return GroupchatErrorType.unableToJoinRoom;
|
||||
} else if (value == GroupchatErrorType.noNicknameSpecified.value) {
|
||||
return GroupchatErrorType.noNicknameSpecified;
|
||||
} else if (value == GroupchatErrorType.invalidStanzaFormat.value) {
|
||||
return GroupchatErrorType.invalidStanzaFormat;
|
||||
} else if (value == GroupchatErrorType.invalidDiscoInfoResponse.value) {
|
||||
return GroupchatErrorType.invalidDiscoInfoResponse;
|
||||
} else if (value == GroupchatErrorType.roomNotJoinedError.value) {
|
||||
return GroupchatErrorType.roomNotJoinedError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static GroupchatErrorType? fromException(dynamic exception) {
|
||||
if (exception == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (exception is NoNicknameSpecified) {
|
||||
return GroupchatErrorType.noNicknameSpecified;
|
||||
} else if (exception is InvalidStanzaFormat) {
|
||||
return GroupchatErrorType.invalidStanzaFormat;
|
||||
} else if (exception is InvalidDiscoInfoResponse) {
|
||||
return GroupchatErrorType.invalidDiscoInfoResponse;
|
||||
} else if (exception is RoomNotJoinedError) {
|
||||
return GroupchatErrorType.roomNotJoinedError;
|
||||
}
|
||||
|
||||
return GroupchatErrorType.unspecified;
|
||||
}
|
||||
|
||||
/// The identifier representing the error.
|
||||
final int value;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:moxlib/awaitabledatasender.dart';
|
||||
import 'package:moxlib/moxlib.dart';
|
||||
import 'package:moxplatform/moxplatform.dart';
|
||||
import 'package:moxxyv2/shared/models/conversation.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
@@ -7,6 +7,7 @@ import 'package:moxxyv2/shared/models/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/reaction_group.dart';
|
||||
import 'package:moxxyv2/shared/models/roster.dart';
|
||||
import 'package:moxxyv2/shared/models/sticker_pack.dart';
|
||||
import 'package:moxxyv2/ui/bloc/sendfiles_bloc.dart';
|
||||
|
||||
part 'events.moxxy.dart';
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:core';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
@@ -7,7 +8,6 @@ import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:synchronized/synchronized.dart';
|
||||
import 'package:video_thumbnail/video_thumbnail.dart';
|
||||
|
||||
/// Add a leading zero, if required, to ensure that an integer is rendered
|
||||
/// as a two "digit" string.
|
||||
@@ -365,49 +365,13 @@ Future<Size?> getImageSizeFromData(Uint8List bytes) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a thumbnail file (JPEG) for the video at [path]. [conversationJid] refers
|
||||
/// to the JID of the conversation the file comes from.
|
||||
/// If the thumbnail already exists, then just its path is returned. If not, then
|
||||
/// it gets generated first.
|
||||
Future<String?> getVideoThumbnailPath(
|
||||
String path,
|
||||
String conversationJid,
|
||||
String mime,
|
||||
) async {
|
||||
//print('getVideoThumbnailPath: Mime type: $mime');
|
||||
|
||||
/// Returns true if we can generate a video thumbnail of mime type [mime]. If not, returns
|
||||
/// false.
|
||||
bool canGenerateVideoThumbnail(String mime) {
|
||||
return ![
|
||||
// Ignore mime types that may be wacky
|
||||
if (mime == 'video/webm') return null;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final thumbnailFilenameNoExtension = p.withoutExtension(
|
||||
p.basename(path),
|
||||
);
|
||||
final thumbnailFilename = '$thumbnailFilenameNoExtension.jpg';
|
||||
final thumbnailDirectory = p.join(
|
||||
tempDir.path,
|
||||
'thumbnails',
|
||||
conversationJid,
|
||||
);
|
||||
final thumbnailPath = p.join(thumbnailDirectory, thumbnailFilename);
|
||||
|
||||
final dir = Directory(thumbnailDirectory);
|
||||
if (!dir.existsSync()) await dir.create(recursive: true);
|
||||
final file = File(thumbnailPath);
|
||||
if (file.existsSync()) return thumbnailPath;
|
||||
|
||||
final r = await VideoThumbnail.thumbnailFile(
|
||||
video: path,
|
||||
thumbnailPath: thumbnailDirectory,
|
||||
imageFormat: ImageFormat.JPEG,
|
||||
quality: 75,
|
||||
);
|
||||
assert(
|
||||
r == thumbnailPath,
|
||||
'The generated video thumbnail has a different path than we expected: $r vs. $thumbnailPath',
|
||||
);
|
||||
|
||||
return thumbnailPath;
|
||||
'video/webm',
|
||||
].contains(mime);
|
||||
}
|
||||
|
||||
Future<String> getContactProfilePicturePath(String id) async {
|
||||
@@ -423,15 +387,6 @@ Future<String> getContactProfilePicturePath(String id) async {
|
||||
return p.join(avatarDir, id);
|
||||
}
|
||||
|
||||
Future<String> getStickerPackPath(String hashFunction, String hashValue) async {
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
return p.join(
|
||||
appDir.path,
|
||||
'stickers',
|
||||
'${hashFunction}_$hashValue',
|
||||
);
|
||||
}
|
||||
|
||||
/// Prepend [item] to [list], but ensure that the resulting list's size is
|
||||
/// smaller than or equal to [maxSize].
|
||||
List<T> clampedListPrepend<T>(List<T> list, T item, int maxSize) {
|
||||
@@ -461,3 +416,15 @@ List<T> clampedListPrependAll<T>(List<T> list, List<T> items, int maxSize) {
|
||||
...list,
|
||||
].sublist(0, maxSize);
|
||||
}
|
||||
|
||||
extension StringJsonHelper on String {
|
||||
/// Converts the Map into a JSON-encoded String. Helper function for working with nullable maps.
|
||||
Map<String, dynamic> fromJson() {
|
||||
return (jsonDecode(this) as Map<dynamic, dynamic>).cast<String, dynamic>();
|
||||
}
|
||||
}
|
||||
|
||||
extension MapJsonHelper on Map<String, dynamic> {
|
||||
/// Converts the map into a String. Helper function for working with nullable Strings.
|
||||
String toJson() => jsonEncode(this);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/shared/models/groupchat.dart';
|
||||
import 'package:moxxyv2/shared/models/message.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
|
||||
@@ -14,11 +16,11 @@ class ConversationChatStateConverter
|
||||
|
||||
@override
|
||||
ChatState fromJson(Map<String, dynamic> json) =>
|
||||
chatStateFromString(json['chatState'] as String);
|
||||
ChatState.fromName(json['chatState'] as String);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(ChatState state) => <String, String>{
|
||||
'chatState': chatStateToString(state),
|
||||
'chatState': state.toName(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,39 +42,137 @@ class ConversationMessageConverter
|
||||
}
|
||||
|
||||
enum ConversationType {
|
||||
@JsonValue('chat')
|
||||
chat,
|
||||
@JsonValue('note')
|
||||
note
|
||||
chat('chat'),
|
||||
note('note'),
|
||||
groupchat('groupchat');
|
||||
|
||||
const ConversationType(this.value);
|
||||
final String value;
|
||||
|
||||
static ConversationType fromString(String value) {
|
||||
switch (value) {
|
||||
case 'groupchat':
|
||||
return ConversationType.groupchat;
|
||||
case 'note':
|
||||
return ConversationType.note;
|
||||
case 'chat':
|
||||
return ConversationType.chat;
|
||||
default:
|
||||
// Should ideally never happen
|
||||
throw Exception();
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the "type" attribute value for a message within the enum value's
|
||||
/// context.
|
||||
String toMessageType() {
|
||||
assert(
|
||||
this != ConversationType.note,
|
||||
'Chat states should not be sent to the self-chat',
|
||||
);
|
||||
|
||||
switch (this) {
|
||||
case ConversationType.note:
|
||||
case ConversationType.chat:
|
||||
return 'chat';
|
||||
case ConversationType.groupchat:
|
||||
return 'groupchat';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ConversationTypeConverter
|
||||
extends JsonConverter<ConversationType, String> {
|
||||
const ConversationTypeConverter();
|
||||
|
||||
@override
|
||||
ConversationType fromJson(String json) {
|
||||
return ConversationType.fromString(json);
|
||||
}
|
||||
|
||||
@override
|
||||
String toJson(ConversationType object) {
|
||||
return object.value;
|
||||
}
|
||||
}
|
||||
|
||||
class GroupchatDetailsConverter
|
||||
extends JsonConverter<GroupchatDetails, Map<String, dynamic>> {
|
||||
const GroupchatDetailsConverter();
|
||||
|
||||
@override
|
||||
GroupchatDetails fromJson(Map<String, dynamic> json) {
|
||||
return GroupchatDetails(
|
||||
json['jid']! as String,
|
||||
json['accountJid']! as String,
|
||||
json['nick']! as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(GroupchatDetails object) {
|
||||
return {
|
||||
'jid': object.jid,
|
||||
'accountJid': object.accountJid,
|
||||
'nick': object.nick,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class Conversation with _$Conversation {
|
||||
factory Conversation(
|
||||
/// The account that has the conversation.
|
||||
String accountJid,
|
||||
|
||||
/// The title of the chat.
|
||||
String title,
|
||||
|
||||
// The newest message in the chat.
|
||||
@ConversationMessageConverter() Message? lastMessage,
|
||||
String avatarUrl,
|
||||
|
||||
// The path to the avatar.
|
||||
String avatarPath,
|
||||
|
||||
// The hash of the avatar.
|
||||
String? avatarHash,
|
||||
|
||||
// The JID of the entity we're having a chat with...
|
||||
String jid,
|
||||
|
||||
// The nick with which the MUC is joined...
|
||||
@GroupchatDetailsConverter() GroupchatDetails? groupchatDetails,
|
||||
|
||||
// The number of unread messages.
|
||||
int unreadCounter,
|
||||
ConversationType type,
|
||||
|
||||
// The kind of chat this conversation is representing.
|
||||
@ConversationTypeConverter() ConversationType type,
|
||||
|
||||
// The timestamp the conversation was last changed.
|
||||
// NOTE: In milliseconds since Epoch or -1 if none has ever happened
|
||||
int lastChangeTimestamp,
|
||||
// Indicates if the conversation should be shown on the homescreen
|
||||
|
||||
// Indicates if the conversation should be shown on the homescreen.
|
||||
bool open,
|
||||
// Indicates, if [jid] is a regular user, if the user is in the roster.
|
||||
bool inRoster,
|
||||
// The subscription state of the roster item
|
||||
String subscription,
|
||||
|
||||
/// Flag indicating whether the "add to roster" button should be shown.
|
||||
bool showAddToRoster,
|
||||
|
||||
// Whether the chat is muted (true = muted, false = not muted)
|
||||
bool muted,
|
||||
|
||||
// Whether the conversation is encrypted or not (true = encrypted, false = unencrypted)
|
||||
bool encrypted,
|
||||
|
||||
// The current chat state
|
||||
@ConversationChatStateConverter() ChatState chatState, {
|
||||
// The id of the contact in the device's phonebook if it exists
|
||||
String? contactId,
|
||||
|
||||
// The path to the contact avatar, if available
|
||||
String? contactAvatarPath,
|
||||
|
||||
// The contact's display name, if it exists
|
||||
String? contactDisplayName,
|
||||
}) = _Conversation;
|
||||
@@ -85,21 +185,21 @@ class Conversation with _$Conversation {
|
||||
|
||||
factory Conversation.fromDatabaseJson(
|
||||
Map<String, dynamic> json,
|
||||
bool inRoster,
|
||||
String subscription,
|
||||
bool showAddToRoster,
|
||||
Message? lastMessage,
|
||||
GroupchatDetails? groupchatDetails,
|
||||
) {
|
||||
return Conversation.fromJson({
|
||||
...json,
|
||||
'muted': intToBool(json['muted']! as int),
|
||||
'open': intToBool(json['open']! as int),
|
||||
'inRoster': inRoster,
|
||||
'subscription': subscription,
|
||||
'showAddToRoster': showAddToRoster,
|
||||
'encrypted': intToBool(json['encrypted']! as int),
|
||||
'chatState':
|
||||
const ConversationChatStateConverter().toJson(ChatState.gone),
|
||||
}).copyWith(
|
||||
lastMessage: lastMessage,
|
||||
groupchatDetails: groupchatDetails,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,9 +207,9 @@ class Conversation with _$Conversation {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('chatState')
|
||||
..remove('inRoster')
|
||||
..remove('subscription')
|
||||
..remove('lastMessage');
|
||||
..remove('showAddToRoster')
|
||||
..remove('lastMessage')
|
||||
..remove('groupchatDetails');
|
||||
|
||||
return {
|
||||
...map,
|
||||
@@ -123,30 +223,68 @@ class Conversation with _$Conversation {
|
||||
/// True, when the chat state of the conversation indicates typing. False, if not.
|
||||
bool get isTyping => chatState == ChatState.composing;
|
||||
|
||||
/// The path to the avatar. This returns, if enabled, first the contact's avatar
|
||||
/// path, then the XMPP avatar's path. If not enabled, just returns the regular
|
||||
/// The path to the avatar. This returns, if [contactIntegration] is true, first the contact's avatar
|
||||
/// path, then the XMPP avatar's path. If [contactIntegration] is false, just returns the regular
|
||||
/// XMPP avatar's path.
|
||||
String? get avatarPathWithOptionalContact {
|
||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||
return contactAvatarPath ?? avatarUrl;
|
||||
String getAvatarPathWithOptionalContact(bool contactIntegration) {
|
||||
if (contactIntegration) {
|
||||
return contactAvatarPath ?? avatarPath;
|
||||
}
|
||||
|
||||
return avatarUrl;
|
||||
return avatarPath;
|
||||
}
|
||||
|
||||
/// The title of the chat. This returns, if enabled, first the contact's display
|
||||
/// name, then the XMPP chat title. If not enabled, just returns the XMPP chat
|
||||
/// This getter is a short-hand for [getAvatarPathWithOptionalContact] with the
|
||||
/// contact integration enablement status extracted from the [PreferencesBloc].
|
||||
/// NOTE: This method only works in the UI.
|
||||
String? get avatarPathWithOptionalContact => getAvatarPathWithOptionalContact(
|
||||
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
|
||||
);
|
||||
|
||||
/// This getter is a short-hand for [getAvatarPathWithOptionalContact] with the
|
||||
/// contact integration enablement status extracted from the [PreferencesService].
|
||||
/// NOTE: This method only works in the background isolate.
|
||||
Future<String?> get avatarPathWithOptionalContactService async =>
|
||||
getAvatarPathWithOptionalContact(
|
||||
(await GetIt.I.get<PreferencesService>().getPreferences())
|
||||
.enableContactIntegration,
|
||||
);
|
||||
|
||||
/// The title of the chat. This returns, if [contactIntegration] is true, first the contact's display
|
||||
/// name, then the XMPP chat title. If [contactIntegration] is false, just returns the XMPP chat
|
||||
/// title.
|
||||
String get titleWithOptionalContact {
|
||||
if (GetIt.I.get<PreferencesBloc>().state.enableContactIntegration) {
|
||||
String getTitleWithOptionalContact(bool contactIntegration) {
|
||||
if (contactIntegration) {
|
||||
return contactDisplayName ?? title;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
|
||||
/// contact integration enablement status extracted from the [PreferencesBloc].
|
||||
/// NOTE: This method only works in the UI.
|
||||
String get titleWithOptionalContact => getTitleWithOptionalContact(
|
||||
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
|
||||
);
|
||||
|
||||
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
|
||||
/// contact integration enablement status extracted from the [PreferencesService].
|
||||
/// NOTE: This method only works in the background isolate.
|
||||
Future<String> get titleWithOptionalContactService async =>
|
||||
getTitleWithOptionalContact(
|
||||
(await GetIt.I.get<PreferencesService>().getPreferences())
|
||||
.enableContactIntegration,
|
||||
);
|
||||
|
||||
/// The amount of items that are shown in the context menu.
|
||||
int get numberContextMenuOptions => 1 + (unreadCounter != 0 ? 1 : 0);
|
||||
|
||||
/// True, if the conversation is a self-chat. False, if not.
|
||||
bool get isSelfChat => type == ConversationType.note;
|
||||
|
||||
/// True, if the conversation is a groupchat. False, if not.
|
||||
bool get isGroupchat => type == ConversationType.groupchat;
|
||||
}
|
||||
|
||||
/// Sorts conversations in descending order by their last change timestamp.
|
||||
|
||||
30
lib/shared/models/groupchat.dart
Normal file
30
lib/shared/models/groupchat.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'groupchat.freezed.dart';
|
||||
part 'groupchat.g.dart';
|
||||
|
||||
@freezed
|
||||
class GroupchatDetails with _$GroupchatDetails {
|
||||
factory GroupchatDetails(
|
||||
/// The JID of the groupchat.
|
||||
String jid,
|
||||
|
||||
/// The associated account JID.
|
||||
String accountJid,
|
||||
|
||||
/// The nick to join as.
|
||||
String nick,
|
||||
) = _GroupchatDetails;
|
||||
|
||||
const GroupchatDetails._();
|
||||
|
||||
/// JSON
|
||||
factory GroupchatDetails.fromJson(Map<String, dynamic> json) =>
|
||||
_$GroupchatDetailsFromJson(json);
|
||||
|
||||
factory GroupchatDetails.fromDatabaseJson(
|
||||
Map<String, dynamic> json,
|
||||
) {
|
||||
return GroupchatDetails.fromJson(json);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxmpp/moxxmpp.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/shared/error_types.dart';
|
||||
import 'package:moxxyv2/shared/helpers.dart';
|
||||
@@ -9,50 +9,130 @@ import 'package:moxxyv2/shared/warning_types.dart';
|
||||
part 'message.freezed.dart';
|
||||
part 'message.g.dart';
|
||||
|
||||
const pseudoMessageTypeNewDevice = 1;
|
||||
enum PseudoMessageType {
|
||||
/// Indicates that a new device was created in the chat.
|
||||
newDevice(1),
|
||||
|
||||
Map<String, dynamic> _optionalJsonDecodeWithFallback(String? data) {
|
||||
if (data == null) return <String, dynamic>{};
|
||||
/// Indicates that an existing device has been replaced.
|
||||
changedDevice(2);
|
||||
|
||||
return (jsonDecode(data) as Map<dynamic, dynamic>).cast<String, dynamic>();
|
||||
const PseudoMessageType(this.value);
|
||||
|
||||
/// The identifier for the type of pseudo message.
|
||||
final int value;
|
||||
|
||||
static PseudoMessageType? fromInt(int value) {
|
||||
switch (value) {
|
||||
case 1:
|
||||
return PseudoMessageType.newDevice;
|
||||
case 2:
|
||||
return PseudoMessageType.changedDevice;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String? _optionalJsonEncodeWithFallback(Map<String, dynamic>? data) {
|
||||
if (data == null) return null;
|
||||
if (data.isEmpty) return null;
|
||||
/// A converter for converting between [PseudoMessageType] and [int].
|
||||
class PseudoMessageTypeConverter extends JsonConverter<PseudoMessageType, int> {
|
||||
const PseudoMessageTypeConverter();
|
||||
|
||||
return jsonEncode(data);
|
||||
@override
|
||||
PseudoMessageType fromJson(int json) {
|
||||
return PseudoMessageType.fromInt(json)!;
|
||||
}
|
||||
|
||||
@override
|
||||
int toJson(PseudoMessageType object) {
|
||||
return object.value;
|
||||
}
|
||||
}
|
||||
|
||||
@freezed
|
||||
class Message with _$Message {
|
||||
factory Message(
|
||||
// The message id (Moxxy-generated UUID).
|
||||
String id,
|
||||
|
||||
/// The JID of the account that sent or received the message.
|
||||
String accountJid,
|
||||
|
||||
/// The full JID of the sender
|
||||
String sender,
|
||||
|
||||
/// The content of the <body /> tag
|
||||
String body,
|
||||
|
||||
/// The timestamp the message was received
|
||||
int timestamp,
|
||||
|
||||
/// The "id" attribute of the message stanza.
|
||||
String sid,
|
||||
// The database-internal identifier of the message
|
||||
int id,
|
||||
|
||||
/// The JID of the conversation this message was received/sent in.
|
||||
String conversationJid,
|
||||
|
||||
/// Flag indicating whether the message is a file upload notification.
|
||||
bool isFileUploadNotification,
|
||||
|
||||
/// Flag indicating whether the message was sent/received encrypted.
|
||||
bool encrypted,
|
||||
// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||
|
||||
/// True if the message contains a <no-store> Message Processing Hint. False if not
|
||||
bool containsNoStore, {
|
||||
int? errorType,
|
||||
int? warningType,
|
||||
/// A message's associated error, if applicable (e.g. crypto error, file upload failure, ...).
|
||||
@MessageErrorTypeConverter() MessageErrorType? errorType,
|
||||
|
||||
/// A message's associated warning, if applicable.
|
||||
@MessageWarningTypeConverter() MessageWarningType? warningType,
|
||||
|
||||
/// If a file is attached, this is a reference to the file metadata.
|
||||
FileMetadata? fileMetadata,
|
||||
|
||||
/// Flag indicating whether the message's file is currently being downloaded.
|
||||
@Default(false) bool isDownloading,
|
||||
|
||||
/// Flag indicating whether the message's file is currently being uploaded.
|
||||
@Default(false) bool isUploading,
|
||||
|
||||
/// Flag indicating whether the message was marked as received.
|
||||
@Default(false) bool received,
|
||||
|
||||
/// If the message was sent by us, this means that the recipient has displayed the message.
|
||||
/// If we received the message, then this means that we sent a read marker for that message.
|
||||
@Default(false) bool displayed,
|
||||
|
||||
/// Specified whether the message has been acked using stream management, i.e. it was successfully sent to
|
||||
/// the server.
|
||||
@Default(false) bool acked,
|
||||
|
||||
/// Indicates whether the message has been retracted.
|
||||
@Default(false) bool isRetracted,
|
||||
|
||||
/// Indicates whether the message has been edited.
|
||||
@Default(false) bool isEdited,
|
||||
|
||||
/// An optional origin id attached to the message
|
||||
String? originId,
|
||||
|
||||
/// The message this message quotes using XEP-0461
|
||||
Message? quotes,
|
||||
|
||||
/// A short summary of reactions, if available
|
||||
@Default([]) List<String> reactionsPreview,
|
||||
|
||||
/// The ID of the sticker pack the sticker belongs to, if the message
|
||||
/// contains a sticker.
|
||||
String? stickerPackId,
|
||||
int? pseudoMessageType,
|
||||
|
||||
/// The occupant-id of the sender, when the message was received in a groupchat.
|
||||
String? occupantId,
|
||||
|
||||
/// If the message is not a real message, then this field indicates
|
||||
/// the type of "pseudo message" we should display.
|
||||
@PseudoMessageTypeConverter() PseudoMessageType? pseudoMessageType,
|
||||
|
||||
/// The associated data for "pseudo messages".
|
||||
Map<String, dynamic>? pseudoMessageData,
|
||||
}) = _Message;
|
||||
|
||||
@@ -82,8 +162,7 @@ class Message with _$Message {
|
||||
'isEdited': intToBool(json['isEdited']! as int),
|
||||
'containsNoStore': intToBool(json['containsNoStore']! as int),
|
||||
'reactionsPreview': reactionsPreview,
|
||||
'pseudoMessageData':
|
||||
_optionalJsonDecodeWithFallback(json['pseudoMessageData'] as String?)
|
||||
'pseudoMessageData': (json['pseudoMessageData'] as String?)?.fromJson(),
|
||||
}).copyWith(
|
||||
quotes: quotes,
|
||||
fileMetadata: fileMetadata,
|
||||
@@ -92,7 +171,6 @@ class Message with _$Message {
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
final map = toJson()
|
||||
..remove('id')
|
||||
..remove('quotes')
|
||||
..remove('reactionsPreview')
|
||||
..remove('fileMetadata')
|
||||
@@ -113,15 +191,29 @@ class Message with _$Message {
|
||||
'isRetracted': boolToInt(isRetracted),
|
||||
'isEdited': boolToInt(isEdited),
|
||||
'containsNoStore': boolToInt(containsNoStore),
|
||||
'pseudoMessageData': _optionalJsonEncodeWithFallback(pseudoMessageData),
|
||||
'pseudoMessageData': pseudoMessageData?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// True if the [errorType] describes an error related to OMEMO.
|
||||
bool get isOmemoError => [
|
||||
MessageErrorType.notEncryptedForDevice,
|
||||
MessageErrorType.invalidHMAC,
|
||||
MessageErrorType.noDecryptionKey,
|
||||
MessageErrorType.invalidAffixElements,
|
||||
MessageErrorType.failedToEncrypt,
|
||||
MessageErrorType.failedToDecryptFile,
|
||||
MessageErrorType.omemoNotSupported,
|
||||
MessageErrorType.failedToEncryptFile,
|
||||
].contains(errorType);
|
||||
|
||||
/// Returns true if the message is an error. If not, then returns false.
|
||||
bool get hasError => errorType != null && errorType != noError;
|
||||
bool get hasError =>
|
||||
errorType != null && errorType != MessageErrorType.noError;
|
||||
|
||||
/// Returns true if the message is a warning. If not, then returns false.
|
||||
bool get hasWarning => warningType != null && warningType != noWarning;
|
||||
bool get hasWarning =>
|
||||
warningType != null && warningType != MessageWarningType.noWarning;
|
||||
|
||||
/// Returns a representative emoji for a message. Its primary purpose is
|
||||
/// to provide a universal fallback for quoted media messages.
|
||||
@@ -181,11 +273,7 @@ class Message with _$Message {
|
||||
|
||||
/// Returns true if the menu item to show the error should be shown in the
|
||||
/// longpress menu.
|
||||
bool get errorMenuVisible {
|
||||
return hasError &&
|
||||
(errorType! < messageNotEncryptedForDevice ||
|
||||
errorType! > messageInvalidAffixElements);
|
||||
}
|
||||
bool get errorMenuVisible => hasError && !isOmemoError;
|
||||
|
||||
/// Returns true if the message contains media that can be thumbnailed, i.e. videos or
|
||||
/// images.
|
||||
@@ -201,9 +289,12 @@ class Message with _$Message {
|
||||
/// Returns true if the message can be copied to the clipboard.
|
||||
bool get isCopyable => !isMedia && body.isNotEmpty && !isPseudoMessage;
|
||||
|
||||
/// Returns true if the message is a sticker
|
||||
/// Returns true if the message is a sticker.
|
||||
bool get isSticker => isMedia && stickerPackId != null && !isPseudoMessage;
|
||||
|
||||
/// True if the message is a media message
|
||||
/// True if the message is a media message.
|
||||
bool get isMedia => fileMetadata != null;
|
||||
|
||||
/// The JID of the sender in moxxmpp's format.
|
||||
JID get senderJid => JID.fromString(sender);
|
||||
}
|
||||
|
||||
60
lib/shared/models/notification.dart
Normal file
60
lib/shared/models/notification.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:moxxy_native/moxxy_native.dart' as native;
|
||||
|
||||
part 'notification.freezed.dart';
|
||||
part 'notification.g.dart';
|
||||
|
||||
@freezed
|
||||
class Notification with _$Notification {
|
||||
factory Notification(
|
||||
// The notification id.
|
||||
int id,
|
||||
|
||||
// The JID of the conversation the notification belongs to.
|
||||
String conversationJid,
|
||||
|
||||
/// The JID of the account that the conversation belongs to.
|
||||
String accountJid,
|
||||
|
||||
// The sender title.
|
||||
String? sender,
|
||||
|
||||
// The JID of the sender.
|
||||
String? senderJid,
|
||||
|
||||
// The path to use as the avatar.
|
||||
String? avatarPath,
|
||||
|
||||
// The body text.
|
||||
String body,
|
||||
|
||||
// The optional mime type of the media attachment.
|
||||
String? mime,
|
||||
|
||||
// The optional mime type of the path attachment.
|
||||
String? path,
|
||||
|
||||
// The timestamp of the notification.
|
||||
int timestamp,
|
||||
) = _Notification;
|
||||
|
||||
const Notification._();
|
||||
|
||||
/// JSON
|
||||
factory Notification.fromJson(Map<String, dynamic> json) =>
|
||||
_$NotificationFromJson(json);
|
||||
|
||||
native.NotificationMessage toNotificationMessage() {
|
||||
return native.NotificationMessage(
|
||||
sender: sender,
|
||||
jid: senderJid,
|
||||
avatarPath: avatarPath,
|
||||
content: native.NotificationMessageContent(
|
||||
body: body,
|
||||
mime: mime,
|
||||
path: path,
|
||||
),
|
||||
timestamp: timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,8 @@ class OmemoDevice with _$OmemoDevice {
|
||||
bool trusted,
|
||||
bool verified,
|
||||
bool enabled,
|
||||
int deviceId, {
|
||||
@Default(true) bool hasSessionWith,
|
||||
}) = _OmemoDevice;
|
||||
int deviceId,
|
||||
) = _OmemoDevice;
|
||||
|
||||
/// JSON
|
||||
factory OmemoDevice.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -8,8 +8,15 @@ class Reaction with _$Reaction {
|
||||
factory Reaction(
|
||||
// This is valid in combination with freezed
|
||||
// ignore: invalid_annotation_target
|
||||
@JsonKey(name: 'message_id') int messageId,
|
||||
@JsonKey(name: 'message_id') String messageId,
|
||||
|
||||
// The account JID of the attached message.
|
||||
String accountJid,
|
||||
|
||||
// The sender of the reaction.
|
||||
String senderJid,
|
||||
|
||||
// The emoji reaction.
|
||||
String emoji,
|
||||
) = _Reaction;
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:moxxyv2/service/database/helpers.dart';
|
||||
import 'package:moxxyv2/service/preferences.dart';
|
||||
import 'package:moxxyv2/ui/bloc/preferences_bloc.dart';
|
||||
|
||||
part 'roster.freezed.dart';
|
||||
part 'roster.g.dart';
|
||||
@@ -7,21 +10,39 @@ part 'roster.g.dart';
|
||||
@freezed
|
||||
class RosterItem with _$RosterItem {
|
||||
factory RosterItem(
|
||||
int id,
|
||||
String avatarUrl,
|
||||
// The the JID of the account this roster belongs to.
|
||||
String accountJid,
|
||||
|
||||
// Path to the roster avatar.
|
||||
String avatarPath,
|
||||
|
||||
// The SHA-1 hash of the roster avatar.
|
||||
String avatarHash,
|
||||
|
||||
// The JID of the roster item.
|
||||
String jid,
|
||||
|
||||
// The title of the roster item.
|
||||
String title,
|
||||
|
||||
// The subscription state of the roster item.
|
||||
String subscription,
|
||||
|
||||
// The ask attribute of the roster item.
|
||||
String ask,
|
||||
|
||||
// Indicates whether the "roster item" really exists on the roster and is not just there
|
||||
// for the contact integration
|
||||
bool pseudoRosterItem,
|
||||
|
||||
// A list of groups the roster item is in.
|
||||
List<String> groups, {
|
||||
// The id of the contact in the device's phonebook, if it exists
|
||||
String? contactId,
|
||||
|
||||
// The path to the profile picture of the contact, if it exists
|
||||
String? contactAvatarPath,
|
||||
|
||||
// The contact's display name, if it exists
|
||||
String? contactDisplayName,
|
||||
}) = _RosterItem;
|
||||
@@ -43,7 +64,6 @@ class RosterItem with _$RosterItem {
|
||||
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
final json = toJson()
|
||||
..remove('id')
|
||||
// TODO(PapaTutuWawa): Fix
|
||||
..remove('groups')
|
||||
..remove('pseudoRosterItem');
|
||||
@@ -53,4 +73,51 @@ class RosterItem with _$RosterItem {
|
||||
'pseudoRosterItem': boolToInt(pseudoRosterItem),
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether a conversation with this roster item should display the "Add to roster" button.
|
||||
bool get showAddToRosterButton {
|
||||
// Those chats are not dealt with on the roster
|
||||
if (pseudoRosterItem) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A full presence subscription is already achieved. Nothing to do
|
||||
if (subscription == 'both') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We are not yet waiting for a response to the presence request
|
||||
if (ask == 'subscribe' && ['none', 'from', 'to'].contains(subscription)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The title of the roster item. This returns, if [contactIntegration] is true, first the contact's display
|
||||
/// name, then the XMPP roster title. If [contactIntegration] is false, just returns the XMPP roster
|
||||
/// title.
|
||||
String getTitleWithOptionalContact(bool contactIntegration) {
|
||||
if (contactIntegration) {
|
||||
return contactDisplayName ?? title;
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
|
||||
/// contact integration enablement status extracted from the [PreferencesBloc].
|
||||
/// NOTE: This method only works in the UI.
|
||||
String get titleWithOptionalContact => getTitleWithOptionalContact(
|
||||
GetIt.I.get<PreferencesBloc>().state.enableContactIntegration,
|
||||
);
|
||||
|
||||
/// This getter is a short-hand for [getTitleWithOptionalContact] with the
|
||||
/// contact integration enablement status extracted from the [PreferencesService].
|
||||
/// NOTE: This method only works in the background isolate.
|
||||
Future<String> get titleWithOptionalContactService async =>
|
||||
getTitleWithOptionalContact(
|
||||
(await GetIt.I.get<PreferencesService>().getPreferences())
|
||||
.enableContactIntegration,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ class StickerPack with _$StickerPack {
|
||||
String hashValue,
|
||||
bool restricted,
|
||||
bool local,
|
||||
|
||||
/// The timestamp (milliseconds since epoch) when the sticker pack was added
|
||||
int addedTimestamp,
|
||||
|
||||
/// The size in bytes
|
||||
int size,
|
||||
) = _StickerPack;
|
||||
|
||||
const StickerPack._();
|
||||
@@ -34,6 +40,8 @@ class StickerPack with _$StickerPack {
|
||||
pack.hashValue,
|
||||
pack.restricted,
|
||||
local,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
/// JSON
|
||||
@@ -49,6 +57,7 @@ class StickerPack with _$StickerPack {
|
||||
'local': true,
|
||||
'restricted': intToBool(json['restricted']! as int),
|
||||
'stickers': <Sticker>[],
|
||||
'size': 0,
|
||||
});
|
||||
|
||||
return pack.copyWith(stickers: stickers);
|
||||
@@ -57,7 +66,8 @@ class StickerPack with _$StickerPack {
|
||||
Map<String, dynamic> toDatabaseJson() {
|
||||
final json = toJson()
|
||||
..remove('local')
|
||||
..remove('stickers');
|
||||
..remove('stickers')
|
||||
..remove('size');
|
||||
|
||||
return {
|
||||
...json,
|
||||
|
||||
@@ -5,13 +5,27 @@ import 'package:moxxmpp/moxxmpp.dart';
|
||||
part 'xmpp_state.freezed.dart';
|
||||
part 'xmpp_state.g.dart';
|
||||
|
||||
extension StreamManagementStateToJson on StreamManagementState {
|
||||
Map<String, dynamic> toJson() => {
|
||||
'c2s': c2s,
|
||||
's2c': s2c,
|
||||
'streamResumptionLocation': streamResumptionLocation,
|
||||
'streamResumptionId': streamResumptionId,
|
||||
};
|
||||
}
|
||||
|
||||
class StreamManagementStateConverter
|
||||
implements JsonConverter<StreamManagementState, Map<String, dynamic>> {
|
||||
const StreamManagementStateConverter();
|
||||
|
||||
@override
|
||||
StreamManagementState fromJson(Map<String, dynamic> json) =>
|
||||
StreamManagementState.fromJson(json);
|
||||
StreamManagementState(
|
||||
json['c2s']! as int,
|
||||
json['s2c']! as int,
|
||||
streamResumptionLocation: json['streamResumptionLocation'] as String?,
|
||||
streamResumptionId: json['streamResumptionId'] as String?,
|
||||
);
|
||||
|
||||
@override
|
||||
Map<String, dynamic> toJson(StreamManagementState state) => state.toJson();
|
||||
@@ -27,9 +41,12 @@ class XmppState with _$XmppState {
|
||||
String? displayName,
|
||||
String? password,
|
||||
String? lastRosterVersion,
|
||||
String? fastToken,
|
||||
@Default('') String avatarUrl,
|
||||
@Default('') String avatarHash,
|
||||
@Default(false) bool askedStoragePermission,
|
||||
@Default(false) bool askedNotificationPermission,
|
||||
@Default(false) bool askedBatteryOptimizationExcemption,
|
||||
}) = _XmppState;
|
||||
|
||||
const XmppState._();
|
||||
@@ -54,6 +71,10 @@ class XmppState with _$XmppState {
|
||||
'avatarUrl': tuples['avatarUrl'],
|
||||
'avatarHash': tuples['avatarHash'],
|
||||
'askedStoragePermission': tuples['askedStoragePermission'] == 'true',
|
||||
'askedNotificationPermission':
|
||||
tuples['askedNotificationPermission'] == 'true',
|
||||
'askedBatteryOptimizationExcemption':
|
||||
tuples['askedBatteryOptimizationExcemption'] == 'true',
|
||||
};
|
||||
|
||||
return XmppState.fromJson(json);
|
||||
@@ -62,12 +83,20 @@ class XmppState with _$XmppState {
|
||||
Map<String, String?> toDatabaseTuples() {
|
||||
final json = toJson()
|
||||
..remove('smState')
|
||||
..remove('askedStoragePermission');
|
||||
..remove('askedStoragePermission')
|
||||
..remove('askedNotificationPermission')
|
||||
..remove('askedBatteryOptimizationExcemption');
|
||||
|
||||
return {
|
||||
...json.cast<String, String?>(),
|
||||
'smState': jsonEncode(smState?.toJson()),
|
||||
'askedStoragePermission': askedStoragePermission ? 'true' : 'false',
|
||||
'askedNotificationPermission':
|
||||
askedNotificationPermission ? 'true' : 'false',
|
||||
'askedBatteryOptimizationExcemption':
|
||||
askedBatteryOptimizationExcemption ? 'true' : 'false',
|
||||
};
|
||||
}
|
||||
|
||||
bool get canLogIn => jid != null && password != null;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user