🔀 Commit

Add settings search bar and persistent voice message mini-player
Commit173c727218d283bbbd2d98e09e9da9e52c41232c
AuthorJabJab <noreply@xmpp.tel>
Date2026-09-23
Parent064e8c61
commit 173c727218d283bbbd2d98e09e9da9e52c41232c
Author: JabJab <noreply@xmpp.tel>
Date:   Wed Sep 23 14:57:38 2026 +0300

    Add settings search bar and persistent voice message mini-player
---
 CHANGELOG.md                                       |   10 +
 build.gradle                                       |  244 ++--
 conversations.doap                                 |    6 +-
 gradle.properties                                  |    5 -
 gradle/wrapper/gradle-wrapper.properties           |    4 +-
 gradlew                                            |   11 +-
 gradlew.bat                                        |   36 +-
 libs/annotation-processor/build.gradle             |    2 +-
 .../tmp/compileJava/previous-compilation-data.bin  |  Bin 5625 -> 5628 bytes
 .../e6e567a27d3ffb94b28b3ab3d033768b/results.bin   |    1 +
 .../annotation/annotation_dex/classes.dex          |  Bin 0 -> 1424 bytes
 proguard-rules.pro                                 |   25 +
 .../metadata/android/es-ES/short_description.txt   |    2 +-
 .../metadata/android/lo-LA/full_description.txt    |   28 +
 .../metadata/android/lo-LA/short_description.txt   |    1 +
 .../tel/xmpp/jabjab/ui/ManageAccountActivity.java  |   79 +-
 .../java/tel/xmpp/jabjab/ui/WelcomeActivity.java   |   27 +-
 src/conversations/res/values-hr/strings.xml        |    6 +-
 src/conversations/res/values-lo/strings.xml        |   15 +
 src/main/AndroidManifest.xml                       |    6 +-
 src/main/java/de/gultsch/common/MiniUri.java       |   32 +-
 src/main/java/de/gultsch/common/Patterns.java      |   23 +-
 .../im/conversations/android/json/Services.java    |   51 +
 .../android/xmpp/model/jingle/Content.java         |    2 +
 .../android/xmpp/model/jingle/Jingle.java          |    3 +
 .../xmpp/model/jingle/apps/ft/Description.java     |   11 -
 .../xmpp/model/jingle/apps/ft/FtDescription.java   |   12 +
 .../xmpp/model/jingle/apps/rtp/Parameter.java      |   12 +
 .../xmpp/model/jingle/apps/rtp/PayloadType.java    |   11 +
 .../xmpp/model/jingle/apps/rtp/RtcpMux.java        |   11 +
 .../rtp/{Description.java => RtpDescription.java}  |    9 +-
 .../xmpp/model/jingle/apps/rtp/ssma/Parameter.java |   11 +
 .../xmpp/model/jingle/apps/rtp/ssma/Source.java    |   11 +
 .../model/jingle/apps/rtp/ssma/package-info.java   |    5 +
 .../xmpp/model/jingle/grouping/Content.java        |   11 +
 .../android/xmpp/model/jingle/grouping/Group.java  |   11 +
 .../xmpp/model/jingle/grouping/package-info.java   |    5 +
 .../datachannel/DataChannelTransport.java          |   11 +
 .../jingle/transport/datachannel/package-info.java |    5 +
 .../model/jingle/transport/ibb/IbbTransport.java   |   11 +
 .../model/jingle/transport/ibb/package-info.java   |    5 +
 .../model/jingle/transport/ice/IceCandidate.java   |   12 +
 .../model/jingle/transport/ice/IceTransport.java   |   11 +
 .../model/jingle/transport/ice/package-info.java   |    5 +
 .../model/jingle/transport/s5b/S5bTransport.java   |   11 +
 .../model/jingle/transport/s5b/package-info.java   |    5 +
 .../android/xmpp/model/last/Query.java             |   12 +
 .../android/xmpp/model/last/package-info.java      |    5 +
 .../android/xmpp/model/limits/IdleSeconds.java     |   20 +
 .../android/xmpp/model/limits/Limits.java          |   21 +
 .../android/xmpp/model/limits/MaxBytes.java        |   12 +
 .../android/xmpp/model/limits/package-info.java    |    5 +
 .../conversations/android/xmpp/model/raa/Info.java |   12 +
 .../android/xmpp/model/raa/package-info.java       |    5 +
 .../android/xmpp/model/streams/Features.java       |    5 +
 src/main/java/tel/xmpp/jabjab/Conversations.java   |   15 +-
 .../tel/xmpp/jabjab/crypto/XmppDomainVerifier.java |   31 +-
 .../java/tel/xmpp/jabjab/entities/Account.java     |    4 +-
 .../java/tel/xmpp/jabjab/entities/Contact.java     |  145 ++-
 .../tel/xmpp/jabjab/entities/Conversation.java     |  535 ++++-----
 src/main/java/tel/xmpp/jabjab/entities/Edit.java   |   26 +-
 .../xmpp/jabjab/entities/IndividualMessage.java    |    3 +-
 .../java/tel/xmpp/jabjab/entities/Message.java     |  204 +++-
 .../java/tel/xmpp/jabjab/entities/MucOptions.java  |   31 +-
 .../xmpp/jabjab/generator/MessageGenerator.java    |    2 +-
 .../java/tel/xmpp/jabjab/parser/MessageParser.java |  140 +--
 .../xmpp/jabjab/persistance/DatabaseBackend.java   |   28 +-
 .../tel/xmpp/jabjab/persistance/FileBackend.java   |    3 +
 .../xmpp/jabjab/receiver/SystemEventReceiver.java  |   41 +-
 .../jabjab/services/XmppConnectionService.java     |  109 +-
 .../xmpp/jabjab/ui/ConferenceDetailsActivity.java  |   75 +-
 .../tel/xmpp/jabjab/ui/ContactDetailsActivity.java |  119 +-
 .../tel/xmpp/jabjab/ui/ConversationFragment.java   |  539 ++++-----
 .../jabjab/ui/ConversationsOverviewFragment.java   |   27 +
 .../tel/xmpp/jabjab/ui/EditAccountActivity.java    |  131 ++-
 .../tel/xmpp/jabjab/ui/EditHistoryActivity.java    |  132 +++
 .../tel/xmpp/jabjab/ui/RtpSessionActivity.java     |   17 +-
 .../java/tel/xmpp/jabjab/ui/SearchActivity.java    |   37 +-
 .../java/tel/xmpp/jabjab/ui/ShareWithActivity.java |   16 +-
 .../xmpp/jabjab/ui/StartConversationActivity.java  |  115 +-
 .../java/tel/xmpp/jabjab/ui/TrustKeysActivity.java |   28 +-
 src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java |   40 +-
 .../xmpp/jabjab/ui/activity/SettingsActivity.java  |  189 +++-
 .../jabjab/ui/adapter/ConversationAdapter.java     |   16 +-
 .../tel/xmpp/jabjab/ui/adapter/MessageAdapter.java |   20 +-
 .../ui/adapter/SettingsSearchResultAdapter.java    |   78 ++
 .../settings/AttachmentsSettingsFragment.java      |    1 +
 .../settings/AvailabilitySettingsFragment.java     |    1 +
 .../fragment/settings/BackupSettingsFragment.java  |    1 +
 .../settings/ConnectionSettingsFragment.java       |    1 +
 .../settings/InterfaceBubblesSettingsFragment.java |    1 +
 .../settings/InterfaceSettingsFragment.java        |    1 +
 .../ui/fragment/settings/MainSettingsFragment.java |    4 +-
 .../settings/SecuritySettingsFragment.java         |    1 +
 .../ui/fragment/settings/UpSettingsFragment.java   |    1 +
 .../fragment/settings/XmppPreferenceFragment.java  |    7 +
 .../tel/xmpp/jabjab/ui/service/AudioPlayer.java    |  249 +++-
 .../ui/util/ConversationMenuConfigurator.java      |    3 +-
 .../ui/util/MucDetailsContextMenuHelper.java       |  104 +-
 .../xmpp/jabjab/ui/util/SettingsSearchIndex.java   |  245 ++++
 .../java/tel/xmpp/jabjab/ui/util/StickerStore.java |   60 +-
 .../xmpp/jabjab/ui/widget/AudioMiniPlayerView.java |  182 +++
 .../java/tel/xmpp/jabjab/utils/Compatibility.java  |    4 +-
 src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java |   25 +-
 src/main/java/tel/xmpp/jabjab/utils/IP.java        |   10 -
 src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java |    2 +
 .../tel/xmpp/jabjab/utils/PermissionUtils.java     |   37 +-
 src/main/java/tel/xmpp/jabjab/utils/Resolver.java  |   12 +-
 src/main/java/tel/xmpp/jabjab/xml/Namespace.java   |    3 +
 src/main/java/tel/xmpp/jabjab/xmpp/Jid.java        |    4 +-
 .../java/tel/xmpp/jabjab/xmpp/XmppConnection.java  |   33 +-
 .../tel/xmpp/jabjab/xmpp/mam/MamReference.java     |   45 +-
 .../xmpp/jabjab/xmpp/manager/DisplayedManager.java |    4 +-
 .../jabjab/xmpp/manager/JingleMessageManager.java  |    4 +-
 .../jabjab/xmpp/manager/MessageArchiveManager.java |   24 +-
 .../jabjab/xmpp/manager/MultiUserChatManager.java  |    7 +-
 .../xmpp/jabjab/xmpp/manager/ReactionManager.java  |    9 +-
 .../xmpp/jabjab/xmpp/manager/RosterManager.java    |    3 +-
 .../activity_conversations.xml                     |   19 +-
 src/main/res/layout/actionview_settings_search.xml |   19 +
 src/main/res/layout/activity_blog.xml              |    7 +
 src/main/res/layout/activity_channel_discovery.xml |    7 +
 src/main/res/layout/activity_contact_details.xml   |   10 +-
 src/main/res/layout/activity_conversations.xml     |   25 +-
 src/main/res/layout/activity_settings.xml          |   26 +-
 src/main/res/layout/activity_stories.xml           |    7 +
 .../res/layout/item_settings_search_result.xml     |   32 +
 src/main/res/layout/view_audio_mini_player.xml     |   74 ++
 src/main/res/menu/activity_settings.xml            |   11 +
 src/main/res/menu/message_context.xml              |    5 +
 src/main/res/values-ar/strings.xml                 |   81 ++
 src/main/res/values-bg/strings.xml                 |   22 +
 src/main/res/values-bn-rIN/strings.xml             |   22 +
 src/main/res/values-ca/strings.xml                 |   22 +
 src/main/res/values-cs/strings.xml                 |  188 +++
 src/main/res/values-da-rDK/strings.xml             |   22 +
 src/main/res/values-de/strings.xml                 |   79 +-
 src/main/res/values-el/strings.xml                 |   22 +
 src/main/res/values-eo/strings.xml                 | 1131 +++++++++++++++++-
 src/main/res/values-es/strings.xml                 |  172 ++-
 src/main/res/values-et/strings.xml                 |   24 +-
 src/main/res/values-eu/strings.xml                 |   22 +
 src/main/res/values-fa-rIR/strings.xml             |   22 +
 src/main/res/values-fi/strings.xml                 |   22 +
 src/main/res/values-fil/strings.xml                |   22 +
 src/main/res/values-fr/strings.xml                 |  168 +++
 src/main/res/values-ga/strings.xml                 |   22 +
 src/main/res/values-gl/strings.xml                 |   28 +-
 src/main/res/values-hi-rIN/strings.xml             |   22 +
 src/main/res/values-hr/strings.xml                 |   36 +
 src/main/res/values-hu/strings.xml                 |   22 +
 src/main/res/values-id/strings.xml                 |   22 +
 src/main/res/values-it/strings.xml                 |  168 +++
 src/main/res/values-iw/strings.xml                 |   22 +
 src/main/res/values-ja/strings.xml                 |  165 +++
 src/main/res/values-kab/strings.xml                |   22 +
 src/main/res/values-ko/strings.xml                 |   22 +
 src/main/res/values-lo/strings.xml                 | 1197 ++++++++++++++++++++
 src/main/res/values-ml/strings.xml                 |   22 +
 src/main/res/values-nb-rNO/strings.xml             |   22 +
 src/main/res/values-nl/strings.xml                 |  182 +++
 src/main/res/values-pl/strings.xml                 |  196 +++-
 src/main/res/values-pt-rBR/strings.xml             |  168 +++
 src/main/res/values-pt/strings.xml                 |  182 +++
 src/main/res/values-ro-rRO/strings.xml             |  187 ++-
 src/main/res/values-ru/strings.xml                 |  176 ++-
 src/main/res/values-sk/strings.xml                 |   22 +
 src/main/res/values-sq-rAL/strings.xml             |   22 +
 src/main/res/values-sr/strings.xml                 |   22 +
 src/main/res/values-sv/strings.xml                 |  184 ++-
 src/main/res/values-szl/strings.xml                |   22 +
 src/main/res/values-th/strings.xml                 |   22 +
 src/main/res/values-tr-rTR/strings.xml             |  400 +++++++
 src/main/res/values-tr/strings.xml                 |  270 +++++
 src/main/res/values-uk/strings.xml                 |   24 +-
 src/main/res/values-vi/strings.xml                 |   78 +-
 src/main/res/values-zh-rCN/strings.xml             |  169 ++-
 src/main/res/values-zh-rTW/strings.xml             |  179 +++
 src/main/res/values/strings.xml                    |   12 +-
 src/main/res/xml/preferences_interface.xml         |    1 +
 src/main/res/xml/preferences_main.xml              |    6 +
 .../metadata/android/es-ES/full_description.txt    |   13 +-
 .../metadata/android/es-ES/short_description.txt   |    2 +-
 .../metadata/android/lo-LA/full_description.txt    |   14 +
 .../metadata/android/lo-LA/short_description.txt   |    1 +
 src/quicksy/res/values-eo/strings.xml              |    3 +-
 src/quicksy/res/values-es/strings.xml              |    2 +-
 src/quicksy/res/values-hr/strings.xml              |    7 +-
 src/quicksy/res/values-lo/strings.xml              |   13 +
 src/quicksy/res/values-vi/strings.xml              |    2 +-
 src/test/java/de/gultsch/common/MiniUriTest.java   |   29 +
 src/test/java/tel/xmpp/jabjab/xmpp/JidTest.java    |   20 +
 192 files changed, 9809 insertions(+), 1792 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 53ce8a8..1699ee0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,15 @@
 # Changelog
 
+### Version 2.20.3
+
+* Request permission when XMPP server is on local network
+
+### Version 2.20.2
+
+* Allow editing of all sent messages; Not just last. Show edit history
+* Fix images not showing in notification
+* Fix some geo URIs not rendering correctly
+
 ### Version 2.20.1
 
 * Add 'Note to self' as search suggestion
diff --git a/build.gradle b/build.gradle
index c1bdbc0..461311e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,5 +1,3 @@
-// Top-level build file where you can add configuration options common to all
-// sub-projects/modules.
 buildscript {
     repositories {
         google()
@@ -7,7 +5,7 @@ buildscript {
     }
     dependencies {
         classpath 'com.android.tools.build:gradle:9.2.1'
-        classpath 'com.diffplug.spotless:spotless-plugin-gradle:8.6.0'
+        classpath 'com.diffplug.spotless:spotless-plugin-gradle:8.10.2'
     }
 }
 
@@ -17,19 +15,7 @@ apply plugin: 'com.diffplug.spotless'
 repositories {
     google()
     mavenCentral()
-    maven { url='https://jitpack.io'}
-}
-
-configurations {
-    playstoreImplementation
-    freeImplementation
-    conversationsFreeImplementation
-    conversationsPlaystorImplementation
-    conversationsPlaystoreImplementation
-    quicksyPlaystoreImplementation
-    quicksyPlaystoreImplementation
-    quicksyFreeImplementation
-    quicksyImplementation
+    maven { url = 'https://jitpack.io' }
 }
 
 spotless {
@@ -40,6 +26,18 @@ spotless {
     }
 }
 
+def abiCodes = ['armeabi-v7a': 1, 'x86': 2, 'x86_64': 3, 'arm64-v8a': 4]
+
+configurations.named('implementation') {
+    exclude group: 'org.jetbrains', module: 'annotations'
+}
+
+configurations {
+    freeImplementation {}
+    conversationsPlaystoreImplementation {}
+    quicksyImplementation {}
+    quicksyPlaystoreImplementation {}
+}
 
 dependencies {
     coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'
@@ -48,7 +46,7 @@ dependencies {
     implementation 'org.immutables:value-annotations:2.12.2'
     annotationProcessor project(':libs:annotation-processor')
     implementation project(':libs:annotation')
-    implementation 'androidx.appcompat:appcompat:1.7.1'
+    implementation 'androidx.appcompat:appcompat:1.8.0'
     implementation 'androidx.cardview:cardview:1.0.0'
     implementation 'androidx.concurrent:concurrent-futures:1.3.0'
     implementation 'androidx.documentfile:documentfile:1.1.0'
@@ -65,41 +63,35 @@ dependencies {
     implementation 'androidx.work:work-runtime:2.11.2'
     implementation 'com.github.open-keychain.open-keychain:openpgp-api:v5.7.1'
     implementation 'com.google.android.material:material:1.14.0'
-    implementation 'com.google.guava:guava:33.6.0-android'
+    implementation 'com.google.guava:guava:33.7.1-android'
     implementation 'com.google.zxing:core:3.5.4'
     implementation 'com.leinardi.android:speed-dial:3.3.0'
-    implementation 'com.squareup.okhttp3:okhttp:5.3.2'
+    implementation 'com.squareup.okhttp3:okhttp:5.5.0'
     implementation 'com.squareup.retrofit2:converter-gson:3.0.0'
     implementation 'com.squareup.retrofit2:retrofit:3.0.0'
     implementation 'com.vanniktech:android-image-cropper:4.7.0'
-    implementation 'im.conversations.webrtc:webrtc-android:129.0.0'
+    implementation 'im.conversations.webrtc:webrtc-android:149.0.0'
     implementation 'io.deepmedia.community:transcoder-android:0.11.2'
     implementation 'me.leolin:ShortcutBadger:1.1.22@aar'
     //noinspection NewerVersionAvailable
     implementation 'net.fellbaum:jemoji:1.7.6' //1.7.6 is the latest with Java 8 support
-    implementation 'org.bouncycastle:bcmail-jdk18on:1.84'
-    implementation 'org.conscrypt:conscrypt-android:2.5.3'
-    implementation 'org.hsluv:hsluv:1.0'
+    implementation 'org.bouncycastle:bcmail-jdk18on:1.86'
+    implementation 'org.conscrypt:conscrypt-android:2.7.0'
+    implementation 'org.hsluv:hsluv:1.0.2'
     implementation 'org.jxmpp:jxmpp-jid:1.1.0'
     implementation 'org.jxmpp:jxmpp-stringprep-libidn:1.1.0'
     implementation 'org.minidns:minidns-client:1.1.1'
     implementation 'org.minidns:minidns-dnssec:1.1.1'
     implementation 'org.osmdroid:osmdroid-android:6.1.20'
+    //noinspection NewerVersionAvailable
     implementation 'org.whispersystems:signal-protocol-java:2.6.2'
     conversationsPlaystoreImplementation 'com.android.installreferrer:installreferrer:2.2'
-    quicksyImplementation 'io.michaelrocks:libphonenumber-android:9.0.31'
-
-    quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.3.0'
+    quicksyImplementation 'io.michaelrocks:libphonenumber-android:9.0.38'
+    quicksyPlaystoreImplementation 'com.google.android.gms:play-services-auth-api-phone:18.3.1'
 
     testImplementation 'junit:junit:4.13.2'
-    testImplementation 'org.robolectric:robolectric:4.16.1'
+    testImplementation 'org.robolectric:robolectric:4.17'
     androidTestImplementation 'androidx.test.ext:junit:1.3.0'
-
-}
-
-ext {
-    preDexEnabled = System.getProperty("pre-dex", "true")
-    abiCodes = ['armeabi-v7a': 1, 'x86': 2, 'x86_64': 3, 'arm64-v8a': 4]
 }
 
 // Load local.properties so GIPHY_API_KEY etc. don't need to be passed on the command line
@@ -108,23 +100,23 @@ def localPropsFile = rootProject.file('local.properties')
 if (localPropsFile.exists()) localProps.load(new FileInputStream(localPropsFile))
 
 android {
-    namespace 'tel.xmpp.jabjab'
-    compileSdk = 36
+    namespace = 'tel.xmpp.jabjab'
+    compileSdk = 37
 
     defaultConfig {
-        minSdkVersion 23
-        versionCode 42314
-        versionName "1.0.7"
-        applicationId "tel.xmpp.jabjab"
-        resValue "string", "applicationId", applicationId
+        minSdk = 23
+        versionCode = 42315
+        versionName = '1.0.8'
+        applicationId = 'tel.xmpp.jabjab'
+        resValue('string', 'applicationId', applicationId)
         def appName = "JabJab"
-        resValue "string", "app_name", appName
-        buildConfigField "String", "APP_NAME", "\"$appName\""
+        resValue('string', 'app_name', appName)
+        buildConfigField('String', 'APP_NAME', "\"${appName}\"")
         // Giphy GIF search — override with GIPHY_API_KEY gradle property
         def giphyKey = localProps.getProperty('GIPHY_API_KEY') ?: project.findProperty('GIPHY_API_KEY') ?: ""
-        buildConfigField "String", "GIPHY_API_KEY", "\"${giphyKey}\""
+        buildConfigField('String', 'GIPHY_API_KEY', "\"${giphyKey}\"")
         def updateUrl = project.findProperty('UPDATE_SERVER_URL') ?: ""
-        buildConfigField "String", "UPDATE_SERVER_URL", "\"${updateUrl}\""
+        buildConfigField('String', 'UPDATE_SERVER_URL', "\"${updateUrl}\"")
         // Conversations upstream version — read from conversations.doap at build time
         def conversationsVersion = "unknown"
         def doapFile = file("conversations.doap")
@@ -132,7 +124,7 @@ android {
             def m = doapFile.text =~ /<revision>([^<]+)<\/revision>/
             if (m.find()) conversationsVersion = m.group(1).trim()
         }
-        buildConfigField "String", "CONVERSATIONS_BASE_VERSION", "\"${conversationsVersion}\""
+        buildConfigField('String', 'CONVERSATIONS_BASE_VERSION', "\"${conversationsVersion}\"")
         base {
             archivesName = "jabjab-${android.defaultConfig.versionName}-${android.defaultConfig.versionCode}"
         }
@@ -140,108 +132,73 @@ android {
 
     splits {
         abi {
-            universalApk true
-            enable true
+            universalApk = true
+            enable = true
             reset()
             //noinspection ChromeOsAbiSupport
-            include project.ext.abiCodes.keySet() as String[]
+            include(abiCodes.keySet() as String[])
         }
     }
 
-    configurations {
-        implementation.exclude group: 'org.jetbrains' , module:'annotations'
-    }
-
-    dataBinding {
-        enabled true
-    }
-
     compileOptions {
-        coreLibraryDesugaringEnabled true
-        sourceCompatibility JavaVersion.VERSION_21
-        targetCompatibility JavaVersion.VERSION_21
+        coreLibraryDesugaringEnabled = true
+        sourceCompatibility = JavaVersion.VERSION_21
+        targetCompatibility = JavaVersion.VERSION_21
     }
 
-    flavorDimensions += "mode"
-    flavorDimensions += "distribution"
+    flavorDimensions += 'mode'
+    flavorDimensions += 'distribution'
 
     productFlavors {
-
         quicksy {
-            dimension "mode"
-            applicationId = "im.quicksy.client"
-            resValue "string", "applicationId", applicationId
-
+            dimension = 'mode'
+            applicationId = 'im.quicksy.client'
+            resValue('string', 'applicationId', applicationId)
             def appName = "Quicksy"
-            resValue "string", "app_name", appName
-            buildConfigField "String", "APP_NAME", "\"$appName\""
-            buildConfigField "String", "PRIVACY_POLICY", "\"https://quicksy.im/privacy.htm\""
+            resValue('string', 'app_name', appName)
+            buildConfigField('String', 'APP_NAME', "\"${appName}\"")
+            buildConfigField('String', 'PRIVACY_POLICY', '"https://quicksy.im/privacy.htm"')
+
             def propsFile = rootProject.file('quicksy-auth-secret.properties')
+            def key = null
             if (propsFile.exists()) {
                 def props = new Properties()
-                props.load(new FileInputStream(propsFile))
-                def key = props.getProperty('QUICKSY_AUTHENTICATION_SECRET')
-                if (key != null) {
-                    buildConfigField "String", "QUICKSY_AUTHENTICATION_SECRET",  "\"${key}\""
-                } else {
-                    buildConfigField "String", "QUICKSY_AUTHENTICATION_SECRET", "null"
-                }
-            } else {
-                buildConfigField "String", "QUICKSY_AUTHENTICATION_SECRET", "null"
+                propsFile.withInputStream { props.load(it) }
+                key = props.getProperty('QUICKSY_AUTHENTICATION_SECRET')
             }
+            buildConfigField('String', 'QUICKSY_AUTHENTICATION_SECRET', key != null ? "\"${key}\"" : 'null')
         }
 
         conversations {
-            dimension "mode"
-            buildConfigField "String", "PRIVACY_POLICY", "\"https://conversations.im/privacy.html\""
+            dimension = 'mode'
+            buildConfigField('String', 'PRIVACY_POLICY', '"https://conversations.im/privacy.html"')
         }
 
         playstore {
-            dimension "distribution"
+            dimension = 'distribution'
         }
         free {
-            dimension "distribution"
+            dimension = 'distribution'
         }
     }
 
     sourceSets {
         quicksyFree {
-            java {
-                srcDir 'src/quicksyFree/java'
-            }
+            java.srcDir('src/quicksyFree/java')
         }
         quicksyPlaystore {
-            java {
-                srcDir 'src/quicksyPlaystore/java'
-            }
-            res {
-                srcDir 'src/quicksyPlaystore/res'
-            }
+            java.srcDir('src/quicksyPlaystore/java')
+            res.srcDir('src/quicksyPlaystore/res')
         }
         conversationsFree {
-            java {
-                srcDir 'src/conversationsFree/java'
-            }
+            java.srcDir('src/conversationsFree/java')
         }
         conversationsPlaystore {
-            java {
-                srcDir 'src/conversationsPlaystore/java'
-            }
-            res {
-                srcDir 'src/conversationsPlaystore/res'
-            }
+            java.srcDir('src/conversationsPlaystore/java')
+            res.srcDir('src/conversationsPlaystore/res')
         }
     }
 
-    buildTypes {
-        release {
-            shrinkResources true
-            minifyEnabled true
-            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
-        }
-    }
-
-
     def signingPropsFile = ["signing.properties",
                             "${System.getProperty('user.home')}/.keys/android/jabjab/signing.properties"]
             .collect { new File(it) }
@@ -256,42 +213,67 @@ android {
 
         signingConfigs {
             release {
-                storeFile file(props['keystore'])
-                storePassword ksPassword
-                keyAlias props['keystore.alias']
-                keyPassword ksPassword
+                storeFile = file(props['keystore'])
+                storePassword = ksPassword
+                keyAlias = props['keystore.alias']
+                keyPassword = ksPassword
             }
         }
-        buildTypes.release.signingConfig = signingConfigs.release
-        buildTypes.debug.signingConfig  = signingConfigs.release
     }
 
-
-    subprojects {
-
-        afterEvaluate {
-            if (getPlugins().hasPlugin('android') ||
-                    getPlugins().hasPlugin('android-library')) {
-
-                configure(android.lintOptions) {
-                    disable 'AndroidGradlePluginVersion', 'MissingTranslation'
-                }
+    buildTypes {
+        release {
+            shrinkResources = true
+            minifyEnabled = true
+            proguardFiles(getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro')
+            if (signingPropsFile != null) {
+                signingConfig = signingConfigs.getByName('release')
+            }
+        }
+        debug {
+            if (signingPropsFile != null) {
+                signingConfig = signingConfigs.getByName('release')
             }
-
         }
     }
-    packagingOptions {
+
+    packaging {
         resources {
-            excludes += ['META-INF/BCKEY.DSA', 'META-INF/BCKEY.SF', 'META-INF/versions/9/OSGI-INF/MANIFEST.MF']
+            excludes += ['META-INF/BCKEY.DSA', 'META-INF/BCKEY.SF', 'META-INF/LICENSE.md',
+                         'META-INF/versions/9/OSGI-INF/MANIFEST.MF']
         }
     }
+
     lint {
-        disable 'MissingTranslation', 'InvalidPackage', 'AppCompatResource'
+        disable += ['MissingTranslation', 'InvalidPackage', 'AppCompatResource']
     }
+
     buildFeatures {
-        buildConfig true
+        buildConfig = true
         resValues = true
-        aidl true
+        dataBinding = true
+        aidl = true
     }
 
+    dependenciesInfo {
+        includeInApk = false
+        includeInBundle = false
+    }
+}
+
+subprojects {
+    plugins.withId('com.android.library') {
+        android {
+            lint {
+                disable += ['AndroidGradlePluginVersion', 'MissingTranslation']
+            }
+        }
+    }
+    plugins.withId('com.android.application') {
+        android {
+            lint {
+                disable += ['AndroidGradlePluginVersion', 'MissingTranslation']
+            }
+        }
+    }
 }
diff --git a/conversations.doap b/conversations.doap
index ed082d0..a67451b 100644
--- a/conversations.doap
+++ b/conversations.doap
@@ -589,9 +589,9 @@
 
     <release>
         <Version>
-            <revision>2.20.1</revision>
-            <created>2025-02-04</created>
-            <file-release rdf:resource="https://codeberg.org/iNPUTmice/Conversations/archive/2.17.10.tar.gz"/>
+            <revision>2.20.3</revision>
+            <created>2026-09-15</created>
+            <file-release rdf:resource="https://codeberg.org/iNPUTmice/Conversations/archive/2.20.3.tar.gz"/>
         </Version>
     </release>
 </Project>
diff --git a/gradle.properties b/gradle.properties
index 9e68553..8c71158 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,10 +1,5 @@
 android.useAndroidX=true
 android.nonTransitiveRClass=true
-android.nonFinalResIds=false
 org.gradle.jvmargs=-Xmx4096m
-android.enableAppCompileTimeRClass=false
 android.uniquePackageNames=false
-android.dependency.useConstraints=true
 android.r8.strictFullModeForKeepRules=false
-android.r8.optimizedResourceShrinking=false
-android.newDsl=false
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index c61a118..ad7845b 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,9 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
 networkTimeout=10000
+retries=0
+retryBackOffMs=500
 validateDistributionUrl=true
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index 23d15a9..249efbb 100755
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
 #!/bin/sh
 
 #
-# Copyright © 2015-2021 the original authors.
+# Copyright © 2015 the original authors.
 #
 # Licensed under the Apache License, Version 2.0 (the "License");
 # you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@
 
 ##############################################################################
 #
-#   Gradle start up script for POSIX generated by Gradle.
+#   gradlew start up script for POSIX generated by Gradle.
 #
 #   Important for running:
 #
@@ -29,7 +29,7 @@
 #       bash, then to run this script, type that shell name before the whole
 #       command line, like:
 #
-#           ksh Gradle
+#           ksh gradlew
 #
 #       Busybox and similar reduced shells will NOT work, because this script
 #       requires all of these POSIX shell features:
@@ -57,7 +57,7 @@
 #       Darwin, MinGW, and NonStop.
 #
 #   (3) This script is generated from the Groovy template
-#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
 #       within the Gradle project.
 #
 #       You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in                #(
   NONSTOP* )        nonstop=true ;;
 esac
 
-CLASSPATH="\\\"\\\""
 
 
 # Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
 # For Cygwin or MSYS, switch paths to Windows format before running java
 if "$cygwin" || "$msys" ; then
     APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
-    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
 
     JAVACMD=$( cygpath --unix "$JAVACMD" )
 
@@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
 
 set -- \
         "-Dorg.gradle.appname=$APP_BASE_NAME" \
-        -classpath "$CLASSPATH" \
         -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
         "$@"
 
diff --git a/gradlew.bat b/gradlew.bat
index 5eed7ee..8508ef6 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -19,12 +19,12 @@
 @if "%DEBUG%"=="" @echo off
 @rem ##########################################################################
 @rem
-@rem  Gradle startup script for Windows
+@rem  gradlew startup script for Windows
 @rem
 @rem ##########################################################################
 
-@rem Set local scope for the variables with windows NT shell
-if "%OS%"=="Windows_NT" setlocal
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
 
 set DIRNAME=%~dp0
 if "%DIRNAME%"=="" set DIRNAME=.
@@ -51,7 +51,7 @@ echo. 1>&2
 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
 echo location of your Java installation. 1>&2
 
-goto fail
+"%COMSPEC%" /c exit 1
 
 :findJavaFromJavaHome
 set JAVA_HOME=%JAVA_HOME:"=%
@@ -65,30 +65,18 @@ echo. 1>&2
 echo Please set the JAVA_HOME variable in your environment to match the 1>&2
 echo location of your Java installation. 1>&2
 
-goto fail
+"%COMSPEC%" /c exit 1
 
 :execute
 @rem Setup the command line
 
-set CLASSPATH=
 
 
-@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
 
-:end
-@rem End local scope for the variables with windows NT shell
-if %ERRORLEVEL% equ 0 goto mainEnd
-
-:fail
-rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
-rem the _cmd.exe /c_ return code!
-set EXIT_CODE=%ERRORLEVEL%
-if %EXIT_CODE% equ 0 set EXIT_CODE=1
-if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
-exit /b %EXIT_CODE%
-
-:mainEnd
-if "%OS%"=="Windows_NT" endlocal
-
-:omega
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/libs/annotation-processor/build.gradle b/libs/annotation-processor/build.gradle
index 58bdf83..1eec952 100644
--- a/libs/annotation-processor/build.gradle
+++ b/libs/annotation-processor/build.gradle
@@ -15,6 +15,6 @@ dependencies {
 
     annotationProcessor 'com.google.auto.service:auto-service:1.1.1'
     api 'com.google.auto.service:auto-service-annotations:1.1.1'
-    implementation 'com.google.guava:guava:33.5.0-jre'
+    implementation 'com.google.guava:guava:33.7.1-jre'
 
 }
\ No newline at end of file
diff --git a/libs/annotation-processor/build/tmp/compileJava/previous-compilation-data.bin b/libs/annotation-processor/build/tmp/compileJava/previous-compilation-data.bin
index 7e60f85..a6fa332 100644
Binary files a/libs/annotation-processor/build/tmp/compileJava/previous-compilation-data.bin and b/libs/annotation-processor/build/tmp/compileJava/previous-compilation-data.bin differ
diff --git a/libs/annotation/build/.transforms/e6e567a27d3ffb94b28b3ab3d033768b/results.bin b/libs/annotation/build/.transforms/e6e567a27d3ffb94b28b3ab3d033768b/results.bin
new file mode 100644
index 0000000..1a31b12
--- /dev/null
+++ b/libs/annotation/build/.transforms/e6e567a27d3ffb94b28b3ab3d033768b/results.bin
@@ -0,0 +1 @@
+o/annotation
diff --git a/libs/annotation/build/.transforms/e6e567a27d3ffb94b28b3ab3d033768b/transformed/annotation/annotation_dex/classes.dex b/libs/annotation/build/.transforms/e6e567a27d3ffb94b28b3ab3d033768b/transformed/annotation/annotation_dex/classes.dex
new file mode 100644
index 0000000..2af3c83
Binary files /dev/null and b/libs/annotation/build/.transforms/e6e567a27d3ffb94b28b3ab3d033768b/transformed/annotation/annotation_dex/classes.dex differ
diff --git a/proguard-rules.pro b/proguard-rules.pro
index 4c0502b..ff65502 100644
--- a/proguard-rules.pro
+++ b/proguard-rules.pro
@@ -1,4 +1,7 @@
 -dontobfuscate
+-renamesourcefileattribute SourceFile
+-keepattributes SourceFile,LineNumberTable
+
 
 -keep class tel.xmpp.jabjab.**
 -keep class im.conversations.**
@@ -14,6 +17,28 @@
 -keep class org.openintents.openpgp.*
 -keep class org.webrtc.** { *; }
 
+# JNI Zero stuff (used by webrtc; copied from chrome)
+-keep class org.jni_zero.** { *; }
+-dontwarn org.jni_zero.**
+
+-keepclasseswithmembers,allowaccessmodification class ** {
+  @**org.jni_zero.AccessedByNative <fields>;
+}
+-keepclasseswithmembers,includedescriptorclasses,allowaccessmodification,allowoptimization class ** {
+  @**org.jni_zero.CalledByNative <methods>;
+}
+-keepclasseswithmembers,includedescriptorclasses,allowaccessmodification,allowoptimization class ** {
+  @**org.jni_zero.CalledByNativeUnchecked <methods>;
+}
+
+-keepclasseswithmembernames,includedescriptorclasses,allowaccessmodification class ** {
+  native <methods>;
+}
+
+-keepclasseswithmembers class !cr_allowunused,**J.N {
+  public long *_HASH;
+}
+
 -keep class net.fellbaum.jemoji.** { *; }
 -keeppackagenames net.fellbaum.jemoji.**
 -keepdirectories jemoji
diff --git a/src/conversations/fastlane/metadata/android/es-ES/short_description.txt b/src/conversations/fastlane/metadata/android/es-ES/short_description.txt
index 7e26d51..7ed0476 100644
--- a/src/conversations/fastlane/metadata/android/es-ES/short_description.txt
+++ b/src/conversations/fastlane/metadata/android/es-ES/short_description.txt
@@ -1 +1 @@
-Aplicación de mensajería instantánea XMPP cifrada y fácil de usar para tu dispositivo móvil
+Mensajería instantánea XMPP cifrada y fácil de usar para tu dispositivo móvil
diff --git a/src/conversations/fastlane/metadata/android/lo-LA/full_description.txt b/src/conversations/fastlane/metadata/android/lo-LA/full_description.txt
new file mode 100644
index 0000000..b9fa63f
--- /dev/null
+++ b/src/conversations/fastlane/metadata/android/lo-LA/full_description.txt
@@ -0,0 +1,28 @@
+ແອັບລູກຂ່າຍ Jabber/XMPP ແບບຟຣີ ແລະ ເປີດເຜີຍຊອດໂຄ້ດ ສຳລັບ Android. ໃຊ້ງານງ່າຍ, ເຊື່ອຖືໄດ້, ປະຢັດແບັດເຕີຣີ. ມາພ້ອມກັບການຮອງຮັບການສົ່ງຮູບພາບ, ການລົມກຸ່ມ ແລະ ການແບ່ງປັນສະຖານທີ່ໃນຕົວ. ເຂົ້າລະຫັດແບບຕົ້ນທາງຫາປາຍທາງໂດຍມາດຕະຖານ. ເຊື່ອມຕໍ່ກັບທຸກເຊີບເວີ Jabber, XMPP ແລະ Snikket ທີ່ທັນສະໄໝ.
+
+**ຄຸນສົມບັດ**
+• ເຂົ້າລະຫັດແບບຕົ້ນທາງຫາປາຍທາງດ້ວຍ OMEMO ຫຼື OpenPGP
+• ສົ່ງ ແລະ ຮັບຮູບພາບ ລວມທັງໄຟລ໌ປະເພດອື່ນໆ
+• ການໂທດ້ວຍສຽງ ແລະ ວິດີໂອແບບເຂົ້າລະຫັດ (DTLS-SRTP)
+• ການກວດຈັບການແຊກແຊງລະຫວ່າງກາງ (Machine-in-the-middle) ຂັ້ນສູງຜ່ານການຜູກມັດຊ່ອງທາງ (ຕ້ອງເປີດໃຊ້ງານເອງ)
+• ແບ່ງປັນສະຖານທີ່ຂອງທ່ານ
+• ສົ່ງຂໍ້ຄວາມສຽງ
+• ການຕອບໂຕ້ດ້ວຍອີໂມຈິ
+• ມີການແຈ້ງເຕືອນເມື່ອຜູ້ຕິດຕໍ່ຂອງທ່ານອ່ານຂໍ້ຄວາມແລ້ວ
+• ສ່ວນຕິດຕໍ່ຜູ້ໃຊ້ທີ່ໃຊ້ງານງ່າຍ ຕາມແນວທາງການອອກແບບຂອງ Android (Material Design 3 ພ້ອມສີສັນທີ່ປັບປ່ຽນຕາມລະບົບ)
+• ຮູບພາບຜູ້ຕິດຕໍ່ຄວາມລະອຽດສູງ (ອາວາຕ້າ)
+• ຊິ້ງຂໍ້ມູນກັບແອັບລູກຂ່າຍໃນຄອມພິວເຕີ (Gajim ແລະ Dino)
+• ການລົມກຸ່ມແບບສ່ວນຕົວ ແລະ ການປະຊຸມແບບສາທາລະນະ (ຮອງຮັບການຂັ້ນໜ້າໄວ້)
+• ການຈັດການຂໍ້ຄວາມສຳລັບຜູ້ດູແລໃນການປະຊຸມສາທາລະນະ
+• ເຊື່ອມໂຍງກັບລາຍຊື່ຜູ້ຕິດຕໍ່ໃນເຄື່ອງ
+• ໃຊ້ງານໄດ້ຫຼາຍບັນຊີ (ລວມກ່ອງຂໍ້ຄວາມເຂົ້າໄວ້ບ່ອນດຽວ)
+• ສົ່ງການແຈ້ງເຕືອນແບບພຸຊໄປຍັງແອັບອື່ນໆທີ່ຮອງຮັບ ເຊັ່ນ Tusky, Ltt.rs, DAVx⁵ ແລະ Fennec ຜ່ານ UnifiedPush
+• ກິນໄຟແບັດເຕີຣີໜ້ອຍຫຼາຍ
+
+<i><b>ໝາຍເຫດ:</b></i> ບາງຄຸນສົມບັດເຫຼົ່ານີ້ຕ້ອງການການຮອງຮັບຈາກເຊີບເວີ. ເຂົ້າໄປທີ່ລາຍລະອຽດບັນຊີ ແລະ ເລືອກ 'ຂໍ້ມູນເຊີບເວີ' ຈາກເມນູເພີ່ມເຕີມ ເພື່ອເບິ່ງວ່າເຊີບເວີຂອງທ່ານມີສ່ວນເສີມທີ່ຈຳເປັນທັງໝົດຫຼືບໍ່.
+
+**ຫຼັກການອອກແບບ**
+• ມີຄວາມສວຍງາມ ແລະ ໃຊ້ງານງ່າຍທີ່ສຸດເທົ່າທີ່ຈະເປັນໄປໄດ້ ໂດຍບໍ່ຫຼຸດຜ່ອນຄວາມປອດໄພ ຫຼື ຄວາມເປັນສ່ວນຕົວ
+• ອີງຕາມໂປຣໂຕຄໍທີ່ມີຢູ່ແລ້ວ ແລະ ເປັນທີ່ຍອມຮັບ
+• ບໍ່ຈຳເປັນຕ້ອງມີບັນຊີ Google ຫຼື ໂດຍສະເພາະແມ່ນ Firebase Cloud Messaging (FCM)
+• ຂໍການອະນຸຍາດເຂົ້າເຖິງຂໍ້ມູນໜ້ອຍທີ່ສຸດເທົ່າທີ່ຈຳເປັນ
diff --git a/src/conversations/fastlane/metadata/android/lo-LA/short_description.txt b/src/conversations/fastlane/metadata/android/lo-LA/short_description.txt
new file mode 100644
index 0000000..5b4ee60
--- /dev/null
+++ b/src/conversations/fastlane/metadata/android/lo-LA/short_description.txt
@@ -0,0 +1 @@
+ຕົວສົ່ງຂໍ້ຄວາມທັນໃຈ XMPP ທີ່ເຂົ້າລະຫັດ ແລະ ໃຊ້ງານງ່າຍ ສຳລັບອຸປະກອນມືຖືຂອງທ່ານ
diff --git a/src/conversations/java/tel/xmpp/jabjab/ui/ManageAccountActivity.java b/src/conversations/java/tel/xmpp/jabjab/ui/ManageAccountActivity.java
index eb81ed3..723960a 100644
--- a/src/conversations/java/tel/xmpp/jabjab/ui/ManageAccountActivity.java
+++ b/src/conversations/java/tel/xmpp/jabjab/ui/ManageAccountActivity.java
@@ -169,25 +169,25 @@ public class ManageAccountActivity extends XmppActivity
     }
 
     @Override
-    public boolean onContextItemSelected(MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.mgmt_account_publish_avatar:
-                publishAvatar(selectedAccount);
-                return true;
-            case R.id.mgmt_account_disable:
-                disableAccount(selectedAccount);
-                return true;
-            case R.id.mgmt_account_enable:
-                enableAccount(selectedAccount);
-                return true;
-            case R.id.mgmt_account_delete:
-                deleteAccount(selectedAccount);
-                return true;
-            case R.id.mgmt_account_announce_pgp:
-                publishOpenPGPPublicKey(selectedAccount);
-                return true;
-            default:
-                return super.onContextItemSelected(item);
+    public boolean onContextItemSelected(final MenuItem item) {
+        final int itemId = item.getItemId();
+        if (itemId == R.id.mgmt_account_publish_avatar) {
+            publishAvatar(selectedAccount);
+            return true;
+        } else if (itemId == R.id.mgmt_account_disable) {
+            disableAccount(selectedAccount);
+            return true;
+        } else if (itemId == R.id.mgmt_account_enable) {
+            enableAccount(selectedAccount);
+            return true;
+        } else if (itemId == R.id.mgmt_account_delete) {
+            deleteAccount(selectedAccount);
+            return true;
+        } else if (itemId == R.id.mgmt_account_announce_pgp) {
+            publishOpenPGPPublicKey(selectedAccount);
+            return true;
+        } else {
+            return super.onContextItemSelected(item);
         }
     }
 
@@ -202,28 +202,27 @@ public class ManageAccountActivity extends XmppActivity
         if (MenuDoubleTabUtil.shouldIgnoreTap()) {
             return false;
         }
-        switch (item.getItemId()) {
-            case R.id.action_add_account:
-                startActivity(new Intent(this, EditAccountActivity.class));
-                break;
-            case R.id.action_import_backup:
-                if (hasStoragePermission(REQUEST_IMPORT_BACKUP)) {
-                    startActivity(new Intent(this, ImportBackupActivity.class));
-                }
-                break;
-            case R.id.action_disable_all:
-                disableAllAccounts();
-                break;
-            case R.id.action_enable_all:
-                enableAllAccounts();
-                break;
-            case R.id.action_add_account_with_cert:
-                addAccountFromKey();
-                break;
-            default:
-                break;
+        final int itemId = item.getItemId();
+        if (itemId == R.id.action_add_account) {
+            startActivity(new Intent(this, EditAccountActivity.class));
+            return true;
+        } else if (itemId == R.id.action_import_backup) {
+            if (hasStoragePermission(REQUEST_IMPORT_BACKUP)) {
+                startActivity(new Intent(this, ImportBackupActivity.class));
+            }
+            return true;
+        } else if (itemId == R.id.action_disable_all) {
+            disableAllAccounts();
+            return true;
+        } else if (itemId == R.id.action_enable_all) {
+            enableAllAccounts();
+            return true;
+        } else if (itemId == R.id.action_add_account_with_cert) {
+            addAccountFromKey();
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
         }
-        return super.onOptionsItemSelected(item);
     }
 
     @Override
diff --git a/src/conversations/java/tel/xmpp/jabjab/ui/WelcomeActivity.java b/src/conversations/java/tel/xmpp/jabjab/ui/WelcomeActivity.java
index 87966fc..7386cad 100644
--- a/src/conversations/java/tel/xmpp/jabjab/ui/WelcomeActivity.java
+++ b/src/conversations/java/tel/xmpp/jabjab/ui/WelcomeActivity.java
@@ -156,20 +156,21 @@ public class WelcomeActivity extends QrCodeProcessingActivity
 
     @Override
     public boolean onOptionsItemSelected(final MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.action_import_backup:
-                if (hasStoragePermission(REQUEST_IMPORT_BACKUP)) {
-                    startActivity(new Intent(this, ImportBackupActivity.class));
-                }
-                break;
-            case R.id.action_scan_qr_code:
-                requestPermissionAndScanQrCode();
-                break;
-            case R.id.action_add_account_with_cert:
-                addAccountFromKey();
-                break;
+        final int itemId = item.getItemId();
+        if (itemId == R.id.action_import_backup) {
+            if (hasStoragePermission(REQUEST_IMPORT_BACKUP)) {
+                startActivity(new Intent(this, ImportBackupActivity.class));
+            }
+            return true;
+        } else if (itemId == R.id.action_scan_qr_code) {
+            requestPermissionAndScanQrCode();
+            return true;
+        } else if (itemId == R.id.action_add_account_with_cert) {
+            addAccountFromKey();
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
         }
-        return super.onOptionsItemSelected(item);
     }
 
     private void addAccountFromKey() {
diff --git a/src/conversations/res/values-hr/strings.xml b/src/conversations/res/values-hr/strings.xml
index 690417d..f7742bd 100644
--- a/src/conversations/res/values-hr/strings.xml
+++ b/src/conversations/res/values-hr/strings.xml
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <string name="pick_a_server">Odaberite svog XMPP davatelja usluga.</string>
+    <string name="pick_a_server">Odaberite svog XMPP davatelja usluga</string>
     <string name="use_conversations.im">Koristite conversations.im</string>
     <string name="create_new_account">Napravi novi račun</string>
     <string name="do_you_have_an_account">Već imate XMPP račun? To može biti slučaj ako već koristite drugi XMPP klijent ili ste prije koristili Razgovore. Ako niste, možete odmah stvoriti novi XMPP račun.\nSavjet: Neki pružatelji usluga e-pošte također nude XMPP račune.</string>
@@ -12,5 +12,5 @@
     <string name="improperly_formatted_provisioning">Neispravno formatiran kod za dodjelu</string>
     <string name="tap_share_button_send_invite">Dodirnite gumb za dijeljenje kako biste svom kontaktu poslali pozivnicu na %1$s.</string>
     <string name="if_contact_is_nearby_use_qr">Ako je vaš kontakt u blizini, također može skenirati kod u nastavku kako bi prihvatio vašu pozivnicu.</string>
-    <string name="share_invite_with">Podijelite pozivnicu s...</string>
-</resources>
\ No newline at end of file
+    <string name="share_invite_with">Podijelite pozivnicu s…</string>
+</resources>
diff --git a/src/conversations/res/values-lo/strings.xml b/src/conversations/res/values-lo/strings.xml
new file mode 100644
index 0000000..10fc6ab
--- /dev/null
+++ b/src/conversations/res/values-lo/strings.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <string name="pick_a_server">ເລືອກຜູ້ໃຫ້ບໍລິການ XMPP ຂອງທ່ານ</string>
+    <string name="use_conversations.im">ໃຊ້ conversations.im</string>
+    <string name="create_new_account">ສ້າງບັນຊີໃໝ່</string>
+    <string name="do_you_have_an_account">ທ່ານມີບັນຊີ XMPP ແລ້ວບໍ? ນີ້ອາດຈະແມ່ນໃນກໍລະນີທີ່ທ່ານກຳລັງໃຊ້ແອັບ XMPP ອື່ນ ຫຼື ເຄີຍໃຊ້ Conversations ມາກ່ອນ. ຖ້າຍັງບໍ່ມີ ທ່ານສາມາດສ້າງບັນຊີ XMPP ໃໝ່ໄດ້ຕອນນີ້ເລີຍ.\nຄຳແນະນຳ: ຜູ້ໃຫ້ບໍລິການອີເມລບາງແຫ່ງກໍມີບໍລິການບັນຊີ XMPP ເຊັ່ນກັນ.</string>
+    <string name="server_select_text">XMPP ແມ່ນເຄືອຂ່າຍການສົ່ງຂໍ້ຄວາມທັນທີທີ່ເປັນອິດສະຫຼະຈາກຜູ້ໃຫ້ບໍລິການ. ທ່ານສາມາດໃຊ້ແອັບນີ້ກັບເຊີບເວີ XMPP ໃດກໍໄດ້ທີ່ທ່ານເລືອກ.\nແນວໃດກໍຕາມ, ເພື່ອຄວາມສະດວກຂອງທ່ານ ພວກເຮົາໄດ້ເຮັດໃຫ້ການສ້າງບັນຊີໃນ conversations.im ເປັນເລື່ອງງ່າຍ; ເຊິ່ງເປັນຜູ້ໃຫ້ບໍລິການທີ່ເໝາະສົມໂດຍສະເພາະສຳລັບໃຊ້ກັບ Conversations.</string>
+    <string name="magic_create_text_on_x">ທ່ານໄດ້ຮັບຄຳເຊີນໃຫ້ເຂົ້າຮ່ວມ %1$s. ພວກເຮົາຈະແນະນຳທ່ານຜ່ານຂັ້ນຕອນການສ້າງບັນຊີ.\nເມື່ອເລືອກ %1$s ເປັນຜູ້ໃຫ້ບໍລິການ ທ່ານຈະສາມາດຕິດຕໍ່ສື່ສານກັບຜູ້ໃຊ້ຂອງຜູ້ໃຫ້ບໍລິການອື່ນໆໄດ້ ໂດຍການໃຫ້ທີ່ຢູ່ XMPP ເຕັມຂອງທ່ານແກ່ພວກເຂົາ.</string>
+    <string name="magic_create_text_fixed">ທ່ານໄດ້ຮັບຄຳເຊີນໃຫ້ເຂົ້າຮ່ວມ %1$s. ຊື່ຜູ້ໃຊ້ໄດ້ຖືກເລືອກໄວ້ໃຫ້ທ່ານແລ້ວ. ພວກເຮົາຈະແນະນຳທ່ານຜ່ານຂັ້ນຕອນການສ້າງບັນຊີ.\nທ່ານຈະສາມາດຕິດຕໍ່ສື່ສານກັບຜູ້ໃຊ້ຂອງຜູ້ໃຫ້ບໍລິການອື່ນໆໄດ້ ໂດຍການໃຫ້ທີ່ຢູ່ XMPP ເຕັມຂອງທ່ານແກ່ພວກເຂົາ.</string>
+    <string name="your_server_invitation">ຄຳເຊີນເຊີບເວີຂອງທ່ານ</string>
+    <string name="improperly_formatted_provisioning">ລະຫັດການຕັ້ງຄ່າຮູບແບບບໍ່ຖືກຕ້ອງ</string>
+    <string name="tap_share_button_send_invite">ແຕະທີ່ປຸ່ມແບ່ງປັນເພື່ອສົ່ງຄຳເຊີນໃຫ້ຜູ້ຕິດຕໍ່ຂອງທ່ານເຂົ້າຮ່ວມ %1$s.</string>
+    <string name="if_contact_is_nearby_use_qr">ຖ້າຜູ້ຕິດຕໍ່ຂອງທ່ານຢູ່ໃກ້ໆ, ພວກເຂົາກໍສາມາດສະແກນລະຫັດລຸ່ມນີ້ເພື່ອຕອບຮັບຄຳເຊີນຂອງທ່ານ.</string>
+    <string name="share_invite_with">ແບ່ງປັນຄຳເຊີນກັບ…</string>
+</resources>
diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml
index b1ff795..2f126c7 100644
--- a/src/main/AndroidManifest.xml
+++ b/src/main/AndroidManifest.xml
@@ -6,6 +6,7 @@
     <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
     <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
     <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
     <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.WAKE_LOCK" />
     <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -137,7 +138,7 @@
 
         <receiver
             android:name=".receiver.SystemEventReceiver"
-            android:exported="false">
+            android:exported="true">
             <intent-filter>
                 <action android:name="android.intent.action.BOOT_COMPLETED" />
                 <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
@@ -186,6 +187,9 @@
         <activity
             android:name=".ui.SearchActivity"
             android:label="@string/search_messages" />
+        <activity
+            android:name=".ui.EditHistoryActivity"
+            android:label="@string/edit_history" />
         <activity
             android:name=".ui.PinnedMessagesActivity"
             android:label="@string/pinned_messages" />
diff --git a/src/main/java/de/gultsch/common/MiniUri.java b/src/main/java/de/gultsch/common/MiniUri.java
index 0fa1ed0..67f7959 100644
--- a/src/main/java/de/gultsch/common/MiniUri.java
+++ b/src/main/java/de/gultsch/common/MiniUri.java
@@ -75,7 +75,6 @@ public class MiniUri {
             this.path = authorityPathParts.size() == 2 ? authorityPathParts.get(1) : null;
         } else {
             this.authority = null;
-            // TODO path ; style path components from something like geo uri
             this.path = authorityPath;
         }
         if (authorityPathAndQuery.size() == 2) {
@@ -87,7 +86,7 @@ public class MiniUri {
 
     private static char getDelimiter(final String scheme) {
         return switch (scheme) {
-            case "xmpp", "geo" -> ';';
+            case "xmpp" -> ';';
             default -> '&';
         };
     }
@@ -277,12 +276,7 @@ public class MiniUri {
                 throw new IllegalArgumentException("HTTP URI does not match pattern");
             }
             case "mumble" -> asMiniUriIfMatch(Patterns.URI_MUMBLE, uri);
-            case "geo" -> {
-                if (Patterns.URI_GEO.matcher(uri).matches()) {
-                    yield new Geo(uri);
-                }
-                throw new IllegalArgumentException("GEO URI does not match pattern");
-            }
+            case "geo" -> new Geo(uri);
             case "xmpp" -> new Xmpp(uri);
             case "taler" -> asMiniUriIfMatch(Patterns.URI_TALER, uri);
             case "imto" -> new Imto(uri);
@@ -545,11 +539,20 @@ public class MiniUri {
             this.longitude = longitude;
             if (pathComponents.size() >= 2) {
                 this.geoParameters = parseParameters(Iterables.skip(pathComponents, 1));
+                checkGeoParameters(this.geoParameters);
             } else {
                 this.geoParameters = Collections.emptyMap();
             }
         }
 
+        private static void checkGeoParameters(final Map<String, Collection<String>> parameters) {
+            for (final var values : parameters.values()) {
+                if (values.size() != 1 || Strings.isNullOrEmpty(Iterables.getOnlyElement(values))) {
+                    throw new IllegalArgumentException("Invalid geo parameters");
+                }
+            }
+        }
+
         public Geo(final GeoPoint geoPoint) {
             this(geoPoint.getLatitude(), geoPoint.getLongitude());
         }
@@ -586,6 +589,19 @@ public class MiniUri {
             return Optional.fromNullable(Ints.tryParse(z));
         }
 
+        public Optional<String> getLabel() {
+            final var q = this.getParameter("q");
+            if (Strings.isNullOrEmpty(q)) {
+                return Optional.absent();
+            }
+            final int start = q.indexOf('(');
+            final int end = q.lastIndexOf(')');
+            if (start != -1 && end != -1 && end > start) {
+                return Optional.of(q.substring(start + 1, end));
+            }
+            return Optional.absent();
+        }
+
         public Uri asUniversalUri() {
             return asUniversalUri(null);
         }
diff --git a/src/main/java/de/gultsch/common/Patterns.java b/src/main/java/de/gultsch/common/Patterns.java
index 92bf40f..8cff3ec 100644
--- a/src/main/java/de/gultsch/common/Patterns.java
+++ b/src/main/java/de/gultsch/common/Patterns.java
@@ -29,28 +29,7 @@ public class Patterns {
     public static final Pattern URI_HTTP = Pattern.compile("https?://\\S+");
     public static final Pattern URI_MUMBLE = Pattern.compile("mumble://\\S+");
     public static final Pattern URI_WEB_AP = Pattern.compile("web\\+ap://\\S+");
-
-    public static Pattern URI_GEO =
-            Pattern.compile(
-                    "geo:(-?\\d+(?:\\.\\d+)?),(-?\\d+(?:\\.\\d+)?)(?:,-?\\d+(?:\\.\\d+)?)?(?:;crs=[\\w-]+)?(?:;u=\\d+(?:\\.\\d+)?)?(?:;[\\w-]+=(?:[\\w-_.!~*'()]|%[\\da-f][\\da-f])+)*(\\?z=\\d+)?",
-                    Pattern.CASE_INSENSITIVE);
-
-    public static final Pattern IPV4 =
-            Pattern.compile(
-                    "\\A(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
-    public static final Pattern IPV6 =
-            Pattern.compile("\\A(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\\z");
-    public static final Pattern IPV6_HEX4_DECOMPRESSED =
-            Pattern.compile(
-                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)"
-                        + " ::((?:[0-9A-Fa-f]{1,4}:)*)(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
-    public static final Pattern IPV6_6HEX4DEC =
-            Pattern.compile(
-                    "\\A((?:[0-9A-Fa-f]{1,4}:){6,6})(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\z");
-    public static final Pattern IPV6_HEX_COMPRESSED =
-            Pattern.compile(
-                    "\\A((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)\\z");
-
+    public static final Pattern URI_GEO = genericUri(ImmutableList.of("geo"));
     public static final Pattern URI_TALER = Pattern.compile("taler://\\S+");
 
     private static Pattern genericUri(final List<String> schemes) {
diff --git a/src/main/java/im/conversations/android/json/Services.java b/src/main/java/im/conversations/android/json/Services.java
index 8f68fb8..9eee262 100644
--- a/src/main/java/im/conversations/android/json/Services.java
+++ b/src/main/java/im/conversations/android/json/Services.java
@@ -1,5 +1,6 @@
 package im.conversations.android.json;
 
+import com.google.common.primitives.Longs;
 import com.google.gson.Gson;
 import com.google.gson.GsonBuilder;
 import com.google.gson.TypeAdapter;
@@ -8,6 +9,7 @@ import com.google.gson.stream.JsonToken;
 import com.google.gson.stream.JsonWriter;
 import tel.xmpp.jabjab.xmpp.Jid;
 import java.io.IOException;
+import java.time.Instant;
 import okhttp3.HttpUrl;
 
 public class Services {
@@ -19,6 +21,7 @@ public class Services {
                 new GsonBuilder()
                         .registerTypeAdapter(Jid.class, new JidTypeAdapter())
                         .registerTypeAdapter(HttpUrl.class, new HttpUrlTypeAdapter())
+                        .registerTypeAdapter(Instant.class, new InstantTypeAdapter())
                         .create();
     }
 
@@ -68,4 +71,52 @@ public class Services {
             throw new IOException("Unexpected token");
         }
     }
+
+    private static class InstantTypeAdapter extends TypeAdapter<Instant> {
+
+        @Override
+        public void write(final JsonWriter out, final Instant value) throws IOException {
+            if (value == null) {
+                out.nullValue();
+            } else if (value.equals(Instant.MAX)) {
+                out.value(Long.MAX_VALUE);
+            } else if (value.equals(Instant.MIN)) {
+                out.value(Long.MIN_VALUE);
+            } else {
+                try {
+                    out.value(value.toEpochMilli());
+                } catch (final ArithmeticException e) {
+                    out.value(value.isAfter(Instant.EPOCH) ? Long.MAX_VALUE : Long.MIN_VALUE);
+                }
+            }
+        }
+
+        @Override
+        public Instant read(final JsonReader in) throws IOException {
+            final long epochMilli;
+            if (in.peek() == JsonToken.NULL) {
+                in.nextNull();
+                return null;
+            } else if (in.peek() == JsonToken.NUMBER) {
+                final var value = in.nextLong();
+                return Instant.ofEpochMilli(value);
+            } else if (in.peek() == JsonToken.STRING) {
+                final var value = in.nextString();
+                final var asLong = Longs.tryParse(value);
+                if (asLong == null) {
+                    return null;
+                }
+                epochMilli = asLong;
+            } else {
+                throw new IOException("Unexpected token");
+            }
+            if (epochMilli == Long.MAX_VALUE) {
+                return Instant.MAX;
+            } else if (epochMilli == Long.MIN_VALUE) {
+                return Instant.MIN;
+            } else {
+                return Instant.ofEpochMilli(epochMilli);
+            }
+        }
+    }
 }
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/Content.java b/src/main/java/im/conversations/android/xmpp/model/jingle/Content.java
index f15c830..93698ad 100644
--- a/src/main/java/im/conversations/android/xmpp/model/jingle/Content.java
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/Content.java
@@ -1,7 +1,9 @@
 package im.conversations.android.xmpp.model.jingle;
 
+import im.conversations.android.annotation.XmlElement;
 import im.conversations.android.xmpp.model.Extension;
 
+@XmlElement
 public class Content extends Extension {
 
     public Content() {
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/Jingle.java b/src/main/java/im/conversations/android/xmpp/model/jingle/Jingle.java
index f4dc7d7..ab3f3a0 100644
--- a/src/main/java/im/conversations/android/xmpp/model/jingle/Jingle.java
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/Jingle.java
@@ -89,6 +89,9 @@ public class Jingle extends Extension {
         this.addChild(content);
     }
 
+    // TODO instead of returning tel.xmpp.jabjab.xmpp.jingle.stanzas.Content return real
+    // Content
+
     public Map<String, Content> getJingleContents() {
         ImmutableMap.Builder<String, Content> builder = new ImmutableMap.Builder<>();
         for (final Element child : this.getChildren()) {
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/ft/Description.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/ft/Description.java
deleted file mode 100644
index 3514b45..0000000
--- a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/ft/Description.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package im.conversations.android.xmpp.model.jingle.apps.ft;
-
-import im.conversations.android.annotation.XmlElement;
-
-@XmlElement
-public class Description extends im.conversations.android.xmpp.model.jingle.Description {
-
-    public Description() {
-        super(Description.class);
-    }
-}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/ft/FtDescription.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/ft/FtDescription.java
new file mode 100644
index 0000000..362bae7
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/ft/FtDescription.java
@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.jingle.apps.ft;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.jingle.Description;
+
+@XmlElement(name = "description")
+public class FtDescription extends Description {
+
+    public FtDescription() {
+        super(FtDescription.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/Parameter.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/Parameter.java
new file mode 100644
index 0000000..050bc0c
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/Parameter.java
@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.jingle.apps.rtp;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Parameter extends Extension {
+
+    public Parameter() {
+        super(Parameter.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/PayloadType.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/PayloadType.java
new file mode 100644
index 0000000..31a19a0
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/PayloadType.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.apps.rtp;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class PayloadType extends Extension {
+    public PayloadType() {
+        super(PayloadType.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/RtcpMux.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/RtcpMux.java
new file mode 100644
index 0000000..71c73b5
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/RtcpMux.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.apps.rtp;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class RtcpMux extends Extension {
+    public RtcpMux() {
+        super(RtcpMux.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/Description.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/RtpDescription.java
similarity index 55%
rename from src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/Description.java
rename to src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/RtpDescription.java
index fb70722..5bbe56d 100644
--- a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/Description.java
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/RtpDescription.java
@@ -2,11 +2,12 @@ package im.conversations.android.xmpp.model.jingle.apps.rtp;
 
 import tel.xmpp.jabjab.xmpp.jingle.Media;
 import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.jingle.Description;
 
-@XmlElement
-public class Description extends im.conversations.android.xmpp.model.jingle.Description {
-    public Description() {
-        super(Description.class);
+@XmlElement(name = "description")
+public class RtpDescription extends Description {
+    public RtpDescription() {
+        super(RtpDescription.class);
     }
 
     public void setMedia(final Media media) {
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/Parameter.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/Parameter.java
new file mode 100644
index 0000000..55a2d77
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/Parameter.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.apps.rtp.ssma;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Parameter extends Extension {
+    public Parameter() {
+        super(Parameter.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/Source.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/Source.java
new file mode 100644
index 0000000..87416d0
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/Source.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.apps.rtp.ssma;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Source extends Extension {
+    public Source() {
+        super(Source.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/package-info.java b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/package-info.java
new file mode 100644
index 0000000..a78a5a6
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/apps/rtp/ssma/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.JINGLE_RTP_SOURCE_SPECIFIC_MEDIA_ATTRIBUTES)
+package im.conversations.android.xmpp.model.jingle.apps.rtp.ssma;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/Content.java b/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/Content.java
new file mode 100644
index 0000000..eca685b
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/Content.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.grouping;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Content extends Extension {
+    public Content() {
+        super(Content.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/Group.java b/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/Group.java
new file mode 100644
index 0000000..e121946
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/Group.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.grouping;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Group extends Extension {
+    public Group() {
+        super(Group.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/package-info.java b/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/package-info.java
new file mode 100644
index 0000000..8a0d2eb
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/grouping/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.JINGLE_APPS_GROUPING)
+package im.conversations.android.xmpp.model.jingle.grouping;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/datachannel/DataChannelTransport.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/datachannel/DataChannelTransport.java
new file mode 100644
index 0000000..836299c
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/datachannel/DataChannelTransport.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.transport.datachannel;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.jingle.Transport;
+
+@XmlElement(name = "transport")
+public class DataChannelTransport extends Transport {
+    public DataChannelTransport() {
+        super(DataChannelTransport.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/datachannel/package-info.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/datachannel/package-info.java
new file mode 100644
index 0000000..68389ae
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/datachannel/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.JINGLE_TRANSPORT_WEBRTC_DATA_CHANNEL)
+package im.conversations.android.xmpp.model.jingle.transport.datachannel;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ibb/IbbTransport.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ibb/IbbTransport.java
new file mode 100644
index 0000000..caac4cb
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ibb/IbbTransport.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.transport.ibb;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.jingle.Transport;
+
+@XmlElement(name = "transport")
+public class IbbTransport extends Transport {
+    public IbbTransport() {
+        super(IbbTransport.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ibb/package-info.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ibb/package-info.java
new file mode 100644
index 0000000..342004b
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ibb/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.JINGLE_TRANSPORTS_IBB)
+package im.conversations.android.xmpp.model.jingle.transport.ibb;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/IceCandidate.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/IceCandidate.java
new file mode 100644
index 0000000..ce6a5bc
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/IceCandidate.java
@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.jingle.transport.ice;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement(name = "candidate")
+public class IceCandidate extends Extension {
+
+    public IceCandidate() {
+        super(IceCandidate.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/IceTransport.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/IceTransport.java
new file mode 100644
index 0000000..a555c6f
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/IceTransport.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.transport.ice;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.jingle.Transport;
+
+@XmlElement(name = "transport")
+public class IceTransport extends Transport {
+    public IceTransport() {
+        super(IceTransport.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/package-info.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/package-info.java
new file mode 100644
index 0000000..2802ce2
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/ice/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.JINGLE_TRANSPORT_ICE_UDP)
+package im.conversations.android.xmpp.model.jingle.transport.ice;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/s5b/S5bTransport.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/s5b/S5bTransport.java
new file mode 100644
index 0000000..74e596e
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/s5b/S5bTransport.java
@@ -0,0 +1,11 @@
+package im.conversations.android.xmpp.model.jingle.transport.s5b;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.jingle.Transport;
+
+@XmlElement(name = "transport")
+public class S5bTransport extends Transport {
+    public S5bTransport() {
+        super(S5bTransport.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/jingle/transport/s5b/package-info.java b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/s5b/package-info.java
new file mode 100644
index 0000000..f268b36
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/jingle/transport/s5b/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.JINGLE_TRANSPORTS_S5B)
+package im.conversations.android.xmpp.model.jingle.transport.s5b;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/last/Query.java b/src/main/java/im/conversations/android/xmpp/model/last/Query.java
new file mode 100644
index 0000000..c554bf4
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/last/Query.java
@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.last;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Query extends Extension {
+
+    public Query() {
+        super(Query.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/last/package-info.java b/src/main/java/im/conversations/android/xmpp/model/last/package-info.java
new file mode 100644
index 0000000..9055f4c
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/last/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.LAST)
+package im.conversations.android.xmpp.model.last;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/limits/IdleSeconds.java b/src/main/java/im/conversations/android/xmpp/model/limits/IdleSeconds.java
new file mode 100644
index 0000000..edd1fe4
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/limits/IdleSeconds.java
@@ -0,0 +1,20 @@
+package im.conversations.android.xmpp.model.limits;
+
+import com.google.common.primitives.Ints;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+import java.time.Duration;
+
+@XmlElement
+public class IdleSeconds extends Extension {
+
+    public IdleSeconds() {
+        super(IdleSeconds.class);
+    }
+
+    public Duration asDuration() {
+        final var content = getContent();
+        final var seconds = content == null ? null : Ints.tryParse(content);
+        return seconds == null ? null : Duration.ofSeconds(seconds);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/limits/Limits.java b/src/main/java/im/conversations/android/xmpp/model/limits/Limits.java
new file mode 100644
index 0000000..b240073
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/limits/Limits.java
@@ -0,0 +1,21 @@
+package im.conversations.android.xmpp.model.limits;
+
+import com.google.common.base.Optional;
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.StreamFeature;
+import java.time.Duration;
+
+@XmlElement
+public class Limits extends StreamFeature {
+    public Limits() {
+        super(Limits.class);
+    }
+
+    public Optional<Duration> getIdleSeconds() {
+        final var idleSeconds = this.getOnlyExtension(IdleSeconds.class);
+        if (idleSeconds == null) {
+            return Optional.absent();
+        }
+        return Optional.fromNullable(idleSeconds.asDuration());
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/limits/MaxBytes.java b/src/main/java/im/conversations/android/xmpp/model/limits/MaxBytes.java
new file mode 100644
index 0000000..1a0c599
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/limits/MaxBytes.java
@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.limits;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class MaxBytes extends Extension {
+
+    public MaxBytes() {
+        super(MaxBytes.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/limits/package-info.java b/src/main/java/im/conversations/android/xmpp/model/limits/package-info.java
new file mode 100644
index 0000000..6cf484d
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/limits/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.LIMITS)
+package im.conversations.android.xmpp.model.limits;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/raa/Info.java b/src/main/java/im/conversations/android/xmpp/model/raa/Info.java
new file mode 100644
index 0000000..6a284fe
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/raa/Info.java
@@ -0,0 +1,12 @@
+package im.conversations.android.xmpp.model.raa;
+
+import im.conversations.android.annotation.XmlElement;
+import im.conversations.android.xmpp.model.Extension;
+
+@XmlElement
+public class Info extends Extension {
+
+    public Info() {
+        super(Info.class);
+    }
+}
diff --git a/src/main/java/im/conversations/android/xmpp/model/raa/package-info.java b/src/main/java/im/conversations/android/xmpp/model/raa/package-info.java
new file mode 100644
index 0000000..bfa045e
--- /dev/null
+++ b/src/main/java/im/conversations/android/xmpp/model/raa/package-info.java
@@ -0,0 +1,5 @@
+@XmlPackage(namespace = Namespace.REPORTING_ACCOUNT_AFFILIATIONS)
+package im.conversations.android.xmpp.model.raa;
+
+import tel.xmpp.jabjab.xml.Namespace;
+import im.conversations.android.annotation.XmlPackage;
diff --git a/src/main/java/im/conversations/android/xmpp/model/streams/Features.java b/src/main/java/im/conversations/android/xmpp/model/streams/Features.java
index 3e80a47..bde3dd3 100644
--- a/src/main/java/im/conversations/android/xmpp/model/streams/Features.java
+++ b/src/main/java/im/conversations/android/xmpp/model/streams/Features.java
@@ -6,6 +6,7 @@ import im.conversations.android.xmpp.model.StreamFeature;
 import im.conversations.android.xmpp.model.bind.Bind;
 import im.conversations.android.xmpp.model.capabilties.EntityCapabilities;
 import im.conversations.android.xmpp.model.csi.ClientStateIndication;
+import im.conversations.android.xmpp.model.limits.Limits;
 import im.conversations.android.xmpp.model.register.RegisterStreamFeature;
 import im.conversations.android.xmpp.model.roster.PreApproval;
 import im.conversations.android.xmpp.model.roster.Versioning;
@@ -47,6 +48,10 @@ public class Features extends StreamElement implements EntityCapabilities {
         return hasExtension(clazz);
     }
 
+    public boolean limits() {
+        return hasStreamFeature(Limits.class);
+    }
+
     public boolean session() {
         final var session = getExtension(Session.class);
         return session != null && !session.isOptional();
diff --git a/src/main/java/tel/xmpp/jabjab/Conversations.java b/src/main/java/tel/xmpp/jabjab/Conversations.java
index 99f10c5..d2fc814 100644
--- a/src/main/java/tel/xmpp/jabjab/Conversations.java
+++ b/src/main/java/tel/xmpp/jabjab/Conversations.java
@@ -17,6 +17,7 @@ import com.google.common.collect.Iterables;
 import tel.xmpp.jabjab.persistance.DatabaseBackend;
 import tel.xmpp.jabjab.services.EmojiInitializationService;
 import tel.xmpp.jabjab.ui.UnlockActivity;
+import tel.xmpp.jabjab.ui.service.AudioPlayer;
 import tel.xmpp.jabjab.ui.util.AppLockManager;
 import tel.xmpp.jabjab.ui.util.SettingsUtils;
 import tel.xmpp.jabjab.ui.util.UpdateChecker;
@@ -147,8 +148,18 @@ public class Conversations extends Application {
                     @Override
                     public void onActivityStopped(final Activity activity) {
                         started--;
-                        if (started == 0 && lockManager.isEnabled()) {
-                            lockManager.lock();
+                        if (started == 0) {
+                            if (lockManager.isEnabled()) {
+                                lockManager.lock();
+                            }
+                            // The whole app (every Activity, not just this one screen) just left
+                            // the foreground — pause (not stop) any playing voice note here
+                            // rather than in ConversationFragment.onStop(), so playback survives
+                            // switching chats/tabs/screens within JabJab, and reopening the app
+                            // brings the mini-player right back, paused, where it left off
+                            // instead of losing the track. Actually playing while backgrounded
+                            // (a foreground-service notification) is a later step, not v1.
+                            AudioPlayer.onAppBackgrounded();
                         }
                     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/crypto/XmppDomainVerifier.java b/src/main/java/tel/xmpp/jabjab/crypto/XmppDomainVerifier.java
index e2ef85f..4db47f7 100644
--- a/src/main/java/tel/xmpp/jabjab/crypto/XmppDomainVerifier.java
+++ b/src/main/java/tel/xmpp/jabjab/crypto/XmppDomainVerifier.java
@@ -6,9 +6,10 @@ import androidx.annotation.NonNull;
 import com.google.common.base.CharMatcher;
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.ImmutableSet;
-import tel.xmpp.jabjab.utils.IP;
+import com.google.common.net.InetAddresses;
 import java.io.IOException;
 import java.net.IDN;
+import java.net.InetAddress;
 import java.security.cert.Certificate;
 import java.security.cert.CertificateEncodingException;
 import java.security.cert.CertificateParsingException;
@@ -123,14 +124,18 @@ public class XmppDomainVerifier {
         try {
             final ValidDomains validDomains = parseValidDomains(certificate);
             Log.d(LOGTAG, "searching for " + domain + " in " + validDomains);
-            if (hostname != null) {
-                Log.d(LOGTAG, "also trying to verify hostname " + hostname);
+            if (InetAddresses.isUriInetAddress(domain)) {
+                Log.d(LOGTAG, "checking for ip in " + validDomains.ipAddresses);
+                return validDomains.ipAddresses.contains(InetAddresses.forUriString(domain));
+            } else {
+                if (hostname != null) {
+                    Log.d(LOGTAG, "also trying to verify hostname " + hostname);
+                }
+                return validDomains.xmppAddresses.contains(domain)
+                        || validDomains.srvNames.contains("_xmpp-client." + domain)
+                        || matchDomain(domain, validDomains.domains)
+                        || (hostname != null && matchDomain(hostname, validDomains.domains));
             }
-            return validDomains.xmppAddresses.contains(domain)
-                    || (IP.matches(domain) && validDomains.ipAddresses.contains(domain))
-                    || validDomains.srvNames.contains("_xmpp-client." + domain)
-                    || matchDomain(domain, validDomains.domains)
-                    || (hostname != null && matchDomain(hostname, validDomains.domains));
         } catch (final Exception e) {
             return false;
         }
@@ -143,7 +148,7 @@ public class XmppDomainVerifier {
         final var xmppAddresses = new ImmutableSet.Builder<String>();
         final var srvNames = new ImmutableSet.Builder<String>();
         final var domains = new ImmutableSet.Builder<String>();
-        final var ips = new ImmutableSet.Builder<String>();
+        final var ips = new ImmutableSet.Builder<InetAddress>();
         if (alternativeNames == null || alternativeNames.isEmpty()) {
             Log.d(LOGTAG, "no alternative names found. using common names");
             return new ValidDomains(
@@ -177,8 +182,8 @@ public class XmppDomainVerifier {
                 }
             } else if (type == 7) {
                 final Object value = san.get(1);
-                if (value instanceof String s) {
-                    ips.add(s);
+                if (value instanceof String s && InetAddresses.isInetAddress(s)) {
+                    ips.add(InetAddresses.forString(s));
                 }
             } else {
                 Log.d(LOGTAG, "found more types: " + type);
@@ -189,13 +194,13 @@ public class XmppDomainVerifier {
     }
 
     public static final class ValidDomains {
-        final Set<String> ipAddresses;
+        final Set<InetAddress> ipAddresses;
         final Set<String> xmppAddresses;
         final Set<String> srvNames;
         final Set<String> domains;
 
         private ValidDomains(
-                Set<String> ipAddresses,
+                Set<InetAddress> ipAddresses,
                 Set<String> xmppAddresses,
                 Set<String> srvNames,
                 Set<String> domains) {
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Account.java b/src/main/java/tel/xmpp/jabjab/entities/Account.java
index d013861..e1700a8 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Account.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Account.java
@@ -776,7 +776,8 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
         STREAM_OPENING_ERROR,
         POLICY_VIOLATION,
         PAYMENT_REQUIRED,
-        MISSING_INTERNET_PERMISSION(false);
+        MISSING_INTERNET_PERMISSION(false),
+        MISSING_LOCAL_NETWORK_PERMISSION(false);
 
         private final boolean isError;
         private final boolean attemptReconnect;
@@ -841,6 +842,7 @@ public class Account extends AbstractEntity implements AvatarService.Avatar {
                 case PAYMENT_REQUIRED -> R.string.payment_required;
                 case SEE_OTHER_HOST -> R.string.reconnect_on_other_host;
                 case MISSING_INTERNET_PERMISSION -> R.string.missing_internet_permission;
+                case MISSING_LOCAL_NETWORK_PERMISSION -> R.string.missing_local_network_permission;
                 case TEMPORARY_AUTH_FAILURE -> R.string.account_status_temporary_auth_failure;
             };
         }
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Contact.java b/src/main/java/tel/xmpp/jabjab/entities/Contact.java
index 16cd683..7173ff0 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Contact.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Contact.java
@@ -4,6 +4,7 @@ import android.content.ContentValues;
 import android.database.Cursor;
 import android.net.Uri;
 import android.text.TextUtils;
+import android.util.Log;
 import androidx.annotation.NonNull;
 import com.google.common.base.Optional;
 import com.google.common.base.Strings;
@@ -12,6 +13,9 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
+import com.google.gson.JsonSyntaxException;
+import com.google.gson.annotations.SerializedName;
+import com.google.gson.reflect.TypeToken;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.android.AbstractPhoneContact;
 import tel.xmpp.jabjab.android.JabberIdContact;
@@ -24,18 +28,16 @@ import tel.xmpp.jabjab.xmpp.jingle.RtpCapability;
 import tel.xmpp.jabjab.xmpp.manager.BlockingManager;
 import tel.xmpp.jabjab.xmpp.manager.DiscoManager;
 import tel.xmpp.jabjab.xmpp.manager.PresenceManager;
+import im.conversations.android.json.Services;
 import im.conversations.android.model.DynamicTag;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.idle.LastUserInteraction;
 import im.conversations.android.xmpp.model.stanza.Presence;
 import im.conversations.android.xmpp.model.stanza.Stanza;
 import java.util.Collection;
-import java.util.HashSet;
+import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
 
 public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser {
     public static final String TABLENAME = "contacts";
@@ -63,8 +65,8 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
     private int subscription = 0;
     private Uri systemAccount;
     private String photoUri;
-    private final JSONObject keys;
-    private JSONArray groups = new JSONArray();
+    private Collection<String> groups = Collections.emptySet();
+    private final Attributes attributes;
     protected Account account;
     protected String avatar;
     // Profile banner URL — intentionally NOT persisted to the DB (unlike avatar). It's
@@ -85,10 +87,10 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
             final int subscription,
             final String photoUri,
             final Uri systemAccount,
-            final String keys,
+            final Attributes attributes,
             final String avatar,
             final String presence,
-            final String groups,
+            final Collection<String> groups,
             final RtpCapability.Capability rtpCapability) {
         this.accountUuid = account;
         this.systemName = systemName;
@@ -98,26 +100,16 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
         this.subscription = subscription;
         this.photoUri = photoUri;
         this.systemAccount = systemAccount;
-        JSONObject tmpJsonObject;
-        try {
-            tmpJsonObject = (keys == null ? new JSONObject("") : new JSONObject(keys));
-        } catch (JSONException e) {
-            tmpJsonObject = new JSONObject();
-        }
-        this.keys = tmpJsonObject;
+        this.attributes = attributes;
         this.avatar = avatar;
-        try {
-            this.groups = (groups == null ? new JSONArray() : new JSONArray(groups));
-        } catch (JSONException e) {
-            this.groups = new JSONArray();
-        }
+        this.groups = groups;
         this.mLastPresence = presence;
         this.rtpCapability = rtpCapability;
     }
 
     public Contact(final Jid jid) {
         this.jid = jid;
-        this.keys = new JSONObject();
+        this.attributes = new Attributes();
     }
 
     public static Contact fromCursor(final Cursor cursor) {
@@ -144,14 +136,26 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
                 cursor.getInt(cursor.getColumnIndexOrThrow(OPTIONS)),
                 cursor.getString(cursor.getColumnIndexOrThrow(PHOTOURI)),
                 systemAccount,
-                cursor.getString(cursor.getColumnIndexOrThrow(KEYS)),
+                Attributes.parse(cursor.getString(cursor.getColumnIndexOrThrow(KEYS))),
                 cursor.getString(cursor.getColumnIndexOrThrow(AVATAR)),
                 cursor.getString(cursor.getColumnIndexOrThrow(LAST_PRESENCE)),
-                cursor.getString(cursor.getColumnIndexOrThrow(GROUPS)),
+                jsonToStringCollection(cursor.getString(cursor.getColumnIndexOrThrow(GROUPS))),
                 RtpCapability.Capability.of(
                         cursor.getString(cursor.getColumnIndexOrThrow(RTP_CAPABILITY))));
     }
 
+    private static Collection<String> jsonToStringCollection(final String json) {
+        if (Strings.isNullOrEmpty(json)) {
+            return Collections.emptyList();
+        }
+        try {
+            return Services.GSON.fromJson(json, new TypeToken<Collection<String>>() {}.getType());
+        } catch (final JsonSyntaxException e) {
+            Log.w(Config.LOGTAG, "could not parse json string collection", e);
+            return Collections.emptyList();
+        }
+    }
+
     public static boolean isNoteworthy(final Iterable<DynamicTag> tags) {
         return Iterables.any(tags, t -> t instanceof DynamicTag.Blocked);
     }
@@ -216,23 +220,21 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
     }
 
     public ContentValues getContentValues() {
-        synchronized (this.keys) {
-            final ContentValues values = new ContentValues();
-            values.put(ACCOUNT, accountUuid);
-            values.put(SYSTEMNAME, systemName);
-            values.put(SERVERNAME, serverName);
-            values.put(PRESENCE_NAME, presenceName);
-            values.put(JID, jid.toString());
-            values.put(OPTIONS, subscription);
-            values.put(SYSTEMACCOUNT, systemAccount != null ? systemAccount.toString() : null);
-            values.put(PHOTOURI, photoUri);
-            values.put(KEYS, keys.toString());
-            values.put(AVATAR, avatar);
-            values.put(LAST_PRESENCE, mLastPresence);
-            values.put(GROUPS, groups.toString());
-            values.put(RTP_CAPABILITY, rtpCapability == null ? null : rtpCapability.toString());
-            return values;
-        }
+        final var values = new ContentValues();
+        values.put(ACCOUNT, accountUuid);
+        values.put(SYSTEMNAME, systemName);
+        values.put(SERVERNAME, serverName);
+        values.put(PRESENCE_NAME, presenceName);
+        values.put(JID, jid.toString());
+        values.put(OPTIONS, subscription);
+        values.put(SYSTEMACCOUNT, systemAccount != null ? systemAccount.toString() : null);
+        values.put(PHOTOURI, photoUri);
+        values.put(KEYS, Services.GSON.toJson(this.attributes));
+        values.put(AVATAR, avatar);
+        values.put(LAST_PRESENCE, mLastPresence);
+        values.put(GROUPS, Services.GSON.toJson(this.groups));
+        values.put(RTP_CAPABILITY, rtpCapability == null ? null : rtpCapability.toString());
+        return values;
     }
 
     public Account getAccount() {
@@ -325,41 +327,23 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
         this.systemAccount = lookupUri;
     }
 
+    public void setGroups(final Collection<String> groups) {
+        this.groups = groups;
+    }
+
     public Collection<String> getGroups() {
-        final Collection<String> groups = new HashSet<>();
-        for (int i = 0; i < this.groups.length(); ++i) {
-            try {
-                groups.add(this.groups.getString(i));
-            } catch (final JSONException ignored) {
-            }
-        }
-        return groups;
+        return this.groups;
     }
 
     public long getPgpKeyId() {
-        synchronized (this.keys) {
-            if (this.keys.has("pgp_keyid")) {
-                try {
-                    return this.keys.getLong("pgp_keyid");
-                } catch (JSONException e) {
-                    return 0;
-                }
-            } else {
-                return 0;
-            }
-        }
+        final var pgpKeyId = this.attributes.pgpKeyId;
+        return pgpKeyId == null ? 0 : pgpKeyId;
     }
 
-    public boolean setPgpKeyId(long keyId) {
+    public boolean setPgpKeyId(final long keyId) {
         final long previousKeyId = getPgpKeyId();
-        synchronized (this.keys) {
-            try {
-                this.keys.put("pgp_keyid", keyId);
-                return previousKeyId != keyId;
-            } catch (final JSONException ignored) {
-            }
-        }
-        return false;
+        this.attributes.pgpKeyId = keyId == 0 ? null : keyId;
+        return previousKeyId != keyId;
     }
 
     public void setOption(int option) {
@@ -428,15 +412,6 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
         }
     }
 
-    public void parseGroupsFromElement(Element item) {
-        this.groups = new JSONArray();
-        for (Element element : item.getChildren()) {
-            if (element.getName().equals("group") && element.getContent() != null) {
-                this.groups.put(element.getContent());
-            }
-        }
-    }
-
     @Override
     public int compareTo(@NonNull final ListItem another) {
         return this.getDisplayName().compareToIgnoreCase(another.getDisplayName());
@@ -594,6 +569,22 @@ public class Contact implements ListItem, Blockable, MucOptions.IdentifiableUser
         return null;
     }
 
+    public static class Attributes {
+        @SerializedName("pgp_keyid")
+        public Long pgpKeyId;
+
+        public static Attributes parse(final String json) {
+            if (Strings.isNullOrEmpty(json)) {
+                return new Attributes();
+            }
+            try {
+                return Services.GSON.fromJson(json, Attributes.class);
+            } catch (final JsonSyntaxException e) {
+                return new Attributes();
+            }
+        }
+    }
+
     public static final class Options {
         public static final int TO = 0;
         public static final int FROM = 1;
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Conversation.java b/src/main/java/tel/xmpp/jabjab/entities/Conversation.java
index 383c198..829edc8 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Conversation.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Conversation.java
@@ -2,7 +2,7 @@ package tel.xmpp.jabjab.entities;
 
 import android.content.ContentValues;
 import android.database.Cursor;
-import android.text.TextUtils;
+import android.util.Log;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import com.google.common.base.Strings;
@@ -12,6 +12,8 @@ import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.gson.JsonParseException;
+import com.google.gson.annotations.SerializedName;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.crypto.OmemoSetting;
 import tel.xmpp.jabjab.crypto.PgpDecryptionService;
@@ -25,18 +27,19 @@ import tel.xmpp.jabjab.xmpp.Jid;
 import tel.xmpp.jabjab.xmpp.mam.MamReference;
 import tel.xmpp.jabjab.xmpp.manager.BookmarkManager;
 import tel.xmpp.jabjab.xmpp.manager.MultiUserChatManager;
+import im.conversations.android.json.Services;
 import im.conversations.android.model.Bookmark;
+import im.conversations.android.xmpp.model.muc.Affiliation;
+import im.conversations.android.xmpp.model.muc.Role;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.ListIterator;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.atomic.AtomicBoolean;
-import org.json.JSONArray;
-import org.json.JSONException;
-import org.json.JSONObject;
 
 public class Conversation extends AbstractEntity
         implements Blockable, Comparable<Conversation>, Conversational, AvatarService.Avatar {
@@ -54,21 +57,8 @@ public class Conversation extends AbstractEntity
     public static final String MODE = "mode";
     public static final String ATTRIBUTES = "attributes";
 
-    public static final String ATTRIBUTE_MUTED_TILL = "muted_till";
-    public static final String ATTRIBUTE_ALWAYS_NOTIFY = "always_notify";
-    public static final String ATTRIBUTE_LAST_CLEAR_HISTORY = "last_clear_history";
-    public static final String ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS =
-            "formerly_private_non_anonymous";
-    public static final String ATTRIBUTE_PINNED_ON_TOP = "pinned_on_top";
     public static final String ATTRIBUTE_PINNED_MESSAGES = "pinned_messages";
     private static final int MAX_PINNED_MESSAGES = 50;
-    static final String ATTRIBUTE_MUC_PASSWORD = "muc_password";
-    static final String ATTRIBUTE_CAPS2_HASH = "muc_caps2_hash";
-    private static final String ATTRIBUTE_NEXT_MESSAGE = "next_message";
-    private static final String ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP = "next_message_timestamp";
-    private static final String ATTRIBUTE_CRYPTO_TARGETS = "crypto_targets";
-    private static final String ATTRIBUTE_NEXT_ENCRYPTION = "next_encryption";
-    private static final String ATTRIBUTE_CORRECTING_MESSAGE = "correcting_message";
     // XEP-0466: per-conversation ephemeral timer in seconds; 0 = disabled
     public static final String ATTRIBUTE_EPHEMERAL_TIMER = "ephemeral_timer";
     protected final ArrayList<Message> messages = new ArrayList<>();
@@ -82,7 +72,7 @@ public class Conversation extends AbstractEntity
     private int status;
     private final long created;
     private int mode;
-    private final JSONObject attributes;
+    private final Attributes attributes;
     private Jid nextCounterpart;
     private boolean messagesLeftOnServer = true;
     private String mFirstMamReference = null;
@@ -99,7 +89,7 @@ public class Conversation extends AbstractEntity
                 System.currentTimeMillis(),
                 STATUS_AVAILABLE,
                 mode,
-                "");
+                new Attributes());
         this.account = account;
     }
 
@@ -112,7 +102,7 @@ public class Conversation extends AbstractEntity
             final long created,
             final int status,
             final int mode,
-            final String attributes) {
+            final Attributes attributes) {
         this.uuid = uuid;
         this.name = name;
         this.contactUuid = contactUuid;
@@ -121,19 +111,7 @@ public class Conversation extends AbstractEntity
         this.created = created;
         this.status = status;
         this.mode = mode;
-        this.attributes = parseAttributes(attributes);
-    }
-
-    private static JSONObject parseAttributes(final String attributes) {
-        if (Strings.isNullOrEmpty(attributes)) {
-            return new JSONObject();
-        } else {
-            try {
-                return new JSONObject(attributes);
-            } catch (final JSONException e) {
-                return new JSONObject();
-            }
-        }
+        this.attributes = attributes;
     }
 
     public static Conversation fromCursor(final Cursor cursor) {
@@ -146,7 +124,7 @@ public class Conversation extends AbstractEntity
                 cursor.getLong(cursor.getColumnIndexOrThrow(CREATED)),
                 cursor.getInt(cursor.getColumnIndexOrThrow(STATUS)),
                 cursor.getInt(cursor.getColumnIndexOrThrow(MODE)),
-                cursor.getString(cursor.getColumnIndexOrThrow(ATTRIBUTES)));
+                Attributes.parse(cursor.getString(cursor.getColumnIndexOrThrow(ATTRIBUTES))));
     }
 
     public static Message getLatestMarkableMessage(
@@ -170,8 +148,7 @@ public class Conversation extends AbstractEntity
             return false;
         }
         return conversation.isSingleOrPrivateAndNonAnonymous()
-                || conversation.getBooleanAttribute(
-                        ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
+                || conversation.attributes.formerlyPrivateNonAnonymous();
     }
 
     public boolean hasMessagesLeftOnServer() {
@@ -228,7 +205,7 @@ public class Conversation extends AbstractEntity
         synchronized (this.messages) {
             for (final Message message : Lists.reverse(this.messages)) {
                 if (message.isEditable()) {
-                    if (message.isGeoUri() || message.getType() != Message.TYPE_TEXT) {
+                    if (!message.acceptMessageCorrection()) {
                         return null;
                     }
                     return message;
@@ -407,12 +384,18 @@ public class Conversation extends AbstractEntity
         }
     }
 
-    public Message findSentMessageWithUuidOrRemoteId(String id) {
+    public Message findMessageWithUuidOrRemoteId(
+            final String id, final String occupantId, final Boolean received) {
         synchronized (this.messages) {
-            for (Message message : this.messages) {
-                if (id.equals(message.getUuid())
-                        || (message.getStatus() >= Message.STATUS_SEND
-                                && id.equals(message.getRemoteMsgId()))) {
+            for (final var message : this.messages) {
+                final var idMatch =
+                        id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId());
+                final var occupantIdMatch =
+                        occupantId == null || occupantId.equals(message.getOccupantId());
+                final var directionMatch =
+                        received == null
+                                || (message.getStatus() == Message.STATUS_RECEIVED) == received;
+                if (idMatch && occupantIdMatch && directionMatch) {
                     return message;
                 }
             }
@@ -420,42 +403,6 @@ public class Conversation extends AbstractEntity
         return null;
     }
 
-    public Message findMessageWithUuidOrRemoteId(final String id) {
-        synchronized (this.messages) {
-            for (final Message message : this.messages) {
-                if (id.equals(message.getUuid()) || id.equals(message.getRemoteMsgId())) {
-                    return message;
-                }
-            }
-        }
-        return null;
-    }
-
-    public Message findMessageWithRemoteIdAndCounterpart(
-            String id, Jid counterpart, boolean received, boolean carbon) {
-        synchronized (this.messages) {
-            for (int i = this.messages.size() - 1; i >= 0; --i) {
-                final Message message = messages.get(i);
-                final Jid mcp = message.getCounterpart();
-                if (mcp == null) {
-                    continue;
-                }
-                if (mcp.equals(counterpart)
-                        && ((message.getStatus() == Message.STATUS_RECEIVED) == received)
-                        && (carbon == message.isCarbon() || received)) {
-                    if (id.equals(message.getRemoteMsgId())
-                            && !message.isFileOrImage()
-                            && !message.treatAsDownloadable()) {
-                        return message;
-                    } else {
-                        return null;
-                    }
-                }
-            }
-        }
-        return null;
-    }
-
     public Message findSentMessageWithUuid(String id) {
         synchronized (this.messages) {
             for (Message message : this.messages) {
@@ -479,18 +426,6 @@ public class Conversation extends AbstractEntity
         return null;
     }
 
-    public Message findReceivedWithRemoteId(final String id) {
-        synchronized (this.messages) {
-            for (final Message message : this.messages) {
-                if (message.getStatus() == Message.STATUS_RECEIVED
-                        && id.equals(message.getRemoteMsgId())) {
-                    return message;
-                }
-            }
-        }
-        return null;
-    }
-
     public Message findMessageWithServerMsgId(String id) {
         synchronized (this.messages) {
             for (Message message : this.messages) {
@@ -550,52 +485,42 @@ public class Conversation extends AbstractEntity
         this.mFirstMamReference = reference;
     }
 
-    public void setLastClearHistory(long time, String reference) {
-        if (reference != null) {
-            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time + ":" + reference);
-        } else {
-            setAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY, time);
-        }
+    public void setLastClearHistory(final long time, final String reference) {
+        this.attributes.lastClearHistory = new MamReference(time, reference);
     }
 
     public MamReference getLastClearHistory() {
-        return MamReference.fromAttribute(getAttribute(ATTRIBUTE_LAST_CLEAR_HISTORY));
+        return this.attributes.lastClearHistory;
     }
 
     public ImmutableSet<Jid> getAcceptedCryptoTargets() {
         if (mode == MODE_SINGLE) {
             return ImmutableSet.of(getAddress().asBareJid());
         } else {
-            return getJidListAttribute(ATTRIBUTE_CRYPTO_TARGETS);
+            final var list = this.attributes.cryptoTargets;
+            return list == null ? ImmutableSet.of() : ImmutableSet.copyOf(list);
         }
     }
 
     public void setAcceptedCryptoTargets(final Collection<Jid> acceptedTargets) {
-        setAttribute(ATTRIBUTE_CRYPTO_TARGETS, acceptedTargets);
+        this.attributes.cryptoTargets = ImmutableList.copyOf(acceptedTargets);
     }
 
-    public boolean setCorrectingMessage(Message correctingMessage) {
-        setAttribute(
-                ATTRIBUTE_CORRECTING_MESSAGE,
-                correctingMessage == null ? null : correctingMessage.getUuid());
+    public boolean setCorrectingMessage(final Message correctingMessage) {
+        this.attributes.correctingMessage =
+                correctingMessage == null ? null : correctingMessage.getUuid();
         return correctingMessage == null && draftMessage != null;
     }
 
     public Message getCorrectingMessage() {
-        final String uuid = getAttribute(ATTRIBUTE_CORRECTING_MESSAGE);
+        final String uuid = this.attributes.correctingMessage;
         return uuid == null ? null : findSentMessageWithUuid(uuid);
     }
 
-    public boolean withSelf() {
-        return getContact().isSelf();
-    }
-
     @Override
     public int compareTo(@NonNull Conversation another) {
         return ComparisonChain.start()
-                .compareFalseFirst(
-                        another.getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false),
-                        getBooleanAttribute(ATTRIBUTE_PINNED_ON_TOP, false))
+                .compareFalseFirst(another.attributes.pinnedOnTop(), attributes.pinnedOnTop())
                 .compare(another.getSortableTime(), getSortableTime())
                 .result();
     }
@@ -606,7 +531,7 @@ public class Conversation extends AbstractEntity
         if (draft == null) {
             return messageTime;
         } else {
-            return Math.max(messageTime, draft.getTimestamp());
+            return Math.max(messageTime, draft.instant.toEpochMilli());
         }
     }
 
@@ -614,7 +539,7 @@ public class Conversation extends AbstractEntity
         return draftMessage;
     }
 
-    public void setDraftMessage(String draftMessage) {
+    public void setDraftMessage(final String draftMessage) {
         this.draftMessage = draftMessage;
     }
 
@@ -649,9 +574,11 @@ public class Conversation extends AbstractEntity
     public Message getLatestMessage() {
         synchronized (this.messages) {
             if (this.messages.isEmpty()) {
-                Message message = new Message(this, "", Message.ENCRYPTION_NONE);
+                final var message = new Message(this, "", Message.ENCRYPTION_NONE);
                 message.setType(Message.TYPE_STATUS);
-                message.setTime(Math.max(getCreated(), getLastClearHistory().getTimestamp()));
+                final var lastClear = getLastClearHistory();
+                message.setTime(
+                        Math.max(getCreated(), lastClear == null ? 0 : lastClear.timestamp()));
                 return message;
             } else {
                 return this.messages.get(this.messages.size() - 1);
@@ -748,7 +675,7 @@ public class Conversation extends AbstractEntity
         values.put(STATUS, status);
         values.put(MODE, mode);
         synchronized (this.attributes) {
-            values.put(ATTRIBUTES, attributes.toString());
+            values.put(ATTRIBUTES, Services.GSON.toJson(attributes));
         }
         return values;
     }
@@ -789,6 +716,76 @@ public class Conversation extends AbstractEntity
         this.nextCounterpart = jid;
     }
 
+    public boolean isFormerlyPrivateNonAnonymous() {
+        return this.attributes.formerlyPrivateNonAnonymous();
+    }
+
+    public boolean setFormerlyPrivateNonAnonymous(final boolean value) {
+        final var current = this.attributes.formerlyPrivateNonAnonymous();
+        this.attributes.formerlyPrivateNonAnonymous = value;
+        return current != value;
+    }
+
+    public boolean setMucAffiliation(final Affiliation affiliation) {
+        final var current = getMucAffiliationOrNone();
+        this.attributes.mucAffiliation = affiliation;
+        return current != affiliation;
+    }
+
+    @NonNull
+    public Affiliation getMucAffiliationOrNone() {
+        final var a = this.attributes.mucAffiliation;
+        return a == null ? Affiliation.NONE : a;
+    }
+
+    @NonNull
+    public Role getMucRoleOrNone() {
+        final var r = this.attributes.mucRole;
+        return r == null ? Role.NONE : r;
+    }
+
+    public boolean setMucRole(final Role role) {
+        final var current = getMucRoleOrNone();
+        this.attributes.mucRole = role;
+        return current != role;
+    }
+
+    public boolean setMucSubject(final String subject) {
+        final var current = this.attributes.mucSubject;
+        this.attributes.mucSubject = subject;
+        return !Objects.equals(current, subject);
+    }
+
+    public String getMucSubject() {
+        return this.attributes.mucSubject;
+    }
+
+    public String getMucPassword() {
+        return this.attributes.mucPassword;
+    }
+
+    public void setMucPassword(final String password) {
+        this.attributes.mucPassword = password;
+    }
+
+    public String getMucCaps2Hash() {
+        return this.attributes.mucCaps2Hash;
+    }
+
+    public boolean setMucCaps2Hash(final String hash) {
+        final var current = this.attributes.mucCaps2Hash;
+        this.attributes.mucCaps2Hash = hash;
+        return !Objects.equals(current, hash);
+    }
+
+    public boolean isPinnedOnTop() {
+        return this.attributes.pinnedOnTop();
+    }
+
+    public void setPinnedOnTop(final boolean value) {
+        this.attributes.pinnedOnTop = value;
+    }
+
     public int getNextEncryption() {
         if (OmemoSetting.isAlways()) {
             return suitableForOmemoByDefault(this)
@@ -801,44 +798,39 @@ public class Conversation extends AbstractEntity
         } else {
             defaultEncryption = Message.ENCRYPTION_NONE;
         }
-        int encryption = this.getIntAttribute(ATTRIBUTE_NEXT_ENCRYPTION, defaultEncryption);
-        if (encryption == Message.ENCRYPTION_OTR || encryption < 0) {
+        final var encryption = this.attributes.nextEncryption;
+        if (encryption == null || encryption == Message.ENCRYPTION_OTR) {
             return defaultEncryption;
         } else {
             return encryption;
         }
     }
 
-    public boolean setNextEncryption(int encryption) {
-        return this.setAttribute(ATTRIBUTE_NEXT_ENCRYPTION, encryption);
-    }
-
-    public String getNextMessage() {
-        final String nextMessage = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
-        return nextMessage == null ? "" : nextMessage;
+    public boolean setNextEncryption(final int encryption) {
+        final boolean modified =
+                this.attributes.nextEncryption == null
+                        || this.attributes.nextEncryption != encryption;
+        this.attributes.nextEncryption = encryption;
+        return modified;
     }
 
     public @Nullable Draft getDraft() {
-        long timestamp = getLongAttribute(ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP, 0);
-        if (timestamp > getLatestMessage().getTimeSent()) {
-            String message = getAttribute(ATTRIBUTE_NEXT_MESSAGE);
-            if (!TextUtils.isEmpty(message) && timestamp != 0) {
-                return new Draft(message, timestamp);
-            }
-        }
-        return null;
+        return this.attributes.draft;
     }
 
     public boolean setNextMessage(final String input) {
-        final String message = input == null || input.trim().isEmpty() ? null : input;
-        boolean changed = !getNextMessage().equals(message);
-        this.setAttribute(ATTRIBUTE_NEXT_MESSAGE, message);
-        if (changed) {
-            this.setAttribute(
-                    ATTRIBUTE_NEXT_MESSAGE_TIMESTAMP,
-                    message == null ? 0 : System.currentTimeMillis());
+        final var message = Strings.nullToEmpty(input).trim();
+        final var current = this.attributes.draft;
+        if (message.isEmpty()) {
+            this.attributes.draft = null;
+            return current != null;
+        }
+        final var modified = current == null || !message.equals(current.message);
+        if (modified) {
+            this.attributes.draft = new Draft(Instant.now(), message);
+            return true;
         }
-        return changed;
+        return false;
     }
 
     public Bookmark getBookmark() {
@@ -859,10 +851,6 @@ public class Conversation extends AbstractEntity
         return null;
     }
 
-    public boolean hasDuplicateMessage(Message message) {
-        return findDuplicateMessage(message) != null;
-    }
-
     public Message findSentMessageWithBody(String body) {
         synchronized (this.messages) {
             for (int i = this.messages.size() - 1; i >= 0; --i) {
@@ -935,107 +923,42 @@ public class Conversation extends AbstractEntity
         return MamReference.max(lastClear, lastReceived);
     }
 
-    public void setMutedTill(long value) {
-        this.setAttribute(ATTRIBUTE_MUTED_TILL, String.valueOf(value));
+    @org.jspecify.annotations.Nullable
+    public Instant getMutedTill() {
+        return this.attributes.mutedTill;
     }
 
-    public boolean isMuted() {
-        return System.currentTimeMillis() < this.getLongAttribute(ATTRIBUTE_MUTED_TILL, 0);
+    public void setMutedTill(final Instant value) {
+        this.attributes.mutedTill = value;
     }
 
-    public boolean alwaysNotify() {
-        return mode == MODE_SINGLE
-                || getBooleanAttribute(
-                        ATTRIBUTE_ALWAYS_NOTIFY,
-                        Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
-    }
-
-    public boolean setAttribute(String key, boolean value) {
-        return setAttribute(key, String.valueOf(value));
+    public boolean isMuted() {
+        final var mutedTill = this.attributes.mutedTill;
+        return mutedTill != null && mutedTill.isAfter(Instant.now());
     }
 
-    private boolean setAttribute(String key, long value) {
-        return setAttribute(key, Long.toString(value));
+    public boolean isAcceptNonAnonymous() {
+        return this.attributes.acceptNonAnonymous();
     }
 
-    private boolean setAttribute(String key, int value) {
-        return setAttribute(key, String.valueOf(value));
+    public void setAcceptNonAnonymous(final boolean value) {
+        this.attributes.acceptNonAnonymous = value;
     }
 
-    public boolean setAttribute(String key, String value) {
-        synchronized (this.attributes) {
-            try {
-                if (value == null) {
-                    if (this.attributes.has(key)) {
-                        this.attributes.remove(key);
-                        return true;
-                    } else {
-                        return false;
-                    }
-                } else {
-                    final String prev = this.attributes.optString(key, null);
-                    this.attributes.put(key, value);
-                    return !value.equals(prev);
-                }
-            } catch (JSONException e) {
-                throw new AssertionError(e);
-            }
-        }
+    public void setAlwaysNotify(final boolean value) {
+        this.attributes.alwaysNotify = value;
     }
 
-    public boolean setAttribute(final String key, final Collection<Jid> addresses) {
-        JSONArray array = new JSONArray();
-        for (Jid jid : addresses) {
-            array.put(jid.asBareJid().toString());
-        }
-        synchronized (this.attributes) {
-            try {
-                this.attributes.put(key, array);
-                return true;
-            } catch (JSONException e) {
-                return false;
-            }
-        }
-    }
-
-    public String getAttribute(String key) {
-        synchronized (this.attributes) {
-            return this.attributes.optString(key, null);
-        }
-    }
-
-    private ImmutableSet<Jid> getJidListAttribute(final String key) {
-        final var builder = new ImmutableSet.Builder<Jid>();
-        synchronized (this.attributes) {
-            try {
-                JSONArray array = this.attributes.getJSONArray(key);
-                for (int i = 0; i < array.length(); ++i) {
-                    try {
-                        builder.add(Jid.of(array.getString(i)));
-                    } catch (IllegalArgumentException e) {
-                        // ignored
-                    }
-                }
-            } catch (JSONException e) {
-                // ignored
-            }
-        }
-        return builder.build();
+    public boolean alwaysNotify() {
+        return mode == MODE_SINGLE
+                || Attributes.valueOrDefault(
+                        this.attributes.alwaysNotify,
+                        Config.ALWAYS_NOTIFY_BY_DEFAULT || isPrivateAndNonAnonymous());
     }
 
     public List<String> getPinnedMessageUuids() {
-        final ImmutableList.Builder<String> builder = new ImmutableList.Builder<>();
-        synchronized (this.attributes) {
-            try {
-                final JSONArray array = this.attributes.getJSONArray(ATTRIBUTE_PINNED_MESSAGES);
-                for (int i = 0; i < array.length(); ++i) {
-                    builder.add(array.getString(i));
-                }
-            } catch (JSONException e) {
-                // ignored
-            }
-        }
-        return builder.build();
+        final var pinned = this.attributes.pinnedMessages;
+        return pinned == null ? Collections.emptyList() : ImmutableList.copyOf(pinned);
     }
 
     public boolean isPinned(final String uuid) {
@@ -1049,71 +972,23 @@ public class Conversation extends AbstractEntity
         while (current.size() > MAX_PINNED_MESSAGES) {
             current.remove(current.size() - 1);
         }
-        setPinnedMessageUuids(current);
+        this.attributes.pinnedMessages = current;
     }
 
     public void unpinMessage(final String uuid) {
         final List<String> current = new ArrayList<>(getPinnedMessageUuids());
         if (current.remove(uuid)) {
-            setPinnedMessageUuids(current);
-        }
-    }
-
-    private void setPinnedMessageUuids(final List<String> uuids) {
-        final JSONArray array = new JSONArray();
-        for (final String uuid : uuids) {
-            array.put(uuid);
-        }
-        synchronized (this.attributes) {
-            try {
-                this.attributes.put(ATTRIBUTE_PINNED_MESSAGES, array);
-            } catch (JSONException e) {
-                throw new AssertionError(e);
-            }
-        }
-    }
-
-    private int getIntAttribute(String key, int defaultValue) {
-        String value = this.getAttribute(key);
-        if (value == null) {
-            return defaultValue;
-        } else {
-            try {
-                return Integer.parseInt(value);
-            } catch (NumberFormatException e) {
-                return defaultValue;
-            }
-        }
-    }
-
-    public long getLongAttribute(String key, long defaultValue) {
-        String value = this.getAttribute(key);
-        if (value == null) {
-            return defaultValue;
-        } else {
-            try {
-                return Long.parseLong(value);
-            } catch (NumberFormatException e) {
-                return defaultValue;
-            }
-        }
-    }
-
-    public boolean getBooleanAttribute(String key, boolean defaultValue) {
-        String value = this.getAttribute(key);
-        if (value == null) {
-            return defaultValue;
-        } else {
-            return Boolean.parseBoolean(value);
+            this.attributes.pinnedMessages = current;
         }
     }
 
     public long getEphemeralMessageTimer() {
-        return getLongAttribute(ATTRIBUTE_EPHEMERAL_TIMER, 0L);
+        final var timer = this.attributes.ephemeralTimer;
+        return timer == null ? 0L : timer;
     }
 
     public void setEphemeralMessageTimer(final long seconds) {
-        setAttribute(ATTRIBUTE_EPHEMERAL_TIMER, String.valueOf(seconds));
+        this.attributes.ephemeralTimer = seconds;
     }
 
     public void add(Message message) {
@@ -1264,25 +1139,91 @@ public class Conversation extends AbstractEntity
         return this.displayState;
     }
 
-    public interface OnMessageFound {
-        void onMessageFound(final Message message);
-    }
+    public static final class Attributes {
+        @SerializedName("muted_till")
+        private Instant mutedTill;
+
+        @SerializedName("always_notify")
+        private Boolean alwaysNotify;
+
+        @SerializedName("last_clear")
+        private MamReference lastClearHistory;
+
+        @SerializedName("formerly_private_non_anonymous")
+        private Boolean formerlyPrivateNonAnonymous;
+
+        @SerializedName("pinned_on_top")
+        private Boolean pinnedOnTop;
+
+        private Affiliation mucAffiliation;
+        private Role mucRole;
+
+        @SerializedName("muc_password")
+        private String mucPassword;
 
-    public static class Draft {
-        private final String message;
-        private final long timestamp;
+        @SerializedName("muc_caps2_hash")
+        private String mucCaps2Hash;
 
-        private Draft(String message, long timestamp) {
-            this.message = message;
-            this.timestamp = timestamp;
+        @SerializedName("subject")
+        private String mucSubject;
+
+        @SerializedName("draft")
+        private Draft draft;
+
+        @SerializedName("crypto_targets")
+        private List<Jid> cryptoTargets;
+
+        @SerializedName("next_encryption")
+        private Integer nextEncryption;
+
+        @SerializedName("correcting_message")
+        private String correctingMessage;
+
+        @SerializedName("accept_non_anonymous")
+        private Boolean acceptNonAnonymous;
+
+        @SerializedName("pinned_messages")
+        private List<String> pinnedMessages;
+
+        @SerializedName("ephemeral_timer")
+        private Long ephemeralTimer;
+
+        public static Attributes parse(final String json) {
+            if (Strings.isNullOrEmpty(json)) {
+                return new Attributes();
+            }
+            try {
+                return Services.GSON.fromJson(json, Attributes.class);
+            } catch (final JsonParseException e) {
+                Log.d(Config.LOGTAG, "could not parse account keys from " + json, e);
+                return new Attributes();
+            }
         }
 
-        public long getTimestamp() {
-            return timestamp;
+        public boolean acceptNonAnonymous() {
+            return valueOrDefault(this.acceptNonAnonymous, false);
         }
 
-        public String getMessage() {
-            return message;
+        public boolean pinnedOnTop() {
+            return valueOrDefault(this.pinnedOnTop, false);
+        }
+
+        public boolean formerlyPrivateNonAnonymous() {
+            return valueOrDefault(this.formerlyPrivateNonAnonymous, false);
+        }
+
+        public MamReference lastClearHistory() {
+            return this.lastClearHistory;
+        }
+
+        private static boolean valueOrDefault(final Boolean value, final boolean d) {
+            return value == null ? d : value;
         }
     }
+
+    public interface OnMessageFound {
+        void onMessageFound(final Message message);
+    }
+
+    public record Draft(Instant instant, String message) {}
 }
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Edit.java b/src/main/java/tel/xmpp/jabjab/entities/Edit.java
index 2707a03..dbdd622 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Edit.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Edit.java
@@ -7,17 +7,22 @@ import com.google.gson.annotations.SerializedName;
 import com.google.gson.reflect.TypeToken;
 import tel.xmpp.jabjab.Config;
 import im.conversations.android.json.Services;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 
 public record Edit(
-        @SerializedName("edited_id") String editedId,
-        @SerializedName("server_msg_id") String serverMsgId) {
+        String id,
+        @SerializedName("server_msg_id") String serverMsgId,
+        Instant sentAt,
+        String body,
+        Integer encryption,
+        String fingerprint) {
 
     static boolean wasPreviouslyEditedRemoteMsgId(
             final List<Edit> edits, final String remoteMsgId) {
         for (final var edit : edits) {
-            if (edit.editedId != null && edit.editedId.equals(remoteMsgId)) {
+            if (edit.id != null && edit.id.equals(remoteMsgId)) {
                 return true;
             }
         }
@@ -45,4 +50,19 @@ public record Edit(
             return Collections.emptyList();
         }
     }
+
+    public Message.BodyVersion asBodyVersion() {
+        return new Message.BodyVersion(
+                body, encryption == null ? Message.ENCRYPTION_NONE : encryption, fingerprint);
+    }
+
+    public Edit withBody(final Message.BodyVersion body) {
+        return new Edit(
+                this.id,
+                this.serverMsgId,
+                this.sentAt,
+                body.body(),
+                body.encryption(),
+                body.fingerprint());
+    }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java b/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java
index ed9500a..42cce77 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/IndividualMessage.java
@@ -34,6 +34,7 @@ import android.database.Cursor;
 import tel.xmpp.jabjab.ui.adapter.MessageAdapter;
 import tel.xmpp.jabjab.xmpp.Jid;
 import java.util.Collection;
+import java.util.List;
 import java.util.Set;
 
 public class IndividualMessage extends Message {
@@ -59,7 +60,7 @@ public class IndividualMessage extends Message {
             String serverMsgId,
             String fingerprint,
             boolean read,
-            Collection<Edit> edited,
+            final List<Edit> edited,
             boolean oob,
             String errorMessage,
             Set<ReadByMarker> readByMarkers,
diff --git a/src/main/java/tel/xmpp/jabjab/entities/Message.java b/src/main/java/tel/xmpp/jabjab/entities/Message.java
index 4814c2f..1f1783b 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/Message.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/Message.java
@@ -8,9 +8,12 @@ import com.google.common.base.Preconditions;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
 import com.google.common.primitives.Longs;
+import de.gultsch.common.MiniUri;
 import de.gultsch.common.Patterns;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.crypto.axolotl.AxolotlService;
@@ -26,13 +29,14 @@ import tel.xmpp.jabjab.utils.UIHelper;
 import tel.xmpp.jabjab.xmpp.Jid;
 import im.conversations.android.json.Services;
 import java.io.File;
-import java.util.ArrayList;
+import java.time.Instant;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import java.util.UUID;
 import java.util.concurrent.CopyOnWriteArraySet;
 import org.jspecify.annotations.NonNull;
 
@@ -111,7 +115,7 @@ public class Message extends AbstractEntity
     protected boolean deleted = false;
     protected boolean carbon = false;
     protected boolean oob = false;
-    protected List<Edit> edits = new ArrayList<>();
+    protected List<Edit> edits = Collections.emptyList();
     protected StorageLocation storageLocation;
     protected boolean read = true;
     protected String remoteMsgId = null;
@@ -229,7 +233,7 @@ public class Message extends AbstractEntity
             final String serverMsgId,
             final String fingerprint,
             final boolean read,
-            @NonNull final Collection<Edit> edited,
+            @NonNull final List<Edit> edited,
             final boolean oob,
             final String errorMessage,
             final Set<ReadByMarker> readByMarkers,
@@ -254,7 +258,7 @@ public class Message extends AbstractEntity
         this.serverMsgId = serverMsgId;
         this.axolotlFingerprint = fingerprint;
         this.read = read;
-        this.edits = new ArrayList<>(edited);
+        this.edits = edited;
         this.oob = oob;
         this.errorMessage = errorMessage;
         this.readByMarkers.addAll(readByMarkers);
@@ -435,7 +439,7 @@ public class Message extends AbstractEntity
         return body;
     }
 
-    public synchronized void setBody(String body) {
+    public synchronized void setBody(final String body) {
         if (body == null) {
             throw new Error("You should not set the message body to null");
         }
@@ -462,6 +466,10 @@ public class Message extends AbstractEntity
         return timeSent;
     }
 
+    public Instant getSentAt() {
+        return Instant.ofEpochMilli(this.timeSent);
+    }
+
     public int getEncryption() {
         return encryption;
     }
@@ -497,6 +505,14 @@ public class Message extends AbstractEntity
         this.storageLocation = storageLocation;
     }
 
+    public String getMessageId() {
+        if (status == Message.STATUS_RECEIVED || this.remoteMsgId != null) {
+            return this.remoteMsgId;
+        } else {
+            return this.uuid;
+        }
+    }
+
     public String getRemoteMsgId() {
         return this.remoteMsgId;
     }
@@ -553,6 +569,7 @@ public class Message extends AbstractEntity
         this.type = type;
     }
 
+    // TODO carbons is mostly unused these days (only the inValidSession() is still using this)
     public boolean isCarbon() {
         return carbon;
     }
@@ -561,11 +578,95 @@ public class Message extends AbstractEntity
         this.carbon = carbon;
     }
 
-    public void putEdited(String edited, String serverMsgId) {
-        final Edit edit = new Edit(edited, serverMsgId);
-        if (this.edits.size() < 128 && !this.edits.contains(edit)) {
-            this.edits.add(edit);
+    public boolean putEdited(final Message edit) {
+        final var body = BodyVersion.of(edit);
+        return putEdited(edit.getRemoteMsgId(), edit.getServerMsgId(), edit.getSentAt(), body);
+    }
+
+    public void putEdited(final BodyVersion body) {
+        final var uuid = java.util.UUID.randomUUID();
+        if (!putEdited(uuid.toString(), null, Instant.now(), body)) {
+            throw new IllegalStateException("Could not store edit");
         }
+        this.remoteMsgId = Objects.requireNonNull(Iterables.getFirst(this.edits, null)).id();
+        this.uuid = uuid.toString();
+    }
+
+    private boolean putEdited(
+            final String id,
+            final String serverMsgId,
+            final Instant sentAt,
+            final BodyVersion body) {
+        final List<Edit> versions = getVersions();
+        if (Iterables.any(
+                versions,
+                v ->
+                        v != null
+                                && ((id != null && id.equals(v.id()))
+                                        || (serverMsgId != null
+                                                && serverMsgId.equals(v.serverMsgId()))))) {
+            return false;
+        }
+        final var edit = new Edit(id, serverMsgId, sentAt, null, null, null);
+        this.edits = new ImmutableList.Builder<Edit>().addAll(versions).add(edit).build();
+        this.setBody(body.body);
+        this.encryption = body.encryption;
+        this.axolotlFingerprint = body.fingerprint;
+        return true;
+    }
+
+    public boolean hasEditHistory() {
+        return getVersions().size() >= 2;
+    }
+
+    private List<Edit> getVersions() {
+        final var withoutLegacy =
+                Collections2.filter(
+                        this.edits, e -> e != null && e.id() != null && e.sentAt() != null);
+        if (withoutLegacy.isEmpty()) {
+            final var id = getMessageId();
+            return Collections.singletonList(
+                    new Edit(id, serverMsgId, getSentAt(), body, encryption, axolotlFingerprint));
+        } else {
+            final var body = BodyVersion.of(this);
+            return ImmutableList.copyOf(
+                    Collections2.transform(
+                            withoutLegacy,
+                            e -> {
+                                Preconditions.checkNotNull(e);
+                                if (e.body() == null || e.encryption() == null) {
+                                    return e.withBody(body);
+                                } else {
+                                    return e;
+                                }
+                            }));
+        }
+    }
+
+    public List<Message> getVersionsAsMessages() {
+        final var versions = getVersions();
+        return Lists.transform(
+                versions,
+                e -> {
+                    if (e == null) {
+                        return null;
+                    }
+                    return new MessageVersion(
+                            conversation,
+                            uuid,
+                            conversationUuid,
+                            counterpart,
+                            trueCounterpart,
+                            e.asBodyVersion(),
+                            e.sentAt().toEpochMilli(),
+                            status,
+                            type,
+                            carbon,
+                            e.id(),
+                            e.serverMsgId(),
+                            oob,
+                            occupantId);
+                });
     }
 
     public String getBodyLanguage() {
@@ -713,17 +814,6 @@ public class Message extends AbstractEntity
         }
     }
 
-    public boolean isLastCorrectableMessage() {
-        Message next = next();
-        while (next != null) {
-            if (next.isEditable()) {
-                return false;
-            }
-            next = next.next();
-        }
-        return isEditable();
-    }
-
     public boolean isEditable() {
         return status != STATUS_RECEIVED
                 && !isCarbon()
@@ -731,6 +821,10 @@ public class Message extends AbstractEntity
                 && type != Message.TYPE_STATUS;
     }
 
+    public boolean acceptMessageCorrection() {
+        return type == Message.TYPE_TEXT && !this.isGeoUri() && !this.treatAsDownloadable();
+    }
+
     public void setCounterparts(List<MucOptions.User> counterparts) {
         this.counterparts = counterparts;
     }
@@ -848,24 +942,10 @@ public class Message extends AbstractEntity
         this.uuid = uuid;
     }
 
-    public String getEditedId() {
-        if (this.edits.isEmpty()) {
-            throw new IllegalStateException("Attempting to access unedited message");
-        }
-        return edits.get(edits.size() - 1).editedId();
-    }
-
     public Collection<String> getEditedServerMessageIds() {
         return Collections2.transform(this.edits, Edit::serverMsgId);
     }
 
-    public String getEditedIdWireFormat() {
-        if (this.edits.isEmpty()) {
-            throw new IllegalStateException("Attempting to access unedited message");
-        }
-        return edits.get(0).editedId();
-    }
-
     public void setOob(boolean isOob) {
         this.oob = isOob;
     }
@@ -900,7 +980,9 @@ public class Message extends AbstractEntity
 
     public synchronized boolean isGeoUri() {
         if (isGeoUri == null) {
-            isGeoUri = Patterns.URI_GEO.matcher(body).matches();
+            isGeoUri =
+                    Patterns.URI_GEO.matcher(body).matches()
+                            && MiniUri.getOrNull(body) instanceof MiniUri.Geo;
         }
         return isGeoUri;
     }
@@ -1150,4 +1232,56 @@ public class Message extends AbstractEntity
     }
 
     public record StorageLocation(File file, boolean sharedStorage) {}
+
+    public record BodyVersion(String body, int encryption, String fingerprint) {
+        public static BodyVersion of(final Message m) {
+            return new BodyVersion(m.getBody(), m.getEncryption(), m.getFingerprint());
+        }
+    }
+
+    public static class MessageVersion extends Message {
+
+        protected MessageVersion(
+                Conversational conversation,
+                String uuid,
+                String conversationUUid,
+                Jid counterpart,
+                Jid trueCounterpart,
+                BodyVersion body,
+                long timeSent,
+                int status,
+                int type,
+                boolean carbon,
+                String remoteMsgId,
+                String serverMsgId,
+                boolean oob,
+                String occupantId) {
+            super(
+                    conversation,
+                    uuid,
+                    conversationUUid,
+                    counterpart,
+                    trueCounterpart,
+                    body.body(),
+                    timeSent,
+                    body.encryption(),
+                    status,
+                    type,
+                    carbon,
+                    remoteMsgId,
+                    null,
+                    serverMsgId,
+                    body.fingerprint(),
+                    true,
+                    Collections.emptyList(),
+                    oob,
+                    null,
+                    Collections.emptySet(),
+                    false,
+                    false,
+                    null,
+                    occupantId,
+                    Collections.emptySet());
+        }
+    }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/entities/MucOptions.java b/src/main/java/tel/xmpp/jabjab/entities/MucOptions.java
index c0b9d25..e447149 100644
--- a/src/main/java/tel/xmpp/jabjab/entities/MucOptions.java
+++ b/src/main/java/tel/xmpp/jabjab/entities/MucOptions.java
@@ -29,7 +29,6 @@ import im.conversations.android.xmpp.model.data.Data;
 import im.conversations.android.xmpp.model.data.Field;
 import im.conversations.android.xmpp.model.disco.info.InfoQuery;
 import im.conversations.android.xmpp.model.muc.Affiliation;
-import im.conversations.android.xmpp.model.muc.Item;
 import im.conversations.android.xmpp.model.muc.Role;
 import im.conversations.android.xmpp.model.reactions.Restrictions;
 import im.conversations.android.xmpp.model.stanza.Presence;
@@ -94,14 +93,12 @@ public class MucOptions {
     private boolean mAutoPushConfiguration = true;
     private Error error = Error.NONE;
     private Self self;
-    // TODO get rid of password; access password through attributes
-    private String password = null;
 
     public MucOptions(final Conversation conversation) {
         this.account = conversation.getAccount();
         this.conversation = conversation;
-        final var affiliation = Item.affiliationOrNone(conversation.getAttribute("affiliation"));
-        final var role = Item.roleOrNone(conversation.getAttribute("role"));
+        final var affiliation = conversation.getMucAffiliationOrNone();
+        final var role = conversation.getMucRoleOrNone();
         // TODO wrap createJoinJid into something reliable
         this.self =
                 new Self(
@@ -135,10 +132,9 @@ public class MucOptions {
             this.self = user.asConnectedSelf();
             this.resetOccupantIdMap();
         }
-        final boolean roleChanged =
-                this.conversation.setAttribute("role", user.getRole().toString());
+        final boolean roleChanged = this.conversation.setMucRole(user.getRole());
         final boolean affiliationChanged =
-                this.conversation.setAttribute("affiliation", user.getAffiliation().toString());
+                this.conversation.setMucAffiliation(user.getAffiliation());
         return roleChanged || affiliationChanged;
     }
 
@@ -681,12 +677,12 @@ public class MucOptions {
         return self;
     }
 
-    public boolean setSubject(String subject) {
-        return this.conversation.setAttribute("subject", subject);
+    public boolean setSubject(final String subject) {
+        return this.conversation.setMucSubject(subject);
     }
 
     public String getSubject() {
-        return this.conversation.getAttribute("subject");
+        return this.conversation.getMucSubject();
     }
 
     private List<User> getFallbackUsersFromCryptoTargets() {
@@ -740,27 +736,26 @@ public class MucOptions {
     }
 
     public String getPassword() {
-        this.password = conversation.getAttribute(Conversation.ATTRIBUTE_MUC_PASSWORD);
-        if (this.password == null
+        final var password = this.conversation.getMucPassword();
+        if (password == null
                 && conversation.getBookmark() != null
                 && conversation.getBookmark().getPassword() != null) {
             return conversation.getBookmark().getPassword();
         } else {
-            return this.password;
+            return password;
         }
     }
 
     public void setPassword(final String password) {
-        this.password = password;
-        conversation.setAttribute(Conversation.ATTRIBUTE_MUC_PASSWORD, password);
+        this.conversation.setMucPassword(password);
     }
 
     public boolean setCaps2Hash(final String hash) {
-        return this.conversation.setAttribute(Conversation.ATTRIBUTE_CAPS2_HASH, hash);
+        return this.conversation.setMucCaps2Hash(hash);
     }
 
     public EntityCapabilities2.EntityCaps2Hash getCaps2Hash() {
-        final var caps2Hash = this.conversation.getAttribute(Conversation.ATTRIBUTE_CAPS2_HASH);
+        final var caps2Hash = this.conversation.getMucCaps2Hash();
         if (Strings.isNullOrEmpty(caps2Hash)) {
             return null;
         }
diff --git a/src/main/java/tel/xmpp/jabjab/generator/MessageGenerator.java b/src/main/java/tel/xmpp/jabjab/generator/MessageGenerator.java
index 58ef535..f083df6 100644
--- a/src/main/java/tel/xmpp/jabjab/generator/MessageGenerator.java
+++ b/src/main/java/tel/xmpp/jabjab/generator/MessageGenerator.java
@@ -66,7 +66,7 @@ public class MessageGenerator extends AbstractGenerator {
             packet.addExtension(new OriginId(message.getUuid()));
         }
         if (message.edited()) {
-            packet.addExtension(new Replace(message.getEditedIdWireFormat()));
+            packet.addExtension(new Replace(message.getMessageId()));
         }
         // XEP-0466: advertise ephemeral timer so the recipient knows when to delete
         if (message.isEphemeral()) {
diff --git a/src/main/java/tel/xmpp/jabjab/parser/MessageParser.java b/src/main/java/tel/xmpp/jabjab/parser/MessageParser.java
index a7e746f..9cd9939 100644
--- a/src/main/java/tel/xmpp/jabjab/parser/MessageParser.java
+++ b/src/main/java/tel/xmpp/jabjab/parser/MessageParser.java
@@ -608,89 +608,66 @@ public class MessageParser extends AbstractParser
                 updateLastseen(account, from);
             }
 
-            if (replacementId != null && mXmppConnectionService.allowMessageCorrection()) {
-                final Message replacedMessage =
-                        conversation.findMessageWithRemoteIdAndCounterpart(
+            if (replacementId != null
+                    && message.acceptMessageCorrection()
+                    && mXmppConnectionService.allowMessageCorrection()) {
+                final String occupantIdFilter;
+                if (conversationMultiMode) {
+                    // a non-null filter ensures that we actually do filter even when we don't have
+                    // one
+                    occupantIdFilter = Strings.nullToEmpty(message.getOccupantId());
+                } else {
+                    occupantIdFilter = null;
+                }
+
+                final var replacedMessage =
+                        conversation.findMessageWithUuidOrRemoteId(
                                 replacementId,
-                                counterpart,
-                                message.getStatus() == Message.STATUS_RECEIVED,
-                                message.isCarbon());
-                if (replacedMessage != null) {
-                    final boolean fingerprintsMatch =
-                            replacedMessage.getFingerprint() == null
-                                    || replacedMessage
-                                            .getFingerprint()
-                                            .equals(message.getFingerprint());
-                    final boolean trueCountersMatch =
-                            replacedMessage.getTrueCounterpart() != null
-                                    && message.getTrueCounterpart() != null
-                                    && replacedMessage
-                                            .getTrueCounterpart()
-                                            .asBareJid()
-                                            .equals(message.getTrueCounterpart().asBareJid());
-                    final boolean occupantIdMatch =
-                            replacedMessage.getOccupantId() != null
-                                    && replacedMessage
-                                            .getOccupantId()
-                                            .equals(message.getOccupantId());
-                    final boolean duplicate = conversation.hasDuplicateMessage(message);
-                    if (fingerprintsMatch
-                            && (trueCountersMatch || occupantIdMatch || !conversationMultiMode)
-                            && !duplicate) {
-                        synchronized (replacedMessage) {
-                            final String uuid = replacedMessage.getUuid();
-                            replacedMessage.setUuid(UUID.randomUUID().toString());
-                            replacedMessage.setBody(message.getBody());
-                            // we store the IDs of the replacing message. This is essentially unused
-                            // today (only the fact that there are _some_ edits causes the edit icon
-                            // to appear)
-                            replacedMessage.putEdited(
-                                    message.getRemoteMsgId(), message.getServerMsgId());
-
-                            // we used to call
-                            // `replacedMessage.setServerMsgId(message.getServerMsgId());` so during
-                            // catchup we could start from the edit; not the original message
-                            // however this caused problems for things like reactions that refer to
-                            // the serverMsgId
-
-                            replacedMessage.setEncryption(message.getEncryption());
-                            if (replacedMessage.getStatus() == Message.STATUS_RECEIVED) {
-                                replacedMessage.markUnread();
-                            }
-                            getManager(ChatStateManager.class).process(packet);
-                            mXmppConnectionService.updateMessage(replacedMessage, uuid);
-                            if (replacedMessage.getStatus() == Message.STATUS_RECEIVED
-                                    && (replacedMessage.trusted()
-                                            || replacedMessage
-                                                    .isPrivateMessage()) // TODO do we really want
-                                    // to send receipts for all
-                                    // PMs?
-                                    && remoteMsgId != null
-                                    && !selfAddressed
-                                    && !isTypeGroupChat) {
-                                getManager(DeliveryReceiptManager.class)
-                                        .processRequest(packet, query);
-                            }
-                            if (replacedMessage.getEncryption() == Message.ENCRYPTION_PGP) {
-                                conversation
-                                        .getAccount()
-                                        .getPgpDecryptionService()
-                                        .discard(replacedMessage);
-                                conversation
-                                        .getAccount()
-                                        .getPgpDecryptionService()
-                                        .decrypt(replacedMessage, false);
-                            }
+                                occupantIdFilter,
+                                message.getStatus() == Message.STATUS_RECEIVED);
+                if (replacedMessage != null && replacedMessage.acceptMessageCorrection()) {
+                    synchronized (replacedMessage) {
+                        if (!replacedMessage.putEdited(message)) {
+                            Log.d(
+                                    Config.LOGTAG,
+                                    account.getJid().asBareJid()
+                                            + ": skipping already applied edit");
+                            return;
+                        }
+                        final String uuid = replacedMessage.getUuid();
+                        replacedMessage.setUuid(UUID.randomUUID().toString());
+                        replacedMessage.setEncryption(message.getEncryption());
+                        if (replacedMessage.getStatus() == Message.STATUS_RECEIVED) {
+                            replacedMessage.markUnread();
+                        }
+                        getManager(ChatStateManager.class).process(packet);
+                        mXmppConnectionService.updateMessage(replacedMessage, uuid);
+                        if (replacedMessage.getStatus() == Message.STATUS_RECEIVED
+                                && (replacedMessage.trusted()
+                                        || replacedMessage
+                                                .isPrivateMessage()) // TODO do we really want
+                                // to send receipts for all
+                                // PMs?
+                                && remoteMsgId != null
+                                && !selfAddressed
+                                && !isTypeGroupChat) {
+                            getManager(DeliveryReceiptManager.class).processRequest(packet, query);
+                        }
+                        if (replacedMessage.getEncryption() == Message.ENCRYPTION_PGP) {
+                            conversation
+                                    .getAccount()
+                                    .getPgpDecryptionService()
+                                    .discard(replacedMessage);
+                            conversation
+                                    .getAccount()
+                                    .getPgpDecryptionService()
+                                    .decrypt(replacedMessage, false);
                         }
-                        mXmppConnectionService.getNotificationService().updateNotification();
-                        return;
-                    } else {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid()
-                                        + ": received message correction but verification didn't"
-                                        + " check out");
                     }
+                    mXmppConnectionService.getNotificationService().updateNotification();
+                    return;
+                } else {
+                    Log.d(Config.LOGTAG, "replaced message not found");
                 }
             }
 
@@ -714,11 +691,12 @@ public class MessageParser extends AbstractParser
                                     && getManager(MessageArchiveManager.class)
                                             .isCatchupInProgress(conversation));
             if (checkForDuplicates) {
+                // TODO the duplicate message check seems very legacy.
                 final Message duplicate = conversation.findDuplicateMessage(message);
                 if (duplicate != null) {
                     final boolean serverMsgIdUpdated;
                     if (duplicate.getStatus() != Message.STATUS_RECEIVED
-                            && duplicate.getUuid().equals(message.getRemoteMsgId())
+                            && duplicate.getMessageId().equals(message.getRemoteMsgId())
                             && duplicate.getServerMsgId() == null
                             && message.getServerMsgId() != null) {
                         duplicate.setServerMsgId(message.getServerMsgId());
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
index b687ee5..0ffa44f 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/DatabaseBackend.java
@@ -66,7 +66,6 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.UUID;
 import java.util.concurrent.CopyOnWriteArrayList;
-import org.json.JSONObject;
 import org.jxmpp.jid.parts.Localpart;
 import org.jxmpp.stringprep.XmppStringprepException;
 import org.whispersystems.libsignal.IdentityKey;
@@ -1716,6 +1715,25 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         return null;
     }
 
+    private Message getMessageWithUuid(final Conversation conversation, final String uuid) {
+        final var db = this.getReadableDatabase();
+        final var sql = "select * from messages where conversationUuid=? and uuid=? LIMIT 1";
+        final String[] args = {conversation.getUuid(), uuid};
+        try (final Cursor cursor = db.rawQuery(sql, args)) {
+            if (cursor.moveToFirst()) {
+                return Message.fromCursor(context, cursor, conversation);
+            }
+        }
+        throw new IllegalStateException("No message found with uuid");
+    }
+
+    public ListenableFuture<Message> getMessageWithUuidFuture(
+            final Conversation conversation, final String uuid) {
+        return Futures.submit(
+                () -> getMessageWithUuid(conversation, uuid),
+                XmppConnectionService.DATABASE_READER);
+    }
+
     public Message getIndividualMessage(final String uuid) {
         final var db = this.getReadableDatabase();
         final String sql = "select * from messages where uuid=? LIMIT 1";
@@ -2167,12 +2185,8 @@ public class DatabaseBackend extends SQLiteOpenHelper {
         MamReference maxClearDate = new MamReference(0);
         while (cursor.moveToNext()) {
             try {
-                final JSONObject o = new JSONObject(cursor.getString(0));
-                maxClearDate =
-                        MamReference.max(
-                                maxClearDate,
-                                MamReference.fromAttribute(
-                                        o.getString(Conversation.ATTRIBUTE_LAST_CLEAR_HISTORY)));
+                final var attributes = Conversation.Attributes.parse(cursor.getString(0));
+                maxClearDate = MamReference.max(maxClearDate, attributes.lastClearHistory());
             } catch (Exception e) {
                 // ignored
             }
diff --git a/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java b/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java
index 93b2e97..bcacf7f 100644
--- a/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java
+++ b/src/main/java/tel/xmpp/jabjab/persistance/FileBackend.java
@@ -668,6 +668,9 @@ public class FileBackend {
 
     private String getExtensionFromUri(final Uri uri) {
         final String filename = getFilenameFromUri(uri);
+        if (Strings.isNullOrEmpty(filename)) {
+            return null;
+        }
         return Iterables.getLast(Splitter.on('.').split(filename), null);
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/receiver/SystemEventReceiver.java b/src/main/java/tel/xmpp/jabjab/receiver/SystemEventReceiver.java
index f1155df..1b3a371 100644
--- a/src/main/java/tel/xmpp/jabjab/receiver/SystemEventReceiver.java
+++ b/src/main/java/tel/xmpp/jabjab/receiver/SystemEventReceiver.java
@@ -3,31 +3,48 @@ package tel.xmpp.jabjab.receiver;
 import android.content.BroadcastReceiver;
 import android.content.Context;
 import android.content.Intent;
-import android.os.Bundle;
+import android.media.AudioManager;
+import android.net.ConnectivityManager;
 import android.util.Log;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import tel.xmpp.jabjab.Config;
 import tel.xmpp.jabjab.Conversations;
 import tel.xmpp.jabjab.services.XmppConnectionService;
 import tel.xmpp.jabjab.utils.Compatibility;
+import java.util.Set;
 
 public class SystemEventReceiver extends BroadcastReceiver {
 
-    public static final String EXTRA_NEEDS_FOREGROUND_SERVICE = "needs_foreground_service";
+    private static final Set<String> ALLOWED_ACTIONS =
+            ImmutableSet.of(
+                    Intent.ACTION_BOOT_COMPLETED,
+                    Intent.ACTION_MY_PACKAGE_REPLACED,
+                    ConnectivityManager.CONNECTIVITY_ACTION,
+                    AudioManager.RINGER_MODE_CHANGED_ACTION,
+                    Intent.ACTION_AIRPLANE_MODE_CHANGED,
+                    XmppConnectionService.ACTION_PING,
+                    XmppConnectionService.ACTION_PING_IDLE,
+                    XmppConnectionService.ACTION_POST_CONNECTIVITY_CHANGE);
 
     @Override
-    public void onReceive(final Context context, final Intent originalIntent) {
-        final Intent intentForService = new Intent(context, XmppConnectionService.class);
-        final String action = originalIntent.getAction();
-        intentForService.setAction(Strings.isNullOrEmpty(action) ? "other" : action);
-        final Bundle extras = originalIntent.getExtras();
-        if (extras != null) {
-            intentForService.putExtras(extras);
+    public void onReceive(final Context context, final Intent intent) {
+        final var extras = intent.getExtras();
+        final var action = intent.getAction();
+        if (Strings.isNullOrEmpty(action)) {
+            return;
         }
-        if ("ui".equals(action) || Conversations.getInstance(context).hasEnabledAccount()) {
-            Compatibility.startService(context, intentForService);
+        if (ALLOWED_ACTIONS.contains(action)
+                && Conversations.getInstance(context).hasEnabledAccount()) {
+            Log.d(Config.LOGTAG, "EventReceiver starting service for " + action);
+            final var service = new Intent(context, XmppConnectionService.class);
+            service.setAction(action);
+            if (extras != null && !extras.isEmpty()) {
+                service.putExtras(extras);
+            }
+            Compatibility.startService(context, service);
         } else {
-            Log.d(Config.LOGTAG, "EventReceiver ignored action " + intentForService.getAction());
+            Log.d(Config.LOGTAG, "EventReceiver ignored action " + action);
         }
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
index bab5e45..508d0aa 100644
--- a/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
+++ b/src/main/java/tel/xmpp/jabjab/services/XmppConnectionService.java
@@ -141,6 +141,7 @@ import im.conversations.android.xmpp.model.up.Push;
 import java.io.File;
 import java.security.cert.CertificateException;
 import java.security.cert.X509Certificate;
+import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -183,16 +184,16 @@ public class XmppConnectionService extends Service {
     public static final String ACTION_TRY_AGAIN = "try_again";
 
     public static final String ACTION_TEMPORARILY_DISABLE = "temporarily_disable";
-    public static final String ACTION_PING = "ping";
-    public static final String ACTION_IDLE_PING = "idle_ping";
-    public static final String ACTION_INTERNAL_PING = "internal_ping";
     public static final String ACTION_FCM_TOKEN_REFRESH = "fcm_token_refresh";
     public static final String ACTION_FCM_MESSAGE_RECEIVED = "fcm_message_received";
     public static final String ACTION_DISMISS_CALL = "dismiss_call";
     public static final String ACTION_END_CALL = "end_call";
     public static final String ACTION_CALL_INTEGRATION_SERVICE_STARTED =
             "call_integration_service_started";
-    private static final String ACTION_POST_CONNECTIVITY_CHANGE =
+    public static final String ACTION_PING = "tel.xmpp.jabjab.PING";
+    public static final String ACTION_PING_IDLE = "tel.xmpp.jabjab.PING_IDLE";
+    public static final String ACTION_PING_INTERNAL = "tel.xmpp.jabjab.PING_INTERNAL";
+    public static final String ACTION_POST_CONNECTIVITY_CHANGE =
             "tel.xmpp.jabjab.POST_CONNECTIVITY_CHANGE";
     public static final String ACTION_RENEW_UNIFIED_PUSH_ENDPOINTS =
             "tel.xmpp.jabjab.UNIFIED_PUSH_RENEW";
@@ -485,61 +486,23 @@ public class XmppConnectionService extends Service {
                 + " packNode=" + stickerPackNode
                 + " filePath=" + stickerFilePath);
 
-        // packFromDir()/getStickerUploadUrl() read meta.json synchronously — for large
-        // packs (dozens of stickers, one uploadUrls entry per account) this is enough
-        // disk I/O to stall the caller, so do the lookup off-thread before branching.
-        final var cachedUrlFuture = Futures.submit(() -> {
-            if (stickerFilePath == null) return null;
-            final java.io.File stickerFile = new java.io.File(stickerFilePath);
-            Log.d(Config.LOGTAG, "STICKER stickerFile exists=" + stickerFile.exists()
-                    + " path=" + stickerFile.getAbsolutePath());
-            if (!stickerFile.exists()) return null;
-            final tel.xmpp.jabjab.ui.util.StickerStore.Pack pack =
-                    tel.xmpp.jabjab.ui.util.StickerStore.packFromDir(stickerFile.getParentFile());
-            return tel.xmpp.jabjab.ui.util.StickerStore.getStickerUploadUrl(pack, stickerFile,
-                    conversation.getAccount().getJid().asBareJid().toString());
-        }, FILE_ATTACHMENT_EXECUTOR);
-
-        return Futures.transformAsync(cachedUrlFuture, cachedUrl -> {
-            Log.d(Config.LOGTAG, "STICKER cachedUrl=" + cachedUrl);
-            if (cachedUrl != null) {
-                // Copy the sticker into private storage and build the full
-                // url|size|width|height body so the sender's message has a
-                // local file and renders correctly as a sticker image.
-                final var copyFuture = Futures.submit(
-                        () -> {
-                            try {
-                                getFileBackend().copyFileToPrivateStorage(
-                                        message, uri, "image/webp");
-                                Log.d(Config.LOGTAG, "STICKER file copy OK, updating params with cachedUrl");
-                            } catch (final tel.xmpp.jabjab.persistance.FileBackend.FileCopyException e) {
-                                Log.w(Config.LOGTAG,
-                                        "STICKER cached sticker: file copy failed, sending bare url: " + e.getMessage());
-                            }
-                            getFileBackend().updateFileParams(message, cachedUrl);
-                            Log.d(Config.LOGTAG, "STICKER message body after updateFileParams=" + message.getBody());
-                        },
-                        FILE_ATTACHMENT_EXECUTOR);
-                return Futures.transformAsync(
-                        copyFuture,
-                        v -> {
-                            Log.d(Config.LOGTAG, "STICKER cached path: calling encryptIfNeededAndSend");
-                            return encryptIfNeededAndSend(message);
-                        },
-                        MoreExecutors.directExecutor());
-            }
-
-            // No cached URL — copy+attach file; HttpUploadConnection will cache the URL after upload
-            Log.d(Config.LOGTAG, "STICKER no cached URL, submitting fresh upload");
-            final var future = submitAttachToConversation(uri, "image/webp", message);
-            return Futures.transformAsync(
-                    future,
-                    v -> {
-                        Log.d(Config.LOGTAG, "STICKER upload path: calling encryptIfNeededAndSend, body=" + message.getBody());
-                        return encryptIfNeededAndSend(message);
-                    },
-                    MoreExecutors.directExecutor());
-        }, MoreExecutors.directExecutor());
+        // Stickers always upload fresh, same as every other attachment type — a sticker
+        // is never skip-sent off a previously-cached URL. That cache existed to save a
+        // re-upload when resending the same sticker file, but the server deletes uploads
+        // after 30 days regardless, so a cached URL is just as likely to be dead as live
+        // by the time it would be reused. setStickerUploadUrl() still records each
+        // successful upload's URL (see HttpUploadConnection) purely so buildPepPackItem()
+        // can list a self-made pack's stickers together when the pack itself is shared —
+        // that's the only remaining reader of this data.
+        Log.d(Config.LOGTAG, "STICKER submitting fresh upload");
+        final var future = submitAttachToConversation(uri, "image/webp", message);
+        return Futures.transformAsync(
+                future,
+                v -> {
+                    Log.d(Config.LOGTAG, "STICKER upload path: calling encryptIfNeededAndSend, body=" + message.getBody());
+                    return encryptIfNeededAndSend(message);
+                },
+                MoreExecutors.directExecutor());
     }
 
     private ListenableFuture<Void> submitAttachToConversation(
@@ -620,7 +583,7 @@ public class XmppConnectionService extends Service {
         final boolean needsForegroundService =
                 intent != null
                         && intent.getBooleanExtra(
-                                SystemEventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE, false);
+                                Compatibility.EXTRA_NEEDS_FOREGROUND_SERVICE, false);
         if (needsForegroundService) {
             Log.d(
                     Config.LOGTAG,
@@ -785,7 +748,7 @@ public class XmppConnectionService extends Service {
                                                 + ")");
                                 return;
                             }
-                            c.setMutedTill(System.currentTimeMillis() + 30 * 60 * 1000);
+                            c.setMutedTill(Instant.now().plus(Duration.ofMinutes(30)));
                             mNotificationService.clearMessages(c);
                             updateConversation(c);
                         });
@@ -833,7 +796,7 @@ public class XmppConnectionService extends Service {
                 // XEP-0466: alarm fired — bulk-delete all messages past their deadline
                 expireEphemeralMessages();
                 break;
-            case ACTION_IDLE_PING:
+            case ACTION_PING_IDLE:
                 scheduleNextIdlePing();
                 break;
             case ACTION_QUICK_LOG:
@@ -912,7 +875,7 @@ public class XmppConnectionService extends Service {
     }
 
     public void manageAccountConnectionStatesInternal() {
-        manageAccountConnectionStates(ACTION_INTERNAL_PING, null);
+        manageAccountConnectionStates(ACTION_PING_INTERNAL, null);
     }
 
     private synchronized void manageAccountConnectionStates(
@@ -1645,7 +1608,7 @@ public class XmppConnectionService extends Service {
             return;
         }
         final Intent intent = new Intent(this, SystemEventReceiver.class);
-        intent.setAction(ACTION_IDLE_PING);
+        intent.setAction(ACTION_PING_IDLE);
         try {
             final PendingIntent pendingIntent =
                     PendingIntent.getBroadcast(
@@ -1961,8 +1924,7 @@ public class XmppConnectionService extends Service {
         if (message.getEncryption() != Message.ENCRYPTION_NONE
                 && conversation.getMode() == Conversation.MODE_MULTI
                 && conversation.isPrivateAndNonAnonymous()) {
-            if (conversation.setAttribute(
-                    Conversation.ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, true)) {
+            if (conversation.setFormerlyPrivateNonAnonymous(true)) {
                 databaseBackend.updateConversation(conversation);
             }
         }
@@ -2088,7 +2050,7 @@ public class XmppConnectionService extends Service {
                         if (message.edited()) {
                             message.setBody(decryptedBody);
                             message.setEncryption(Message.ENCRYPTION_DECRYPTED);
-                            if (!databaseBackend.updateMessage(message, message.getEditedId())) {
+                            if (!databaseBackend.updateMessage(message, true)) {
                                 Log.e(Config.LOGTAG, "error updated message in DB after edit");
                             }
                             updateConversationUi();
@@ -2128,7 +2090,7 @@ public class XmppConnectionService extends Service {
             if (saveInDb) {
                 databaseBackend.createMessage(message);
             } else if (message.edited()) {
-                if (!databaseBackend.updateMessage(message, message.getEditedId())) {
+                if (!databaseBackend.updateMessage(message, false)) {
                     Log.e(Config.LOGTAG, "error updated message in DB after edit");
                 }
             }
@@ -2460,12 +2422,13 @@ public class XmppConnectionService extends Service {
                     final Account account = conversation.getAccount();
                     List<Message> messages =
                             databaseBackend.getMessages(conversation, 50, timestamp);
+                    final var lastClear = conversation.getLastClearHistory();
                     if (messages.size() > 0) {
                         conversation.addAll(0, messages);
                         callback.onMoreMessagesLoaded(messages.size(), conversation);
                     } else if (conversation.hasMessagesLeftOnServer()
                             && account.isOnlineAndConnected()
-                            && conversation.getLastClearHistory().getTimestamp() == 0) {
+                            && (lastClear == null || lastClear.timestamp() == 0)) {
                         final boolean mamAvailable;
                         if (conversation.getMode() == Conversation.MODE_SINGLE) {
                             mamAvailable =
@@ -3746,10 +3709,11 @@ public class XmppConnectionService extends Service {
         if (uuid == null) {
             return null;
         }
-        for (Conversation conversation : getConversations()) {
+        for (final var conversation : getConversations()) {
             if (conversation.getAddress().asBareJid().equals(recipient)
                     && conversation.getAccount() == account) {
-                final Message message = conversation.findSentMessageWithUuidOrRemoteId(uuid);
+                final Message message =
+                        conversation.findMessageWithUuidOrRemoteId(uuid, null, false);
                 if (message != null) {
                     markMessage(message, status, errorMessage);
                 }
@@ -4172,7 +4136,8 @@ public class XmppConnectionService extends Service {
     public void publishStickerPack(final Account account, final StickerStore.Pack pack) {
         final String nodeId = StickerStore.getOrCreateNodeId(XmppConnectionService.this, pack);
         Log.d(Config.LOGTAG, "XmppConnectionService: publishStickerPack account=" + account.getJid().asBareJid() + " pack=" + pack.displayName() + " node=" + nodeId);
-        final tel.xmpp.jabjab.xml.Element packItem = StickerStore.buildPepPackItem(pack);
+        final tel.xmpp.jabjab.xml.Element packItem =
+                StickerStore.buildPepPackItem(pack, account.getJid().asBareJid().toString());
         final Iq publish = mIqGenerator.publishStickerPack(nodeId, packItem);
         sendIqPacket(account, publish, result -> {
             if (result != null && result.getType() == Iq.Type.RESULT) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConferenceDetailsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ConferenceDetailsActivity.java
index 1c1f54f..3d32e89 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConferenceDetailsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConferenceDetailsActivity.java
@@ -55,6 +55,7 @@ import tel.xmpp.jabjab.xmpp.manager.BookmarkManager;
 import tel.xmpp.jabjab.xmpp.manager.MultiUserChatManager;
 import im.conversations.android.model.Bookmark;
 import im.conversations.android.xmpp.model.muc.Affiliation;
+import java.time.Instant;
 import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -122,8 +123,7 @@ public class ConferenceDetailsActivity extends XmppActivity
                         getString(R.string.notify_never)
                     };
                     final AtomicInteger choice;
-                    if (mConversation.getLongAttribute(Conversation.ATTRIBUTE_MUTED_TILL, 0)
-                            == Long.MAX_VALUE) {
+                    if (Instant.MAX.equals(mConversation.getMutedTill())) {
                         choice = new AtomicInteger(2);
                     } else {
                         choice = new AtomicInteger(mConversation.alwaysNotify() ? 0 : 1);
@@ -135,12 +135,10 @@ public class ConferenceDetailsActivity extends XmppActivity
                             R.string.ok,
                             (dialog, which) -> {
                                 if (choice.get() == 2) {
-                                    mConversation.setMutedTill(Long.MAX_VALUE);
+                                    mConversation.setMutedTill(Instant.MAX);
                                 } else {
-                                    mConversation.setMutedTill(0);
-                                    mConversation.setAttribute(
-                                            Conversation.ATTRIBUTE_ALWAYS_NOTIFY,
-                                            String.valueOf(choice.get() == 0));
+                                    mConversation.setMutedTill(null);
+                                    mConversation.setAlwaysNotify(choice.get() == 0);
                                 }
                                 xmppConnectionService.updateConversation(mConversation);
                                 updateView();
@@ -314,35 +312,36 @@ public class ConferenceDetailsActivity extends XmppActivity
         if (MenuDoubleTabUtil.shouldIgnoreTap()) {
             return false;
         }
-        switch (menuItem.getItemId()) {
-            case android.R.id.home:
-                finish();
-                break;
-            case R.id.action_share_http:
-                shareLink(true);
-                break;
-            case R.id.action_share_uri:
-                shareLink(false);
-                break;
-            case R.id.action_save_as_bookmark:
-                saveAsBookmark();
-                break;
-            case R.id.action_destroy_room:
-                destroyRoom();
-                break;
-            case R.id.action_server_info_show_more:
-                final var show = !menuItem.isChecked();
-                this.binding.mucInfoMore.setVisibility(show ? View.VISIBLE : View.GONE);
-                invalidateOptionsMenu();
-                updateView();
-                break;
-            case R.id.action_custom_notifications:
-                if (mConversation != null) {
-                    configureCustomNotifications(mConversation);
-                }
-                break;
+        final int itemId = menuItem.getItemId();
+        if (itemId == android.R.id.home) {
+            finish();
+            return true;
+        } else if (itemId == R.id.action_share_http) {
+            shareLink(true);
+            return true;
+        } else if (itemId == R.id.action_share_uri) {
+            shareLink(false);
+            return true;
+        } else if (itemId == R.id.action_save_as_bookmark) {
+            saveAsBookmark();
+            return true;
+        } else if (itemId == R.id.action_destroy_room) {
+            destroyRoom();
+            return true;
+        } else if (itemId == R.id.action_server_info_show_more) {
+            final var show = !menuItem.isChecked();
+            this.binding.mucInfoMore.setVisibility(show ? View.VISIBLE : View.GONE);
+            invalidateOptionsMenu();
+            updateView();
+            return true;
+        } else if (itemId == R.id.action_custom_notifications) {
+            if (mConversation != null) {
+                configureCustomNotifications(mConversation);
+            }
+            return true;
+        } else {
+            return super.onOptionsItemSelected(menuItem);
         }
-        return super.onOptionsItemSelected(menuItem);
     }
 
     private void configureCustomNotifications(final Conversation conversation) {
@@ -686,12 +685,12 @@ public class ConferenceDetailsActivity extends XmppActivity
             this.binding.mucSettings.setVisibility(View.GONE);
         }
 
-        final long mutedTill = mConversation.getLongAttribute(Conversation.ATTRIBUTE_MUTED_TILL, 0);
-        if (mutedTill == Long.MAX_VALUE) {
+        final var mutedTill = mConversation.getMutedTill();
+        if (Instant.MAX.equals(mutedTill)) {
             this.binding.notificationStatusText.setText(R.string.notify_never);
             this.binding.notificationStatusButton.setImageResource(
                     R.drawable.ic_notifications_off_24dp);
-        } else if (System.currentTimeMillis() < mutedTill) {
+        } else if (mutedTill != null && mutedTill.isBefore(Instant.now())) {
             this.binding.notificationStatusText.setText(R.string.notify_paused);
             this.binding.notificationStatusButton.setImageResource(
                     R.drawable.ic_notifications_paused_24dp);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
index 2eb9b19..d98b12c 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ContactDetailsActivity.java
@@ -346,68 +346,67 @@ public class ContactDetailsActivity extends OmemoActivity
         if (MenuDoubleTabUtil.shouldIgnoreTap()) {
             return false;
         }
-        switch (menuItem.getItemId()) {
-            case android.R.id.home:
-                finish();
-                break;
-            case R.id.action_share_http:
-                shareLink(true);
-                break;
-            case R.id.action_share_uri:
-                shareLink(false);
-                break;
-            case R.id.action_delete_contact:
-                final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
-                builder.setNegativeButton(getString(R.string.cancel), null);
-                builder.setTitle(getString(R.string.action_delete_contact))
-                        .setMessage(
-                                JidDialog.style(
-                                        this,
-                                        R.string.remove_contact_text,
-                                        contact.getAddress().toString()))
-                        .setPositiveButton(getString(R.string.delete), removeFromRoster)
-                        .create()
-                        .show();
-                break;
-            case R.id.action_edit_contact:
-                final Uri systemAccount = contact.getSystemAccount();
-                if (systemAccount == null) {
-                    quickEdit(
-                            contact.getServerName(),
-                            R.string.contact_name,
-                            value -> {
-                                contact.setServerName(value);
-                                final var connection = contact.getAccount().getXmppConnection();
-                                connection
-                                        .getManager(RosterManager.class)
-                                        .addRosterItem(contact, null);
-                                populateView();
-                                return null;
-                            },
-                            true);
-                } else {
-                    Intent intent = new Intent(Intent.ACTION_EDIT);
-                    intent.setDataAndType(systemAccount, Contacts.CONTENT_ITEM_TYPE);
-                    intent.putExtra("finishActivityOnSaveCompleted", true);
-                    try {
-                        startActivity(intent);
-                    } catch (ActivityNotFoundException e) {
-                        Toast.makeText(
-                                        ContactDetailsActivity.this,
-                                        R.string.no_application_found_to_view_contact,
-                                        Toast.LENGTH_SHORT)
-                                .show();
-                    }
+        final int itemId = menuItem.getItemId();
+        if (itemId == android.R.id.home) {
+            finish();
+            return true;
+        } else if (itemId == R.id.action_share_http) {
+            shareLink(true);
+            return true;
+        } else if (itemId == R.id.action_share_uri) {
+            shareLink(false);
+            return true;
+        } else if (itemId == R.id.action_delete_contact) {
+            final MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(this);
+            builder.setNegativeButton(getString(R.string.cancel), null);
+            builder.setTitle(getString(R.string.action_delete_contact))
+                    .setMessage(
+                            JidDialog.style(
+                                    this,
+                                    R.string.remove_contact_text,
+                                    contact.getAddress().toString()))
+                    .setPositiveButton(getString(R.string.delete), removeFromRoster)
+                    .create()
+                    .show();
+            return true;
+        } else if (itemId == R.id.action_edit_contact) {
+            final Uri systemAccount = contact.getSystemAccount();
+            if (systemAccount == null) {
+                quickEdit(
+                        contact.getServerName(),
+                        R.string.contact_name,
+                        value -> {
+                            contact.setServerName(value);
+                            final var connection = contact.getAccount().getXmppConnection();
+                            connection.getManager(RosterManager.class).addRosterItem(contact, null);
+                            populateView();
+                            return null;
+                        },
+                        true);
+            } else {
+                Intent intent = new Intent(Intent.ACTION_EDIT);
+                intent.setDataAndType(systemAccount, Contacts.CONTENT_ITEM_TYPE);
+                intent.putExtra("finishActivityOnSaveCompleted", true);
+                try {
+                    startActivity(intent);
+                } catch (ActivityNotFoundException e) {
+                    Toast.makeText(
+                                    ContactDetailsActivity.this,
+                                    R.string.no_application_found_to_view_contact,
+                                    Toast.LENGTH_SHORT)
+                            .show();
                 }
-                break;
-            case R.id.action_block, R.id.action_unblock:
-                BlockContactDialog.show(this, contact);
-                break;
-            case R.id.action_custom_notifications:
-                configureCustomNotifications(contact);
-                break;
+            }
+            return true;
+        } else if (itemId == R.id.action_block || itemId == R.id.action_unblock) {
+            BlockContactDialog.show(this, contact);
+            return true;
+        } else if (itemId == R.id.action_custom_notifications) {
+            configureCustomNotifications(contact);
+            return true;
+        } else {
+            return super.onOptionsItemSelected(menuItem);
         }
-        return super.onOptionsItemSelected(menuItem);
     }
 
     private void configureCustomNotifications(final Contact contact) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
index 160c914..55552fe 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationFragment.java
@@ -183,7 +183,6 @@ import java.util.Iterator;
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
-import java.util.UUID;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -381,7 +380,7 @@ public class ConversationFragment extends XmppFragment
             new OnClickListener() {
                 @Override
                 public void onClick(View v) {
-                    conversation.setAttribute("accept_non_anonymous", true);
+                    conversation.setAcceptNonAnonymous(true);
                     requireXmppActivity().xmppConnectionService.updateConversation(conversation);
                     requireXmppActivity().xmppConnectionService.joinMuc(conversation);
                 }
@@ -805,7 +804,7 @@ public class ConversationFragment extends XmppFragment
                         menuUnmute.setVisible(false);
                     }
                     ConversationMenuConfigurator.configureEncryptionMenu(c, menu, requireContext());
-                    if (c.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false)) {
+                    if (c.isPinnedOnTop()) {
                         menuTogglePinned.setTitle(R.string.remove_from_favorites);
                     } else {
                         menuTogglePinned.setTitle(R.string.add_to_favorites);
@@ -820,61 +819,42 @@ public class ConversationFragment extends XmppFragment
                     } else if (conversation == null) {
                         return false;
                     }
-                    switch (menuItem.getItemId()) {
-                        case R.id.encryption_choice_axolotl:
-                        case R.id.encryption_choice_none:
-                        case R.id.encryption_choice_pgp:
-                            handleEncryptionSelection(menuItem);
-                            break;
-                        case R.id.action_search:
-                            startSearch();
-                            break;
-                        case R.id.action_archive:
-                            requireXmppActivity()
-                                    .xmppConnectionService
-                                    .archiveConversation(conversation);
-                            break;
-                        case R.id.action_invite:
-                            startActivityForResult(
-                                    ChooseContactActivity.create(requireActivity(), conversation),
-                                    REQUEST_INVITE_TO_CONVERSATION);
-                            break;
-                        case R.id.action_clear_history:
-                            clearHistoryDialog(conversation);
-                            break;
-                        case R.id.action_mute:
-                            muteConversationDialog(conversation);
-                            break;
-                        case R.id.action_unmute:
-                            unMuteConversation(conversation);
-                            break;
-                        case R.id.action_block:
-                        case R.id.action_unblock:
-                            final Activity activity = getActivity();
-                            if (activity instanceof XmppActivity) {
-                                BlockContactDialog.show((XmppActivity) activity, conversation);
-                            }
-                            break;
-                        case R.id.action_audio_call:
-                            checkPermissionAndTriggerAudioCall();
-                            break;
-                        case R.id.action_video_call:
-                            checkPermissionAndTriggerVideoCall();
-                            break;
-                        case R.id.action_ongoing_call:
-                            returnToOngoingCall();
-                            break;
-                        case R.id.action_toggle_pinned:
-                            togglePinned();
-                            break;
-                        case R.id.action_pinned_messages:
-                            openPinnedMessagesList();
-                            break;
-                        case R.id.action_set_ephemeral_timer:
-                            showEphemeralTimerDialog(conversation);
-                            break;
-                        default:
-                            break;
+                    final int itemId = menuItem.getItemId();
+                    if (itemId == R.id.encryption_choice_axolotl
+                            || itemId == R.id.encryption_choice_none
+                            || itemId == R.id.encryption_choice_pgp) {
+                        handleEncryptionSelection(menuItem);
+                    } else if (itemId == R.id.action_search) {
+                        startSearch();
+                    } else if (itemId == R.id.action_archive) {
+                        requireXmppActivity().xmppConnectionService.archiveConversation(conversation);
+                    } else if (itemId == R.id.action_invite) {
+                        startActivityForResult(
+                                ChooseContactActivity.create(requireActivity(), conversation),
+                                REQUEST_INVITE_TO_CONVERSATION);
+                    } else if (itemId == R.id.action_clear_history) {
+                        clearHistoryDialog(conversation);
+                    } else if (itemId == R.id.action_mute) {
+                        muteConversationDialog(conversation);
+                    } else if (itemId == R.id.action_unmute) {
+                        unMuteConversation(conversation);
+                    } else if (itemId == R.id.action_block || itemId == R.id.action_unblock) {
+                        final Activity activity = getActivity();
+                        if (activity instanceof XmppActivity) {
+                            BlockContactDialog.show((XmppActivity) activity, conversation);
+                        }
+                    } else if (itemId == R.id.action_audio_call) {
+                        checkPermissionAndTriggerAudioCall();
+                    } else if (itemId == R.id.action_video_call) {
+                        checkPermissionAndTriggerVideoCall();
+                    } else if (itemId == R.id.action_ongoing_call) {
+                        returnToOngoingCall();
+                    } else if (itemId == R.id.action_toggle_pinned) {
+                        togglePinned();
+                    } else if (itemId == R.id.action_pinned_messages) {
+                        openPinnedMessagesList();
+                    } else if (itemId == R.id.action_set_ephemeral_timer) {
+                        showEphemeralTimerDialog(conversation);
                     }
                     return false;
                 }
@@ -1155,9 +1135,14 @@ public class ConversationFragment extends XmppFragment
             Message.configurePrivateMessage(message);
         } else {
             message = conversation.getCorrectingMessage();
-            message.setBody(body);
-            message.putEdited(message.getUuid(), message.getServerMsgId());
-            message.setUuid(UUID.randomUUID().toString());
+            final var uuid = message.getUuid();
+            // this resets both encryption and fingerprint; when using axolotl fingerprint will be
+            // added again on send
+            message.putEdited(
+                    new Message.BodyVersion(body, conversation.getNextEncryption(), null));
+            if (!DatabaseBackend.getInstance(requireContext()).updateMessage(message, uuid)) {
+                throw new IllegalStateException("Could not update message after edit");
+            }
         }
         if (conversation.getNextEncryption() == Message.ENCRYPTION_PGP) {
             sendPgpMessage(message);
@@ -2407,6 +2392,7 @@ public class ConversationFragment extends XmppFragment
             final MenuItem jumpToReply = menu.findItem(R.id.jump_to_reply);
             final MenuItem retryDecryption = menu.findItem(R.id.retry_decryption);
             final MenuItem correctMessage = menu.findItem(R.id.correct_message);
+            final MenuItem viewEditHistory = menu.findItem(R.id.view_edit_history);
             final MenuItem retractMessage = menu.findItem(R.id.retract_message);
             final MenuItem selectMessage = menu.findItem(R.id.action_select_message);
             final MenuItem shareWith = menu.findItem(R.id.share_with);
@@ -2474,9 +2460,8 @@ public class ConversationFragment extends XmppFragment
                                 : R.string.moderate_delete_dot_dot_dot);
                 correctMessage.setVisible(
                         !showError
-                                && m.getType() == Message.TYPE_TEXT
-                                && !m.isGeoUri()
-                                && m.isLastCorrectableMessage()
+                                && m.getStatus() != Message.STATUS_RECEIVED
+                                && m.acceptMessageCorrection()
                                 && singleOrOccupantId);
             } else {
                 moderateMessage.setVisible(false);
@@ -2539,6 +2524,7 @@ public class ConversationFragment extends XmppFragment
                 }
                 translateMessage.setVisible(!Strings.isNullOrEmpty(m.getBody()));
             }
+            viewEditHistory.setVisible(m.hasEditHistory());
             if (m.getEncryption() == Message.ENCRYPTION_DECRYPTION_FAILED && !deleted) {
                 retryDecryption.setVisible(true);
             }
@@ -2655,125 +2641,107 @@ public class ConversationFragment extends XmppFragment
         if (!(getActivity() instanceof XmppActivity)) {
             return false;
         }
-        return switch (item.getItemId()) {
-            case R.id.share_with -> {
-                ShareUtil.share(requireXmppActivity(), selectedMessage);
-                yield true;
-            }
-            case R.id.correct_message -> {
-                correctMessage(selectedMessage);
-                yield true;
-            }
-            case R.id.copy_message -> {
-                ShareUtil.copyToClipboard(requireXmppActivity(), selectedMessage);
-                yield true;
-            }
-            case R.id.copy_link -> {
-                ShareUtil.copyLinkToClipboard(requireXmppActivity(), selectedMessage);
-                yield true;
-            }
-            case R.id.translate_message -> {
-                translateMessage(selectedMessage);
-                yield true;
-            }
-            case R.id.quote_message -> {
-                quoteMessage(selectedMessage);
-                yield true;
-            }
-            case R.id.jump_to_reply -> {
-                jumpToReplyOf(selectedMessage);
-                yield true;
-            }
-            case R.id.send_again -> {
-                resendMessage(selectedMessage, false);
-                yield true;
-            }
-            case R.id.send_again_as_p2p -> {
-                resendMessage(selectedMessage, true);
-                yield true;
-            }
-            case R.id.copy_url -> {
-                ShareUtil.copyUrlToClipboard(requireXmppActivity(), selectedMessage);
-                yield true;
-            }
-            case R.id.share_upload_link -> {
-                ShareUtil.shareUploadLink(requireXmppActivity(), selectedMessage);
-                yield true;
-            }
-            case R.id.download_file -> {
-                startDownloadable(selectedMessage);
-                yield true;
-            }
-            case R.id.cancel_transmission -> {
-                cancelTransmission(selectedMessage);
-                yield true;
-            }
-            case R.id.retry_decryption -> {
-                retryDecryption(selectedMessage);
-                yield true;
-            }
-            case R.id.delete_file -> {
-                deleteFileDialog(selectedMessage);
-                yield true;
-            }
-            case R.id.save_file -> {
-                saveFile(selectedMessage);
-                yield true;
-            }
-            case R.id.save_to_stickers -> {
-                saveMessageToStickers(selectedMessage);
-                yield true;
-            }
-            case R.id.save_gif_to_favorites -> {
-                saveGifToFavorites(selectedMessage);
-                yield true;
-            }
-            case R.id.action_install_sticker_pack -> {
-                installStickerPackFromMessage(selectedMessage);
-                yield true;
-            }
-            case R.id.moderation -> {
-                moderate(selectedMessage);
-                yield true;
-            }
-            case R.id.show_error_message -> {
-                showErrorMessage(selectedMessage);
-                yield true;
-            }
-            case R.id.open_with -> {
-                openWith(selectedMessage);
-                yield true;
-            }
-            case R.id.action_report_and_block -> {
-                reportMessage(selectedMessage);
-                yield true;
-            }
-            case R.id.action_add_reaction -> {
-                addReaction(selectedMessage);
-                yield true;
-            }
-            case R.id.retract_message -> {
-                requireXmppActivity().xmppConnectionService.retractMessage(selectedMessage);
-                yield true;
-            }
-            case R.id.action_pin_message -> {
-                togglePinMessage(selectedMessage, true);
-                yield true;
-            }
-            case R.id.action_unpin_message -> {
-                togglePinMessage(selectedMessage, false);
-                yield true;
-            }
-            case R.id.action_select_message -> {
-                enterSelectionMode(selectedMessage);
-                yield true;
-            }
-            case R.id.forward_to_contacts -> {
-                forwardToMultipleContacts(selectedMessage);
-                yield true;
-            }
-            default -> super.onContextItemSelected(item);
-        };
+        final int itemId = item.getItemId();
+        if (itemId == R.id.share_with) {
+            ShareUtil.share(requireXmppActivity(), selectedMessage);
+            return true;
+        } else if (itemId == R.id.correct_message) {
+            correctMessage(selectedMessage);
+            return true;
+        } else if (itemId == R.id.copy_message) {
+            ShareUtil.copyToClipboard(requireXmppActivity(), selectedMessage);
+            return true;
+        } else if (itemId == R.id.copy_link) {
+            ShareUtil.copyLinkToClipboard(requireXmppActivity(), selectedMessage);
+            return true;
+        } else if (itemId == R.id.translate_message) {
+            translateMessage(selectedMessage);
+            return true;
+        } else if (itemId == R.id.quote_message) {
+            quoteMessage(selectedMessage);
+            return true;
+        } else if (itemId == R.id.jump_to_reply) {
+            jumpToReplyOf(selectedMessage);
+            return true;
+        } else if (itemId == R.id.send_again) {
+            resendMessage(selectedMessage, false);
+            return true;
+        } else if (itemId == R.id.send_again_as_p2p) {
+            resendMessage(selectedMessage, true);
+            return true;
+        } else if (itemId == R.id.copy_url) {
+            ShareUtil.copyUrlToClipboard(requireXmppActivity(), selectedMessage);
+            return true;
+        } else if (itemId == R.id.share_upload_link) {
+            ShareUtil.shareUploadLink(requireXmppActivity(), selectedMessage);
+            return true;
+        } else if (itemId == R.id.download_file) {
+            startDownloadable(selectedMessage);
+            return true;
+        } else if (itemId == R.id.cancel_transmission) {
+            cancelTransmission(selectedMessage);
+            return true;
+        } else if (itemId == R.id.retry_decryption) {
+            retryDecryption(selectedMessage);
+            return true;
+        } else if (itemId == R.id.delete_file) {
+            deleteFileDialog(selectedMessage);
+            return true;
+        } else if (itemId == R.id.save_file) {
+            saveFile(selectedMessage);
+            return true;
+        } else if (itemId == R.id.save_to_stickers) {
+            saveMessageToStickers(selectedMessage);
+            return true;
+        } else if (itemId == R.id.save_gif_to_favorites) {
+            saveGifToFavorites(selectedMessage);
+            return true;
+        } else if (itemId == R.id.action_install_sticker_pack) {
+            installStickerPackFromMessage(selectedMessage);
+            return true;
+        } else if (itemId == R.id.moderation) {
+            moderate(selectedMessage);
+            return true;
+        } else if (itemId == R.id.show_error_message) {
+            showErrorMessage(selectedMessage);
+            return true;
+        } else if (itemId == R.id.open_with) {
+            openWith(selectedMessage);
+            return true;
+        } else if (itemId == R.id.action_report_and_block) {
+            reportMessage(selectedMessage);
+            return true;
+        } else if (itemId == R.id.action_add_reaction) {
+            addReaction(selectedMessage);
+            return true;
+        } else if (itemId == R.id.view_edit_history) {
+            viewEditHistory(selectedMessage);
+            return true;
+        } else if (itemId == R.id.retract_message) {
+            requireXmppActivity().xmppConnectionService.retractMessage(selectedMessage);
+            return true;
+        } else if (itemId == R.id.action_pin_message) {
+            togglePinMessage(selectedMessage, true);
+            return true;
+        } else if (itemId == R.id.action_unpin_message) {
+            togglePinMessage(selectedMessage, false);
+            return true;
+        } else if (itemId == R.id.action_select_message) {
+            enterSelectionMode(selectedMessage);
+            return true;
+        } else if (itemId == R.id.forward_to_contacts) {
+            forwardToMultipleContacts(selectedMessage);
+            return true;
+        } else {
+            return super.onContextItemSelected(item);
+        }
+    }
+
+    private void viewEditHistory(final Message message) {
+        final var intent = new Intent(getActivity(), EditHistoryActivity.class);
+        intent.putExtra(EditHistoryActivity.EXTRA_CONVERSATION_UUID, message.getConversationUuid());
+        intent.putExtra(EditHistoryActivity.EXTRA_MESSAGE_UUID, message.getUuid());
+        startActivity(intent);
     }
 
     private void forwardToMultipleContacts(final Message message) {
@@ -2901,9 +2869,8 @@ public class ConversationFragment extends XmppFragment
     }
 
     private void togglePinned() {
-        final boolean pinned =
-                conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false);
-        conversation.setAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, !pinned);
+        final boolean pinned = conversation.isPinnedOnTop();
+        conversation.setPinnedOnTop(!pinned);
         requireXmppActivity().xmppConnectionService.updateConversation(conversation);
         this.binding.toolbar.invalidateMenu();
     }
@@ -3088,16 +3055,7 @@ public class ConversationFragment extends XmppFragment
         if (isAccountInsufficientState()) {
             return;
         }
-        final List<String> permissions;
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-            permissions =
-                    Arrays.asList(
-                            Manifest.permission.RECORD_AUDIO,
-                            Manifest.permission.BLUETOOTH_CONNECT);
-        } else {
-            permissions = Collections.singletonList(Manifest.permission.RECORD_AUDIO);
-        }
-        if (hasPermissions(REQUEST_START_AUDIO_CALL, permissions)) {
+        if (hasPermissions(REQUEST_START_AUDIO_CALL, PermissionUtils.rtpPermissions(false))) {
             triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VOICE_CALL);
         }
     }
@@ -3106,18 +3064,7 @@ public class ConversationFragment extends XmppFragment
         if (isAccountInsufficientState()) {
             return;
         }
-        final List<String> permissions;
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-            permissions =
-                    Arrays.asList(
-                            Manifest.permission.RECORD_AUDIO,
-                            Manifest.permission.CAMERA,
-                            Manifest.permission.BLUETOOTH_CONNECT);
-        } else {
-            permissions =
-                    Arrays.asList(Manifest.permission.RECORD_AUDIO, Manifest.permission.CAMERA);
-        }
-        if (hasPermissions(REQUEST_START_VIDEO_CALL, permissions)) {
+        if (hasPermissions(REQUEST_START_VIDEO_CALL, PermissionUtils.rtpPermissions(true))) {
             triggerRtpSession(RtpSessionActivity.ACTION_MAKE_VIDEO_CALL);
         }
     }
@@ -3181,32 +3128,29 @@ public class ConversationFragment extends XmppFragment
             return;
         }
         final boolean updated;
-        switch (item.getItemId()) {
-            case R.id.encryption_choice_none:
-                updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
-                item.setChecked(true);
-                break;
-            case R.id.encryption_choice_axolotl:
-                Log.d(
-                        Config.LOGTAG,
-                        AxolotlService.getLogprefix(conversation.getAccount())
-                                + "Enabled axolotl for Contact "
-                                + conversation.getContact().getAddress());
-                updated = conversation.setNextEncryption(Message.ENCRYPTION_AXOLOTL);
-                item.setChecked(true);
-                break;
-            case R.id.encryption_choice_pgp:
-                if (!requireXmppActivity().hasPgp()) {
-                    requireXmppActivity().showInstallPgpDialog();
-                    return;
-                }
-                requireXmppActivity().announcePgp(
-                        conversation.getAccount(), conversation, null,
-                        requireXmppActivity().onOpenPGPKeyPublished);
-                return; // announcePgp handles encryption state + UI refresh itself
-            default:
-                updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
-                break;
+        final int itemId = item.getItemId();
+        if (itemId == R.id.encryption_choice_none) {
+            updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
+            item.setChecked(true);
+        } else if (itemId == R.id.encryption_choice_axolotl) {
+            Log.d(
+                    Config.LOGTAG,
+                    AxolotlService.getLogprefix(conversation.getAccount())
+                            + "Enabled axolotl for Contact "
+                            + conversation.getContact().getAddress());
+            updated = conversation.setNextEncryption(Message.ENCRYPTION_AXOLOTL);
+            item.setChecked(true);
+        } else if (itemId == R.id.encryption_choice_pgp) {
+            if (!requireXmppActivity().hasPgp()) {
+                requireXmppActivity().showInstallPgpDialog();
+                return;
+            }
+            requireXmppActivity().announcePgp(
+                    conversation.getAccount(), conversation, null,
+                    requireXmppActivity().onOpenPGPKeyPublished);
+            return; // announcePgp handles encryption state + UI refresh itself
+        } else {
+            updated = conversation.setNextEncryption(Message.ENCRYPTION_NONE);
         }
         if (updated) {
             requireXmppActivity().xmppConnectionService.updateConversation(conversation);
@@ -3309,12 +3253,12 @@ public class ConversationFragment extends XmppFragment
     @Override
     public void onRequestPermissionsResult(
             int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
-        final PermissionUtils.PermissionResult permissionResult =
-                PermissionUtils.removeBluetoothConnect(permissions, grantResults);
+        final var permissionResult =
+                PermissionUtils.removeOptionalRtpPermissions(permissions, grantResults);
         if (grantResults.length == 0) {
             return;
         }
-        if (allGranted(permissionResult.grantResults)) {
+        if (allGranted(permissionResult.grantResults())) {
             switch (requestCode) {
                 case REQUEST_START_DOWNLOAD:
                     if (this.mPendingDownloadableMessage != null) {
@@ -3341,7 +3285,7 @@ public class ConversationFragment extends XmppFragment
             }
         } else {
             final var firstDenied =
-                    getFirstDenied(permissionResult.grantResults, permissionResult.permissions);
+                    getFirstDenied(permissionResult.grantResults(), permissionResult.permissions());
             @StringRes
             final int res =
                     switch (firstDenied) {
@@ -3449,11 +3393,11 @@ public class ConversationFragment extends XmppFragment
         builder.setItems(
                 labels,
                 (dialog, which) -> {
-                    final long till;
+                    final Instant till;
                     if (durations[which] == -1) {
-                        till = Long.MAX_VALUE;
+                        till = Instant.MAX;
                     } else {
-                        till = System.currentTimeMillis() + (durations[which] * 1000L);
+                        till = Instant.now().plus(Duration.ofSeconds(durations[which]));
                     }
                     conversation.setMutedTill(till);
                     requireXmppActivity().xmppConnectionService.updateConversation(conversation);
@@ -3495,7 +3439,7 @@ public class ConversationFragment extends XmppFragment
     }
 
     public void unMuteConversation(final Conversation conversation) {
-        conversation.setMutedTill(0);
+        conversation.setMutedTill(null);
         requireXmppActivity().xmppConnectionService.updateConversation(conversation);
         requireConversationsActivity().onConversationsListItemUpdated();
         refresh();
@@ -4329,10 +4273,14 @@ public class ConversationFragment extends XmppFragment
     public void onStop() {
         super.onStop();
         final Activity activity = getActivity();
+        // Detaches the proximity-sensor listener and this fragment's bubble ViewHolder/layout
+        // registrations — correct regardless of why this fragment is stopping. Playback itself
+        // is intentionally left running: it should survive switching chats/tabs within the app,
+        // and only stops when the whole app leaves the foreground (see the
+        // ActivityLifecycleCallbacks counter in Conversations / AudioPlayer.onAppBackgrounded).
         messageListAdapter.unregisterListenerInAudioPlayer();
         if (activity == null || !activity.isChangingConfigurations()) {
             hideSoftKeyboard(activity);
-            messageListAdapter.stopAudioPlayer();
         }
         if (this.conversation != null) {
             final String msg = CharSequences.nullToEmpty(this.binding.textInput.getText());
@@ -4351,16 +4299,20 @@ public class ConversationFragment extends XmppFragment
         ChatStateManager.send(conversation, state);
     }
 
-    private void saveMessageDraftStopAudioPlayer() {
+    private void saveMessageDraft() {
         final Conversation previousConversation = this.conversation;
         if (this.binding == null || previousConversation == null) {
             return;
         }
-        Log.d(Config.LOGTAG, "ConversationFragment.saveMessageDraftStopAudioPlayer()");
+        Log.d(Config.LOGTAG, "ConversationFragment.saveMessageDraft()");
         final String msg = CharSequences.nullToEmpty(this.binding.textInput.getText());
         storeNextMessage(msg);
         updateChatState(this.conversation, msg);
-        messageListAdapter.stopAudioPlayer();
+        // Detach (but don't stop) the audio player: the same MessageAdapter/AudioPlayer
+        // instance is reused across conversations in this fragment, so its bubble/sensor
+        // registrations belong to the conversation being left, not the one being opened.
+        // Playback itself keeps running — see onStop() above for why.
+        messageListAdapter.unregisterListenerInAudioPlayer();
         mediaPreviewAdapter.clearPreviews();
         toggleInputMethod();
     }
@@ -4369,7 +4321,7 @@ public class ConversationFragment extends XmppFragment
         QuickLoader.set(conversation.getUuid());
         final boolean changedConversation = this.conversation != conversation;
         if (changedConversation) {
-            this.saveMessageDraftStopAudioPlayer();
+            this.saveMessageDraft();
         }
         this.clearPending();
         if (this.reInit(conversation, extras != null)) {
@@ -4432,8 +4384,9 @@ public class ConversationFragment extends XmppFragment
         final boolean participating =
                 conversation.getMode() == Conversational.MODE_SINGLE
                         || conversation.getMucOptions().participating();
-        if (participating) {
-            this.binding.textInput.setText(this.conversation.getNextMessage());
+        final var draft = this.conversation.getDraft();
+        if (participating && draft != null) {
+            this.binding.textInput.setText(draft.message());
             this.binding.textInput.setSelection(this.binding.textInput.length());
         } else {
             this.binding.textInput.setText(CharSequences.EMPTY_STRING);
@@ -4754,26 +4707,22 @@ public class ConversationFragment extends XmppFragment
         popupMenu.getMenu().findItem(R.id.reject).setVisible(showReject);
         popupMenu.setOnMenuItemClickListener(
                 menuItem -> {
-                    Blockable blockable;
-                    switch (menuItem.getItemId()) {
-                        case R.id.reject:
-                            requireXmppActivity()
-                                    .xmppConnectionService
-                                    .stopPresenceUpdatesTo(conversation.getContact());
-                            updateSnackBar(conversation);
-                            return true;
-                        case R.id.block_domain:
-                            blockable =
-                                    conversation
-                                            .getAccount()
-                                            .getRoster()
-                                            .getContact(jid.getDomain());
-                            break;
-                        default:
-                            blockable = conversation;
+                    final int itemId = menuItem.getItemId();
+                    if (itemId == R.id.reject) {
+                        requireXmppActivity()
+                                .xmppConnectionService
+                                .stopPresenceUpdatesTo(conversation.getContact());
+                        updateSnackBar(conversation);
+                        return true;
+                    } else if (itemId == R.id.block_domain) {
+                        final Blockable blockable =
+                                conversation.getAccount().getRoster().getContact(jid.getDomain());
+                        BlockContactDialog.show(requireXmppActivity(), blockable);
+                        return true;
+                    } else {
+                        BlockContactDialog.show(requireXmppActivity(), conversation);
+                        return true;
                     }
-                    BlockContactDialog.show(requireXmppActivity(), blockable);
-                    return true;
                 });
         popupMenu.show();
         return true;
@@ -5537,8 +5486,9 @@ public class ConversationFragment extends XmppFragment
         final var connection = c.getAccount().getXmppConnection();
         final boolean mam = hasMamSupport(c) && !c.getContact().isBlocked();
         final MessageArchiveManager service = connection.getManager(MessageArchiveManager.class);
+        final var lastClearHistory = c.getLastClearHistory();
         return mam
-                && (c.getLastClearHistory().getTimestamp() != 0
+                && ((lastClearHistory != null && lastClearHistory.timestamp() != 0)
                         || (c.countMessages() == 0
                                 && c.messagesLoaded.get()
                                 && c.hasMessagesLeftOnServer()
@@ -5961,19 +5911,18 @@ public class ConversationFragment extends XmppFragment
                 popupMenu.inflate(R.menu.one_on_one_context);
                 popupMenu.setOnMenuItemClickListener(
                         item -> {
-                            switch (item.getItemId()) {
-                                case R.id.action_contact_details:
-                                    requireXmppActivity()
-                                            .switchToContactDetails(
-                                                    message.getContact(), fingerprint);
-                                    break;
-                                case R.id.action_show_qr_code:
-                                    final var uri =
-                                            new MiniUri.Xmpp(message.getContact().getAddress());
-                                    requireXmppActivity().showQrCode(uri);
-                                    break;
+                            final int itemId = item.getItemId();
+                            if (itemId == R.id.action_contact_details) {
+                                requireXmppActivity()
+                                        .switchToContactDetails(message.getContact(), fingerprint);
+                                return true;
+                            } else if (itemId == R.id.action_show_qr_code) {
+                                final var uri = new MiniUri.Xmpp(message.getContact().getAddress());
+                                requireXmppActivity().showQrCode(uri);
+                                return true;
+                            } else {
+                                return true;
                             }
-                            return true;
                         });
             }
         } else {
@@ -5983,21 +5932,21 @@ public class ConversationFragment extends XmppFragment
                     .setVisible(QuickConversationsService.isConversations());
             popupMenu.setOnMenuItemClickListener(
                     item -> {
-                        switch (item.getItemId()) {
-                            case R.id.action_show_qr_code:
-                                requireXmppActivity().showQrCode(conversation.getAccount());
-                                break;
-                            case R.id.action_account_details:
-                                requireXmppActivity()
-                                        .switchToAccount(
-                                                message.getConversation().getAccount(),
-                                                fingerprint);
-                                break;
-                            case R.id.action_manage_accounts:
-                                AccountUtils.launchManageAccounts(requireActivity());
-                                break;
+                        final int itemId = item.getItemId();
+                        if (itemId == R.id.action_show_qr_code) {
+                            requireXmppActivity().showQrCode(conversation.getAccount());
+                            return true;
+                        } else if (itemId == R.id.action_account_details) {
+                            requireXmppActivity()
+                                    .switchToAccount(
+                                            message.getConversation().getAccount(), fingerprint);
+                            return true;
+                        } else if (itemId == R.id.action_manage_accounts) {
+                            AccountUtils.launchManageAccounts(requireActivity());
+                            return true;
+                        } else {
+                            return true;
                         }
-                        return true;
                     });
         }
         popupMenu.show();
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
index a6a7784..dc5b45c 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ConversationsOverviewFragment.java
@@ -414,6 +414,9 @@ public class ConversationsOverviewFragment extends XmppFragment {
         showTranslationFeatureBanner();
         showAnimatedBannerFeatureBanner();
         showReactionNotificationsFeatureBanner();
+        showMessageCorrectionFeatureBanner();
+        showSettingsSearchFeatureBanner();
+        showAudioMiniPlayerFeatureBanner();
 
         this.conversationsAdapter =
                 new ConversationAdapter(requireXmppActivity(), this.conversations);
@@ -478,6 +481,30 @@ public class ConversationsOverviewFragment extends XmppFragment {
                 });
     }
 
+    private void showMessageCorrectionFeatureBanner() {
+        this.binding.featureBanner.registerBanner(
+                "message_correction_feature",
+                R.drawable.ic_edit_24dp,
+                getString(R.string.message_correction_feature_banner_text),
+                () -> {});
+    }
+
+    private void showSettingsSearchFeatureBanner() {
+        this.binding.featureBanner.registerBanner(
+                "settings_search_feature",
+                R.drawable.ic_search_24dp,
+                getString(R.string.settings_search_feature_banner_text),
+                () -> startActivity(new Intent(requireContext(), SettingsActivity.class)));
+    }
+
+    private void showAudioMiniPlayerFeatureBanner() {
+        this.binding.featureBanner.registerBanner(
+                "audio_mini_player_feature",
+                R.drawable.ic_mic_24dp,
+                getString(R.string.audio_mini_player_feature_banner_text),
+                () -> {});
+    }
+
     private void startSearch(final String term) {
         final var intent = new Intent(requireContext(), SearchActivity.class);
         intent.putExtra(SearchActivity.EXTRA_SEARCH_TERM, term);
diff --git a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
index 9da2ca2..72f2127 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/EditAccountActivity.java
@@ -1,12 +1,15 @@
 package tel.xmpp.jabjab.ui;
 
+import android.Manifest;
 import android.app.Activity;
 import android.content.ActivityNotFoundException;
 import android.content.Intent;
 import android.content.IntentSender;
 import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
 import android.graphics.Bitmap;
 import android.net.Uri;
+import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.preference.PreferenceManager;
@@ -120,6 +123,7 @@ public class EditAccountActivity extends OmemoActivity
     private static final int REQUEST_DATA_SAVER = 0xf244;
     private static final int REQUEST_CHANGE_STATUS = 0xee11;
     private static final int REQUEST_ORBOT = 0xff22;
+    private static final int REQUEST_LOCAL_NETWORK = 0xff33;
     private final PendingItem<PresenceTemplate> mPendingPresenceTemplate = new PendingItem<>();
     private AlertDialog mCaptchaDialog = null;
     private Jid jidToEdit;
@@ -227,7 +231,15 @@ public class EditAccountActivity extends OmemoActivity
                         }
                         return;
                     }
-
+                    if (mAccount != null
+                            && mAccount.getStatus()
+                                    == Account.State.MISSING_LOCAL_NETWORK_PERMISSION
+                            && Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
+                        requestPermissions(
+                                new String[] {Manifest.permission.ACCESS_LOCAL_NETWORK},
+                                REQUEST_LOCAL_NETWORK);
+                        return;
+                    }
                     if (inNeedOfSaslAccept()) {
                         mAccount.resetPinnedMechanism();
                         if (!xmppConnectionService.updateAccount(mAccount)) {
@@ -677,6 +689,42 @@ public class EditAccountActivity extends OmemoActivity
                 });
     }
 
+    @Override
+    public void onRequestPermissionsResult(
+            int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+        if (grantResults.length == 0) {
+            return;
+        }
+        if (requestCode == REQUEST_LOCAL_NETWORK) {
+            if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
+                if (xmppConnectionService != null && mAccount != null) {
+                    xmppConnectionService.reconnectAccount(mAccount, true);
+                }
+            } else {
+                showLanPermissionDialog();
+            }
+        }
+    }
+
+    private void showLanPermissionDialog() {
+        new MaterialAlertDialogBuilder(this)
+                .setTitle(R.string.missing_local_network_permission)
+                .setMessage(getString(R.string.no_lan_permission, getString(R.string.app_name)))
+                .setNegativeButton(R.string.cancel, null)
+                .setPositiveButton(
+                        R.string.open_settings,
+                        (dialogInterface, i) -> {
+                            final var intent =
+                                    new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+                            intent.setData(Uri.fromParts("package", getPackageName(), null));
+                            startActivity(intent);
+                        })
+                .create()
+                .show();
+        ;
+    }
+
     @Override
     public void onActivityResult(int requestCode, int resultCode, Intent data) {
         // TODO check for Camera / Scan permission
@@ -744,6 +792,10 @@ public class EditAccountActivity extends OmemoActivity
                 && !mInitMode) {
             this.binding.saveButton.setEnabled(true);
             this.binding.saveButton.setText(R.string.enable);
+        } else if (mAccount != null
+                && mAccount.getStatus() == Account.State.MISSING_LOCAL_NETWORK_PERMISSION) {
+            this.binding.saveButton.setEnabled(true);
+            this.binding.saveButton.setText(R.string.grant_permission);
         } else if (torNeedsInstall(mAccount)) {
             this.binding.saveButton.setEnabled(true);
             this.binding.saveButton.setText(R.string.install_orbot);
@@ -1128,56 +1180,46 @@ public class EditAccountActivity extends OmemoActivity
         if (MenuDoubleTabUtil.shouldIgnoreTap()) {
             return false;
         }
-        switch (item.getItemId()) {
-            case android.R.id.home:
-                deleteAccountAndReturnIfNecessary();
-                break;
-            case R.id.action_edit_profile:
-                if (mAccount == null) break;
+        final int itemId = item.getItemId();
+        if (itemId == android.R.id.home) {
+            deleteAccountAndReturnIfNecessary();
+        } else if (itemId == R.id.action_edit_profile) {
+            if (mAccount != null) {
                 final Intent editProfileIntent = new Intent(this, EditProfileActivity.class);
                 editProfileIntent.putExtra(
                         EditProfileActivity.EXTRA_ACCOUNT,
                         mAccount.getJid().asBareJid().toString());
                 startActivity(editProfileIntent);
-                break;
-            case R.id.action_show_block_list:
-                if (mAccount == null) break;
+            }
+        } else if (itemId == R.id.action_show_block_list) {
+            if (mAccount != null) {
                 final Intent showBlocklistIntent = new Intent(this, BlocklistActivity.class);
                 showBlocklistIntent.putExtra(EXTRA_ACCOUNT, mAccount.getJid().toString());
                 startActivity(showBlocklistIntent);
-                break;
-            case R.id.action_server_info_show_more:
-                changeMoreTableVisibility(!item.isChecked());
-                break;
-            case R.id.action_share_barcode:
-                shareBarcode();
-                break;
-            case R.id.action_share_http:
-                shareInviteLink(true);
-                break;
-            case R.id.action_share_uri:
-                shareInviteLink(false);
-                break;
-            case R.id.action_show_qr_code:
-                if (mAccount != null) {
-                    showQrCode(mAccount);
-                }
-                return true;
-            case R.id.action_change_password_on_server:
-                gotoChangePassword();
-                break;
-            case R.id.action_delete_account:
-                deleteAccount();
-                break;
-            case R.id.action_mam_prefs:
-                editMamPrefs();
-                break;
-            case R.id.action_renew_certificate:
-                renewCertificate();
-                break;
-            case R.id.action_change_presence:
-                changePresence();
-                break;
+            }
+        } else if (itemId == R.id.action_server_info_show_more) {
+            changeMoreTableVisibility(!item.isChecked());
+        } else if (itemId == R.id.action_share_barcode) {
+            shareBarcode();
+        } else if (itemId == R.id.action_share_http) {
+            shareInviteLink(true);
+        } else if (itemId == R.id.action_share_uri) {
+            shareInviteLink(false);
+        } else if (itemId == R.id.action_show_qr_code) {
+            if (mAccount != null) {
+                showQrCode(mAccount);
+            }
+            return true;
+        } else if (itemId == R.id.action_change_password_on_server) {
+            gotoChangePassword();
+        } else if (itemId == R.id.action_delete_account) {
+            deleteAccount();
+        } else if (itemId == R.id.action_mam_prefs) {
+            editMamPrefs();
+        } else if (itemId == R.id.action_renew_certificate) {
+            renewCertificate();
+        } else if (itemId == R.id.action_change_presence) {
+            changePresence();
         }
         return super.onOptionsItemSelected(item);
     }
@@ -1622,7 +1664,8 @@ public class EditAccountActivity extends OmemoActivity
                     || Arrays.asList(
                                     Account.State.NO_INTERNET,
                                     Account.State.AIRPLANE_MODE,
-                                    Account.State.MISSING_INTERNET_PERMISSION)
+                                    Account.State.MISSING_INTERNET_PERMISSION,
+                                    Account.State.MISSING_LOCAL_NETWORK_PERMISSION)
                             .contains(status)) {
                 if (status == Account.State.UNAUTHORIZED
                         || status == Account.State.DOWNGRADE_ATTACK) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/EditHistoryActivity.java b/src/main/java/tel/xmpp/jabjab/ui/EditHistoryActivity.java
new file mode 100644
index 0000000..f9bf661
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/EditHistoryActivity.java
@@ -0,0 +1,132 @@
+package tel.xmpp.jabjab.ui;
+
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+import androidx.databinding.DataBindingUtil;
+import com.google.android.material.color.MaterialColors;
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.databinding.ActivitySearchBinding;
+import tel.xmpp.jabjab.entities.Contact;
+import tel.xmpp.jabjab.entities.Message;
+import tel.xmpp.jabjab.persistance.DatabaseBackend;
+import tel.xmpp.jabjab.ui.adapter.MessageAdapter;
+import tel.xmpp.jabjab.ui.util.DateSeparator;
+import tel.xmpp.jabjab.ui.util.ListViewUtils;
+import java.util.ArrayList;
+import java.util.List;
+
+public class EditHistoryActivity extends XmppActivity
+        implements MessageAdapter.OnContactPictureClicked {
+
+    public static final String EXTRA_CONVERSATION_UUID = "conversation-uuid";
+    public static final String EXTRA_MESSAGE_UUID = "message-uuid";
+
+    private ActivitySearchBinding binding;
+    private MessageAdapter messageListAdapter;
+    private final List<Message> messages = new ArrayList<>();
+    private String conversationUuid;
+    private String messageUuid;
+
+    @Override
+    public void onCreate(final Bundle bundle) {
+        super.onCreate(bundle);
+        final Intent intent = getIntent();
+        final Bundle parameters;
+        if (bundle != null) {
+            parameters = bundle;
+        } else if (intent != null) {
+            parameters = intent.getExtras();
+        } else {
+            parameters = null;
+        }
+        if (parameters != null) {
+            this.conversationUuid = parameters.getString(EXTRA_CONVERSATION_UUID);
+            this.messageUuid = parameters.getString(EXTRA_MESSAGE_UUID);
+        } else {
+            this.conversationUuid = null;
+            this.messageUuid = null;
+        }
+        this.binding = DataBindingUtil.setContentView(this, R.layout.activity_search);
+        this.binding.searchResults.setBackgroundColor(
+                MaterialColors.getColor(
+                        binding.searchResults, com.google.android.material.R.attr.colorSurface));
+        Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
+        setSupportActionBar(this.binding.toolbar);
+        configureActionBar(getSupportActionBar());
+        this.messageListAdapter = new MessageAdapter(this, this.messages, false);
+        this.messageListAdapter.setOnContactPictureClicked(this);
+        this.binding.searchResults.setAdapter(messageListAdapter);
+    }
+
+    @Override
+    public void onSaveInstanceState(@NonNull Bundle bundle) {
+        if (this.conversationUuid != null && this.messageUuid != null) {
+            bundle.putString(EXTRA_CONVERSATION_UUID, this.conversationUuid);
+            bundle.putString(EXTRA_MESSAGE_UUID, this.messageUuid);
+        }
+        super.onSaveInstanceState(bundle);
+    }
+
+    @Override
+    protected void refreshUiReal() {}
+
+    @Override
+    protected void onBackendConnected() {
+        if (this.conversationUuid == null || this.messageUuid == null) {
+            return;
+        }
+        final var c = xmppConnectionService.findConversationByUuid(this.conversationUuid);
+        if (c == null) {
+            return;
+        }
+        final var future =
+                DatabaseBackend.getInstance(this).getMessageWithUuidFuture(c, this.messageUuid);
+        Futures.addCallback(
+                future,
+                new FutureCallback<>() {
+                    @Override
+                    public void onSuccess(final Message m) {
+                        setMessages(m.getVersionsAsMessages());
+                    }
+
+                    @Override
+                    public void onFailure(@NonNull Throwable t) {}
+                },
+                ContextCompat.getMainExecutor(this));
+    }
+
+    private void setMessages(final List<Message> messages) {
+        this.messages.clear();
+        this.messages.addAll(messages);
+        DateSeparator.addAll(this.messages);
+        messageListAdapter.notifyDataSetChanged();
+        ListViewUtils.scrollToBottom(this.binding.searchResults);
+    }
+
+    @Override
+    public void onContactPictureClicked(Message message) {
+        String fingerprint;
+        if (message.getEncryption() == Message.ENCRYPTION_PGP
+                || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) {
+            fingerprint = "pgp";
+        } else {
+            fingerprint = message.getFingerprint();
+        }
+        if (message.getStatus() == Message.STATUS_RECEIVED) {
+            final Contact contact = message.getContact();
+            if (contact != null) {
+                if (contact.isSelf()) {
+                    switchToAccount(message.getConversation().getAccount(), fingerprint);
+                } else {
+                    switchToContactDetails(contact, fingerprint);
+                }
+            }
+        } else {
+            switchToAccount(message.getConversation().getAccount(), fingerprint);
+        }
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/RtpSessionActivity.java b/src/main/java/tel/xmpp/jabjab/ui/RtpSessionActivity.java
index 8014f33..1473b8c 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/RtpSessionActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/RtpSessionActivity.java
@@ -377,17 +377,12 @@ public class RtpSessionActivity extends XmppActivity
         }
     }
 
-    private List<String> permissions(final Set<Media> media) {
-        final ImmutableList.Builder<String> permissions = ImmutableList.builder();
+    private static List<String> permissions(final Set<Media> media) {
         if (media.contains(Media.VIDEO)) {
-            permissions.add(Manifest.permission.CAMERA).add(Manifest.permission.RECORD_AUDIO);
+            return PermissionUtils.rtpPermissions(true);
         } else {
-            permissions.add(Manifest.permission.RECORD_AUDIO);
+            return PermissionUtils.rtpPermissions(false);
         }
-        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
-            permissions.add(Manifest.permission.BLUETOOTH_CONNECT);
-        }
-        return permissions.build();
     }
 
     private void acceptCall() {
@@ -574,8 +569,8 @@ public class RtpSessionActivity extends XmppActivity
             int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
         super.onRequestPermissionsResult(requestCode, permissions, grantResults);
         final PermissionUtils.PermissionResult permissionResult =
-                PermissionUtils.removeBluetoothConnect(permissions, grantResults);
-        if (PermissionUtils.allGranted(permissionResult.grantResults)) {
+                PermissionUtils.removeOptionalRtpPermissions(permissions, grantResults);
+        if (PermissionUtils.allGranted(permissionResult.grantResults())) {
             if (requestCode == REQUEST_ACCEPT_CALL) {
                 acceptCall();
             } else if (requestCode == REQUEST_ACCEPT_CONTENT) {
@@ -586,7 +581,7 @@ public class RtpSessionActivity extends XmppActivity
         } else {
             @StringRes int res;
             final String firstDenied =
-                    getFirstDenied(permissionResult.grantResults, permissionResult.permissions);
+                    getFirstDenied(permissionResult.grantResults(), permissionResult.permissions());
             if (firstDenied == null) {
                 return;
             }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/SearchActivity.java b/src/main/java/tel/xmpp/jabjab/ui/SearchActivity.java
index b6409f3..068642b 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/SearchActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/SearchActivity.java
@@ -44,6 +44,7 @@ import android.view.MotionEvent;
 import android.view.View;
 import android.widget.AdapterView;
 import android.widget.EditText;
+import androidx.annotation.NonNull;
 import androidx.databinding.DataBindingUtil;
 import com.google.android.material.color.MaterialColors;
 import tel.xmpp.jabjab.R;
@@ -183,25 +184,27 @@ public class SearchActivity extends XmppActivity
     }
 
     @Override
-    public boolean onContextItemSelected(MenuItem item) {
+    public boolean onContextItemSelected(@NonNull final MenuItem item) {
         final Message message = selectedMessageReference.get();
-        if (message != null) {
-            switch (item.getItemId()) {
-                case R.id.open_conversation:
-                    switchToConversation(wrap(message.getConversation()));
-                    break;
-                case R.id.share_with:
-                    ShareUtil.share(this, message);
-                    break;
-                case R.id.copy_message:
-                    ShareUtil.copyToClipboard(this, message);
-                    break;
-                case R.id.quote_message:
-                    quote(message);
-                    break;
-            }
+        if (message == null) {
+            return super.onContextItemSelected(item);
+        }
+        final int itemId = item.getItemId();
+        if (itemId == R.id.open_conversation) {
+            switchToConversation(wrap(message.getConversation()));
+            return true;
+        } else if (itemId == R.id.share_with) {
+            ShareUtil.share(this, message);
+            return true;
+        } else if (itemId == R.id.copy_message) {
+            ShareUtil.copyToClipboard(this, message);
+            return true;
+        } else if (itemId == R.id.quote_message) {
+            quote(message);
+            return true;
+        } else {
+            return super.onContextItemSelected(item);
         }
-        return super.onContextItemSelected(item);
     }
 
     @Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/ShareWithActivity.java b/src/main/java/tel/xmpp/jabjab/ui/ShareWithActivity.java
index 75d13c5..c7caf6e 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/ShareWithActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/ShareWithActivity.java
@@ -171,15 +171,15 @@ public class ShareWithActivity extends XmppActivity
 
     @Override
     public boolean onOptionsItemSelected(final MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.action_add:
-                final Intent intent =
-                        new Intent(getApplicationContext(), ChooseContactActivity.class);
-                intent.putExtra("direct_search", true);
-                startActivityForResult(intent, REQUEST_START_NEW_CONVERSATION);
-                return true;
+        final int itemId = item.getItemId();
+        if (itemId == R.id.action_add) {
+            final Intent intent = new Intent(getApplicationContext(), ChooseContactActivity.class);
+            intent.putExtra("direct_search", true);
+            startActivityForResult(intent, REQUEST_START_NEW_CONVERSATION);
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
         }
-        return super.onOptionsItemSelected(item);
     }
 
     @Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/StartConversationActivity.java b/src/main/java/tel/xmpp/jabjab/ui/StartConversationActivity.java
index bb687f1..e5fe766 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/StartConversationActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/StartConversationActivity.java
@@ -353,29 +353,30 @@ public class StartConversationActivity extends XmppActivity
                     } else {
                         prefilled = null;
                     }
-                    switch (actionItem.getId()) {
-                        case R.id.discover_public_channels:
-                            if (QuickConversationsService.isPlayStoreFlavor()) {
-                                throw new IllegalStateException(
-                                        "Channel discovery is not available on Google Play flavor");
-                            } else {
-                                startActivity(new Intent(this, ChannelDiscoveryActivity.class));
-                            }
-                            break;
-                        case R.id.join_public_channel:
-                            showJoinConferenceDialog(prefilled);
-                            break;
-                        case R.id.create_private_group_chat:
-                            showCreatePrivateGroupChatDialog();
-                            break;
-                        case R.id.create_public_channel:
-                            showPublicChannelDialog();
-                            break;
-                        case R.id.create_contact:
-                            showCreateContactDialog(prefilled, null);
-                            break;
+                    final int itemId = actionItem.getId();
+                    if (itemId == R.id.discover_public_channels) {
+                        if (QuickConversationsService.isPlayStoreFlavor()) {
+                            throw new IllegalStateException(
+                                    "Channel discovery is not available on Google Play flavor");
+                        } else {
+                            startActivity(new Intent(this, ChannelDiscoveryActivity.class));
+                        }
+                        return false;
+                    } else if (itemId == R.id.join_public_channel) {
+                        showJoinConferenceDialog(prefilled);
+                        return false;
+                    } else if (itemId == R.id.create_private_group_chat) {
+                        showCreatePrivateGroupChatDialog();
+                        return false;
+                    } else if (itemId == R.id.create_public_channel) {
+                        showPublicChannelDialog();
+                        return false;
+                    } else if (itemId == R.id.create_contact) {
+                        showCreateContactDialog(prefilled, null);
+                        return false;
+                    } else {
+                        return false;
                     }
-                    return false;
                 });
         final var backDispatcher = this.getOnBackPressedDispatcher();
         backDispatcher.addCallback(this, this.fabBackPressedCallback);
@@ -765,23 +766,25 @@ public class StartConversationActivity extends XmppActivity
     }
 
     @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
+    public boolean onOptionsItemSelected(final MenuItem item) {
         if (MenuDoubleTabUtil.shouldIgnoreTap()) {
             return false;
         }
-        switch (item.getItemId()) {
-            case android.R.id.home:
-                finish();
-                return true;
-            case R.id.action_hide_offline:
-                mHideOfflineContacts = !item.isChecked();
-                getPreferences().edit().putBoolean("hide_offline", mHideOfflineContacts).apply();
-                if (mSearchEditText != null) {
-                    filter(mSearchEditText.getText().toString());
-                }
-                invalidateOptionsMenu();
+        final int itemId = item.getItemId();
+        if (itemId == android.R.id.home) {
+            finish();
+            return true;
+        } else if (itemId == R.id.action_hide_offline) {
+            mHideOfflineContacts = !item.isChecked();
+            getPreferences().edit().putBoolean("hide_offline", mHideOfflineContacts).apply();
+            if (mSearchEditText != null) {
+                filter(mSearchEditText.getText().toString());
+            }
+            invalidateOptionsMenu();
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
         }
-        return super.onOptionsItemSelected(item);
     }
 
     @Override
@@ -1370,31 +1373,33 @@ public class StartConversationActivity extends XmppActivity
         }
 
         @Override
-        public boolean onContextItemSelected(final MenuItem item) {
+        public boolean onContextItemSelected(@NonNull final MenuItem item) {
             StartConversationActivity activity = (StartConversationActivity) getActivity();
             if (activity == null) {
                 return true;
             }
-            switch (item.getItemId()) {
-                case R.id.context_contact_details:
-                    activity.openDetailsForContact();
-                    break;
-                case R.id.context_show_qr:
-                    activity.showQrForContact();
-                    break;
-                case R.id.context_contact_block_unblock:
-                    activity.toggleContactBlock();
-                    break;
-                case R.id.context_delete_contact:
-                    activity.deleteContact();
-                    break;
-                case R.id.context_share_uri:
-                    activity.shareBookmarkUri();
-                    break;
-                case R.id.context_delete_conference:
-                    activity.deleteConference();
+            final int itemId = item.getItemId();
+            if (itemId == R.id.context_contact_details) {
+                activity.openDetailsForContact();
+                return true;
+            } else if (itemId == R.id.context_show_qr) {
+                activity.showQrForContact();
+                return true;
+            } else if (itemId == R.id.context_contact_block_unblock) {
+                activity.toggleContactBlock();
+                return true;
+            } else if (itemId == R.id.context_delete_contact) {
+                activity.deleteContact();
+                return true;
+            } else if (itemId == R.id.context_share_uri) {
+                activity.shareBookmarkUri();
+                return true;
+            } else if (itemId == R.id.context_delete_conference) {
+                activity.deleteConference();
+                return true;
+            } else {
+                return super.onContextItemSelected(item);
             }
-            return true;
         }
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/TrustKeysActivity.java b/src/main/java/tel/xmpp/jabjab/ui/TrustKeysActivity.java
index dbd5351..4ba6be7 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/TrustKeysActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/TrustKeysActivity.java
@@ -118,20 +118,22 @@ public class TrustKeysActivity extends OmemoActivity implements OnKeyStatusUpdat
 
     @Override
     public boolean onOptionsItemSelected(MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.action_scan_qr_code:
-                if (hasPendingKeyFetches()) {
-                    Toast.makeText(
-                                    this,
-                                    R.string.please_wait_for_keys_to_be_fetched,
-                                    Toast.LENGTH_SHORT)
-                            .show();
-                } else {
-                    requestPermissionAndScanQrCode();
-                    return true;
-                }
+        final int itemId = item.getItemId();
+        if (itemId == R.id.action_scan_qr_code) {
+            if (hasPendingKeyFetches()) {
+                Toast.makeText(
+                                this,
+                                R.string.please_wait_for_keys_to_be_fetched,
+                                Toast.LENGTH_SHORT)
+                        .show();
+                return true;
+            } else {
+                requestPermissionAndScanQrCode();
+                return true;
+            }
+        } else {
+            return super.onOptionsItemSelected(item);
         }
-        return super.onOptionsItemSelected(item);
     }
 
     @Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
index 50fe341..dd270b2 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/XmppActivity.java
@@ -577,26 +577,26 @@ public abstract class XmppActivity extends ActionBarActivity {
 
     @Override
     public boolean onOptionsItemSelected(final MenuItem item) {
-        switch (item.getItemId()) {
-            case R.id.action_settings:
-                startActivity(
-                        new Intent(
-                                this, tel.xmpp.jabjab.ui.activity.SettingsActivity.class));
-                break;
-            case R.id.action_accounts:
-                AccountUtils.launchManageAccounts(this);
-                break;
-            case R.id.action_account:
-                AccountUtils.launchManageAccount(this);
-                break;
-            case android.R.id.home:
-                finish();
-                break;
-            case R.id.action_show_qr_code:
-                showQrCode();
-                break;
-        }
-        return super.onOptionsItemSelected(item);
+        final int itemId = item.getItemId();
+        if (itemId == R.id.action_settings) {
+            startActivity(
+                    new Intent(this, tel.xmpp.jabjab.ui.activity.SettingsActivity.class));
+            return true;
+        } else if (itemId == R.id.action_accounts) {
+            AccountUtils.launchManageAccounts(this);
+            return true;
+        } else if (itemId == R.id.action_account) {
+            AccountUtils.launchManageAccount(this);
+            return true;
+        } else if (itemId == android.R.id.home) {
+            finish();
+            return true;
+        } else if (itemId == R.id.action_show_qr_code) {
+            showQrCode();
+            return true;
+        } else {
+            return super.onOptionsItemSelected(item);
+        }
     }
 
     public void selectPresence(
diff --git a/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java b/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
index 0a8f36c..08fa946 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/activity/SettingsActivity.java
@@ -1,10 +1,22 @@
 package tel.xmpp.jabjab.ui.activity;
 
 import android.app.Notification;
+import android.content.Intent;
 import android.os.Bundle;
+import android.os.Handler;
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.EditText;
 
+import androidx.annotation.Nullable;
 import androidx.databinding.DataBindingUtil;
+import androidx.fragment.app.Fragment;
+import androidx.preference.Preference;
 import androidx.preference.PreferenceFragmentCompat;
+import androidx.recyclerview.widget.LinearLayoutManager;
 
 import com.google.common.collect.ImmutableSet;
 
@@ -12,14 +24,19 @@ import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.databinding.ActivitySettingsBinding;
 import tel.xmpp.jabjab.ui.Activities;
 import tel.xmpp.jabjab.ui.XmppActivity;
+import tel.xmpp.jabjab.ui.adapter.SettingsSearchResultAdapter;
 import tel.xmpp.jabjab.ui.fragment.settings.MainSettingsFragment;
 import tel.xmpp.jabjab.ui.fragment.settings.NotificationsSettingsFragment;
 import tel.xmpp.jabjab.ui.fragment.settings.PrivacySettingsFragment;
 import tel.xmpp.jabjab.ui.fragment.settings.XmppPreferenceFragment;
+import tel.xmpp.jabjab.ui.util.SettingsSearchIndex;
+import tel.xmpp.jabjab.utils.CharSequences;
 
 import java.util.Collections;
+import java.util.List;
 
-public class SettingsActivity extends XmppActivity {
+public class SettingsActivity extends XmppActivity
+        implements PreferenceFragmentCompat.OnPreferenceStartFragmentCallback {
 
     // Deep-links straight to a sub-screen instead of the top-level settings list — e.g. from
     // the "new translation feature" announcement banner on the conversations overview.
@@ -30,6 +47,14 @@ public class SettingsActivity extends XmppActivity {
     // (e.g. "translation_backend_priority") instead of leaving the user at the top of the list.
     public static final String EXTRA_SCROLL_TO_PREFERENCE = "scroll_to_preference";
 
+    private static final long SEARCH_DEBOUNCE_MS = 150L;
+
+    private ActivitySettingsBinding binding;
+    private SettingsSearchResultAdapter searchResultAdapter;
+    private final Handler searchDebounceHandler = new Handler();
+    private final Runnable searchDebounceRunnable = this::runSearch;
+    private String pendingSearchQuery = "";
+
     @Override
     protected void refreshUiReal() {}
 
@@ -47,8 +72,13 @@ public class SettingsActivity extends XmppActivity {
         super.onCreate(savedInstanceState);
         final ActivitySettingsBinding binding =
                 DataBindingUtil.setContentView(this, R.layout.activity_settings);
+        this.binding = binding;
         setSupportActionBar(binding.materialToolbar);
         Activities.setStatusAndNavigationBarColors(this, binding.getRoot());
+        // Warms the settings-search index (parses every preferences_*.xml + resolves every
+        // shipped locale's strings) off the main thread so it's ready by the time the user
+        // actually opens search — see SettingsSearchIndex.
+        SettingsSearchIndex.warmUp(this);
 
         final var intent = getIntent();
         final var categories = intent == null ? Collections.emptySet() : intent.getCategories();
@@ -58,26 +88,11 @@ public class SettingsActivity extends XmppActivity {
             preferenceFragment = new NotificationsSettingsFragment();
         } else if (FRAGMENT_PRIVACY.equals(
                 intent == null ? null : intent.getStringExtra(EXTRA_OPEN_FRAGMENT))) {
-            final var fragment = new PrivacySettingsFragment();
-            final String scrollTo =
-                    intent == null ? null : intent.getStringExtra(EXTRA_SCROLL_TO_PREFERENCE);
-            if (scrollTo != null) {
-                final Bundle args = new Bundle();
-                args.putString(EXTRA_SCROLL_TO_PREFERENCE, scrollTo);
-                fragment.setArguments(args);
-            }
-            preferenceFragment = fragment;
+            preferenceFragment = withScrollArgs(new PrivacySettingsFragment(), scrollToExtra(intent));
         } else if (FRAGMENT_NOTIFICATIONS.equals(
                 intent == null ? null : intent.getStringExtra(EXTRA_OPEN_FRAGMENT))) {
-            final var fragment = new NotificationsSettingsFragment();
-            final String scrollTo =
-                    intent == null ? null : intent.getStringExtra(EXTRA_SCROLL_TO_PREFERENCE);
-            if (scrollTo != null) {
-                final Bundle args = new Bundle();
-                args.putString(EXTRA_SCROLL_TO_PREFERENCE, scrollTo);
-                fragment.setArguments(args);
-            }
-            preferenceFragment = fragment;
+            preferenceFragment =
+                    withScrollArgs(new NotificationsSettingsFragment(), scrollToExtra(intent));
         } else {
             preferenceFragment = new MainSettingsFragment();
         }
@@ -98,5 +113,141 @@ public class SettingsActivity extends XmppActivity {
                         fragmentManager.popBackStack();
                     }
                 });
+
+        this.searchResultAdapter = new SettingsSearchResultAdapter();
+        this.searchResultAdapter.setOnResultClicked(this::onSearchResultClicked);
+        binding.settingsSearchResults.setLayoutManager(new LinearLayoutManager(this));
+        binding.settingsSearchResults.setAdapter(this.searchResultAdapter);
+    }
+
+    @Nullable
+    private static String scrollToExtra(@Nullable final Intent intent) {
+        return intent == null ? null : intent.getStringExtra(EXTRA_SCROLL_TO_PREFERENCE);
+    }
+
+    private static <F extends Fragment> F withScrollArgs(
+            final F fragment, @Nullable final String scrollToKey) {
+        if (scrollToKey != null) {
+            final Bundle args = new Bundle();
+            args.putString(EXTRA_SCROLL_TO_PREFERENCE, scrollToKey);
+            fragment.setArguments(args);
+        }
+        return fragment;
+    }
+
+    /**
+     * Generic "go to fragment X, then scroll to preference Y" navigation. Used both for
+     * app:fragment-linked preference rows (see {@link #onPreferenceStartFragment}, which is what
+     * actually makes those rows clickable at all) and for tapping a settings-search result.
+     */
+    private void navigateToFragment(
+            final String fragmentClassName, @Nullable final String scrollToKey) {
+        final var fragmentManager = getSupportFragmentManager();
+        final Fragment fragment;
+        try {
+            fragment =
+                    fragmentManager
+                            .getFragmentFactory()
+                            .instantiate(getClassLoader(), fragmentClassName);
+        } catch (final Fragment.InstantiationException e) {
+            return;
+        }
+        withScrollArgs(fragment, scrollToKey);
+        fragmentManager
+                .beginTransaction()
+                .replace(R.id.fragment_container, fragment)
+                .addToBackStack(null)
+                .commit();
+    }
+
+    @Override
+    public boolean onPreferenceStartFragment(
+            final PreferenceFragmentCompat caller, final Preference pref) {
+        final String fragmentClassName = pref.getFragment();
+        if (fragmentClassName == null) {
+            return false;
+        }
+        navigateToFragment(fragmentClassName, null);
+        return true;
+    }
+
+    private void onSearchResultClicked(final SettingsSearchIndex.Entry entry) {
+        collapseSearch();
+        final var current = getSupportFragmentManager().findFragmentById(R.id.fragment_container);
+        if (current != null
+                && current.getClass().getName().equals(entry.fragmentClassName())
+                && current instanceof XmppPreferenceFragment xmppPreferenceFragment) {
+            // Already on the right screen — scroll/highlight in place instead of pushing a
+            // second instance of the same fragment onto the back stack.
+            xmppPreferenceFragment.scrollToPreferenceAndHighlight(entry.preferenceKey());
+            return;
+        }
+        navigateToFragment(entry.fragmentClassName(), entry.preferenceKey());
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(final Menu menu) {
+        getMenuInflater().inflate(R.menu.activity_settings, menu);
+        final var searchMenuItem = menu.findItem(R.id.action_search);
+        final var searchActionView = searchMenuItem.getActionView();
+        final EditText searchField = searchActionView.findViewById(R.id.search_field);
+        searchField.addTextChangedListener(
+                new TextWatcher() {
+                    @Override
+                    public void beforeTextChanged(
+                            final CharSequence s, final int start, final int count, final int after) {}
+
+                    @Override
+                    public void onTextChanged(
+                            final CharSequence s, final int start, final int before, final int count) {}
+
+                    @Override
+                    public void afterTextChanged(final Editable s) {
+                        onSearchQueryChanged(CharSequences.nullToEmpty(s));
+                    }
+                });
+        searchMenuItem.setOnActionExpandListener(
+                new MenuItem.OnActionExpandListener() {
+                    @Override
+                    public boolean onMenuItemActionExpand(final MenuItem item) {
+                        return true;
+                    }
+
+                    @Override
+                    public boolean onMenuItemActionCollapse(final MenuItem item) {
+                        searchField.setText("");
+                        showSearchResults(false);
+                        return true;
+                    }
+                });
+        return true;
+    }
+
+    private void onSearchQueryChanged(final String query) {
+        this.pendingSearchQuery = query;
+        searchDebounceHandler.removeCallbacks(searchDebounceRunnable);
+        searchDebounceHandler.postDelayed(searchDebounceRunnable, SEARCH_DEBOUNCE_MS);
+    }
+
+    private void runSearch() {
+        final String query = this.pendingSearchQuery;
+        if (query.isEmpty()) {
+            showSearchResults(false);
+            return;
+        }
+        final var results = SettingsSearchIndex.search(this, query);
+        searchResultAdapter.submitList(results);
+        showSearchResults(true);
+    }
+
+    private void showSearchResults(final boolean show) {
+        binding.settingsSearchResults.setVisibility(show ? View.VISIBLE : View.GONE);
+        if (!show) {
+            searchResultAdapter.submitList(List.of());
+        }
+    }
+
+    private void collapseSearch() {
+        showSearchResults(false);
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/ConversationAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/ConversationAdapter.java
index 51bca24..90f50ad 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/ConversationAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/ConversationAdapter.java
@@ -27,6 +27,7 @@ import tel.xmpp.jabjab.utils.UIHelper;
 import tel.xmpp.jabjab.xmpp.Jid;
 import tel.xmpp.jabjab.xmpp.jingle.OngoingRtpSession;
 import tel.xmpp.jabjab.xmpp.manager.JingleManager;
+import java.time.Instant;
 import java.util.List;
 
 public class ConversationAdapter
@@ -127,7 +128,7 @@ public class ConversationAdapter
 
         if (draft != null) {
             viewHolder.binding.conversationLastmsgImg.setVisibility(View.GONE);
-            viewHolder.binding.conversationLastmsg.setText(draft.getMessage());
+            viewHolder.binding.conversationLastmsg.setText(draft.message());
             viewHolder.binding.senderName.setText(R.string.draft);
             viewHolder.binding.senderName.setVisibility(View.VISIBLE);
             viewHolder.binding.conversationLastmsg.setTypeface(null, Typeface.NORMAL);
@@ -215,13 +216,12 @@ public class ConversationAdapter
             viewHolder.binding.notificationStatus.setImageResource(
                     R.drawable.ic_phone_in_talk_24dp);
         } else {
-            final long muted_till =
-                    conversation.getLongAttribute(Conversation.ATTRIBUTE_MUTED_TILL, 0);
-            if (muted_till == Long.MAX_VALUE) {
+            final var mutedTill = conversation.getMutedTill();
+            if (mutedTill != null && mutedTill.equals(Instant.MAX)) {
                 viewHolder.binding.notificationStatus.setVisibility(View.VISIBLE);
                 viewHolder.binding.notificationStatus.setImageResource(
                         R.drawable.ic_notifications_off_24dp);
-            } else if (muted_till >= System.currentTimeMillis()) {
+            } else if (mutedTill != null && mutedTill.isAfter(Instant.now())) {
                 viewHolder.binding.notificationStatus.setVisibility(View.VISIBLE);
                 viewHolder.binding.notificationStatus.setImageResource(
                         R.drawable.ic_notifications_paused_24dp);
@@ -236,14 +236,12 @@ public class ConversationAdapter
 
         long timestamp;
         if (draft != null) {
-            timestamp = draft.getTimestamp();
+            timestamp = draft.instant().toEpochMilli();
         } else {
             timestamp = conversation.getLatestMessage().getTimeSent();
         }
         viewHolder.binding.pinnedOnTop.setVisibility(
-                conversation.getBooleanAttribute(Conversation.ATTRIBUTE_PINNED_ON_TOP, false)
-                        ? View.VISIBLE
-                        : View.GONE);
+                conversation.isPinnedOnTop() ? View.VISIBLE : View.GONE);
         viewHolder.binding.conversationLastupdate.setText(
                 UIHelper.readableTimeDifference(activity, timestamp));
         AvatarWorkerTask.loadAvatar(
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
index 8e4daba..3f4a547 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/MessageAdapter.java
@@ -14,6 +14,7 @@ import android.text.SpannableString;
 import android.text.SpannableStringBuilder;
 import android.text.Spanned;
 import android.text.TextUtils;
+import android.text.format.DateFormat;
 import android.text.format.DateUtils;
 import android.text.style.ForegroundColorSpan;
 import android.text.style.RelativeSizeSpan;
@@ -102,6 +103,7 @@ import java.time.ZoneOffset;
 import java.time.temporal.ChronoUnit;
 import java.util.Arrays;
 import java.util.Collection;
+import java.util.Date;
 import java.util.List;
 import java.util.Locale;
 import java.util.regex.Matcher;
@@ -321,8 +323,15 @@ public class MessageAdapter extends ArrayAdapter<Message> {
             viewHolder.indicatorPinned().setVisibility(View.GONE);
         }
 
-        final String formattedTime =
-                UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
+        final String formattedTime;
+        if (message instanceof Message.MessageVersion) {
+            formattedTime =
+                    DateFormat.getTimeFormat(getContext()).format(new Date(message.getTimeSent()));
+        } else {
+            formattedTime =
+                    UIHelper.readableTimeDifferenceFull(getContext(), message.getTimeSent());
+        }
+
         final String bodyLanguage = message.getBodyLanguage();
         final ImmutableList.Builder<String> timeInfoBuilder = new ImmutableList.Builder<>();
 
@@ -1595,7 +1604,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
         activity.xmppConnectionService.updateConversation(conversation);
         conversation.setHasMessagesLeftOnServer(true);
         conversation.setFirstMamReference(null);
-        long timestamp = conversation.getLastMessageTransmitted().getTimestamp();
+        long timestamp = conversation.getLastMessageTransmitted().timestamp();
         if (timestamp == 0) {
             timestamp = System.currentTimeMillis();
         }
@@ -2222,7 +2231,7 @@ public class MessageAdapter extends ArrayAdapter<Message> {
     }
 
     private boolean mergeIntoTop(final int position, final Message message) {
-        if (position <= 0) {
+        if (position <= 0 || message instanceof Message.MessageVersion) {
             return false;
         }
         final var top = getItem(position - 1);
@@ -2240,6 +2249,9 @@ public class MessageAdapter extends ArrayAdapter<Message> {
     }
 
     private static boolean merge(final Message a, final Message b) {
+        if (a instanceof Message.MessageVersion || b instanceof Message.MessageVersion) {
+            return false;
+        }
         if (getItemViewType(a, false) != getItemViewType(b, false)) {
             return false;
         }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/adapter/SettingsSearchResultAdapter.java b/src/main/java/tel/xmpp/jabjab/ui/adapter/SettingsSearchResultAdapter.java
new file mode 100644
index 0000000..e6cf656
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/adapter/SettingsSearchResultAdapter.java
@@ -0,0 +1,78 @@
+package tel.xmpp.jabjab.ui.adapter;
+
+import android.view.LayoutInflater;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.databinding.DataBindingUtil;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.ListAdapter;
+import androidx.recyclerview.widget.RecyclerView;
+
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.databinding.ItemSettingsSearchResultBinding;
+import tel.xmpp.jabjab.ui.util.SettingsSearchIndex;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+public class SettingsSearchResultAdapter
+        extends ListAdapter<
+                SettingsSearchIndex.Entry, SettingsSearchResultAdapter.ResultViewHolder> {
+
+    private static final DiffUtil.ItemCallback<SettingsSearchIndex.Entry> DIFF =
+            new DiffUtil.ItemCallback<>() {
+                @Override
+                public boolean areItemsTheSame(
+                        @NonNull SettingsSearchIndex.Entry oldItem,
+                        @NonNull SettingsSearchIndex.Entry newItem) {
+                    return oldItem.fragmentClassName().equals(newItem.fragmentClassName())
+                            && oldItem.preferenceKey().equals(newItem.preferenceKey());
+                }
+
+                @Override
+                public boolean areContentsTheSame(
+                        @NonNull SettingsSearchIndex.Entry oldItem,
+                        @NonNull SettingsSearchIndex.Entry newItem) {
+                    return Objects.equals(oldItem, newItem);
+                }
+            };
+
+    private Consumer<SettingsSearchIndex.Entry> onResultClicked;
+
+    public SettingsSearchResultAdapter() {
+        super(DIFF);
+    }
+
+    public void setOnResultClicked(final Consumer<SettingsSearchIndex.Entry> consumer) {
+        this.onResultClicked = consumer;
+    }
+
+    @NonNull
+    @Override
+    public ResultViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) {
+        final LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
+        final ItemSettingsSearchResultBinding binding =
+                DataBindingUtil.inflate(
+                        layoutInflater, R.layout.item_settings_search_result, parent, false);
+        return new ResultViewHolder(binding);
+    }
+
+    @Override
+    public void onBindViewHolder(@NonNull final ResultViewHolder holder, final int position) {
+        final var entry = getItem(position);
+        holder.binding.title.setText(entry.title());
+        holder.binding.breadcrumb.setText(entry.screenTitle());
+        holder.binding.wrapper.setOnClickListener(v -> onResultClicked.accept(entry));
+    }
+
+    public static class ResultViewHolder extends RecyclerView.ViewHolder {
+
+        private final ItemSettingsSearchResultBinding binding;
+
+        public ResultViewHolder(@NonNull final ItemSettingsSearchResultBinding binding) {
+            super(binding.getRoot());
+            this.binding = binding;
+        }
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AttachmentsSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AttachmentsSettingsFragment.java
index 1159049..c5f2bba 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AttachmentsSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AttachmentsSettingsFragment.java
@@ -56,5 +56,6 @@ public class AttachmentsSettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.pref_attachments);
+        scrollToRequestedPreference();
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AvailabilitySettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AvailabilitySettingsFragment.java
index 5c7ccc0..c7bc110 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AvailabilitySettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/AvailabilitySettingsFragment.java
@@ -30,5 +30,6 @@ public class AvailabilitySettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.pref_presence_settings);
+        scrollToRequestedPreference();
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java
index 5ca1c34..a15fccd 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/BackupSettingsFragment.java
@@ -185,6 +185,7 @@ public class BackupSettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.backup_and_restore);
+        scrollToRequestedPreference();
     }
 
     private boolean onBackupPreferenceClicked(final Preference preference) {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/ConnectionSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/ConnectionSettingsFragment.java
index 5ab28fa..e54f935 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/ConnectionSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/ConnectionSettingsFragment.java
@@ -87,5 +87,6 @@ public class ConnectionSettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.pref_connection_options);
+        scrollToRequestedPreference();
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceBubblesSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceBubblesSettingsFragment.java
index 18bcdf2..bc167d6 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceBubblesSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceBubblesSettingsFragment.java
@@ -53,5 +53,6 @@ public class InterfaceBubblesSettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.pref_title_bubbles);
+        scrollToRequestedPreference();
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceSettingsFragment.java
index d61b639..67b917f 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/InterfaceSettingsFragment.java
@@ -84,6 +84,7 @@ public class InterfaceSettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.pref_title_interface);
+        scrollToRequestedPreference();
     }
 
     public SettingsActivity requireSettingsActivity() {
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/MainSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/MainSettingsFragment.java
index 67d040c..ffa75b7 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/MainSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/MainSettingsFragment.java
@@ -4,14 +4,13 @@ import android.os.Build;
 import android.os.Bundle;
 
 import androidx.annotation.Nullable;
-import androidx.preference.PreferenceFragmentCompat;
 
 import com.google.common.base.Strings;
 
 import tel.xmpp.jabjab.BuildConfig;
 import tel.xmpp.jabjab.R;
 
-public class MainSettingsFragment extends PreferenceFragmentCompat {
+public class MainSettingsFragment extends XmppPreferenceFragment {
 
     @Override
     public void onCreatePreferences(@Nullable Bundle savedInstanceState, @Nullable String rootKey) {
@@ -43,5 +42,6 @@ public class MainSettingsFragment extends PreferenceFragmentCompat {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.title_activity_settings);
+        scrollToRequestedPreference();
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/SecuritySettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/SecuritySettingsFragment.java
index c1e35ba..d228f88 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/SecuritySettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/SecuritySettingsFragment.java
@@ -76,6 +76,7 @@ public class SecuritySettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.pref_title_security);
+        scrollToRequestedPreference();
     }
 
     @Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/UpSettingsFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/UpSettingsFragment.java
index 7b58a26..7ad16bb 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/UpSettingsFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/UpSettingsFragment.java
@@ -109,5 +109,6 @@ public class UpSettingsFragment extends XmppPreferenceFragment {
     public void onStart() {
         super.onStart();
         requireActivity().setTitle(R.string.unified_push_distributor);
+        scrollToRequestedPreference();
     }
 }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java
index 173190a..f287462 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/fragment/settings/XmppPreferenceFragment.java
@@ -119,6 +119,13 @@ public abstract class XmppPreferenceFragment extends PreferenceFragmentCompat {
                         tel.xmpp.jabjab.ui.activity.SettingsActivity.EXTRA_SCROLL_TO_PREFERENCE);
         if (key == null) return;
         didScrollToRequestedPreference = true;
+        scrollToPreferenceAndHighlight(key);
+    }
+
+    /** Same as {@link #scrollToRequestedPreference()} but callable directly (e.g. from a
+     * settings-search result tap landing on a fragment that's already on screen), bypassing
+     * the one-shot arguments-based gate. */
+    public void scrollToPreferenceAndHighlight(final String key) {
         scrollToPreference(key);
         // Same smooth "blink" used for jumping to a reply's original message
         // (ConversationFragment.highlightMessageAt) — scrolling alone can be easy to miss,
diff --git a/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java b/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
index aeba8d2..3074022 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/service/AudioPlayer.java
@@ -49,6 +49,26 @@ public class AudioPlayer
     private static Message currentlyPlayingMessage = null;
     private static float playbackSpeed = 1.25f;
     private static PowerManager.WakeLock wakeLock;
+    // Whichever AudioPlayer instance is currently tied to the live MediaPlayer (its
+    // MessageAdapter/sensorManager/handler) — always the same instance that is registered as
+    // the MediaPlayer's OnCompletionListener, see the constructor and play(). Lets static,
+    // instance-less callers (the persistent mini-player bar, app-backgrounded handling) drive
+    // playback without needing a bubble ViewHolder.
+    private static AudioPlayer activeInstance = null;
+    private static final WeakReferenceSet<PlaybackListener> playbackListeners =
+            new WeakReferenceSet<>();
+
+    /** Observer for the persistent mini-player bar (or any other instance-less UI) to react to
+     * playback without polling — see {@link #addPlaybackListener(PlaybackListener)}. */
+    public interface PlaybackListener {
+        void onProgress(Message message, int currentMs, int durationMs);
+
+        void onPlaybackStateChanged(Message message, boolean playing);
+
+        void onSpeedChanged(String speedLabel);
+
+        void onPlaybackStopped();
+    }
     private final MessageAdapter messageAdapter;
     private final WeakReferenceSet<RelativeLayout> audioPlayerLayouts = new WeakReferenceSet<>();
     private final SensorManager sensorManager;
@@ -72,6 +92,10 @@ public class AudioPlayer
         synchronized (AudioPlayer.LOCK) {
             if (AudioPlayer.player != null) {
                 AudioPlayer.player.setOnCompletionListener(this);
+                // This instance's MessageAdapter is the freshest one (e.g. the user re-opened
+                // the conversation that's still playing) — take over as the active instance so
+                // static callers keep operating on a live, correctly-scoped MessageAdapter.
+                AudioPlayer.activeInstance = this;
                 if (AudioPlayer.player.isPlaying() && sensorManager != null) {
                     sensorManager.registerListener(
                             this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
@@ -80,6 +104,168 @@ public class AudioPlayer
         }
     }
 
+    public static void addPlaybackListener(final PlaybackListener listener) {
+        synchronized (LOCK) {
+            playbackListeners.addWeakReferenceTo(listener);
+        }
+    }
+
+    public static void removePlaybackListener(final PlaybackListener listener) {
+        synchronized (LOCK) {
+            playbackListeners.removeWeakReferenceTo(listener);
+        }
+    }
+
+    private static void notifyProgress(
+            final Message message, final int currentMs, final int durationMs) {
+        for (final WeakReference<PlaybackListener> ref : playbackListeners) {
+            final PlaybackListener listener = ref.get();
+            if (listener != null) {
+                listener.onProgress(message, currentMs, durationMs);
+            }
+        }
+    }
+
+    private static void notifyPlaybackStateChanged(final Message message, final boolean playing) {
+        for (final WeakReference<PlaybackListener> ref : playbackListeners) {
+            final PlaybackListener listener = ref.get();
+            if (listener != null) {
+                listener.onPlaybackStateChanged(message, playing);
+            }
+        }
+    }
+
+    private static void notifySpeedChanged(final String speedLabel) {
+        for (final WeakReference<PlaybackListener> ref : playbackListeners) {
+            final PlaybackListener listener = ref.get();
+            if (listener != null) {
+                listener.onSpeedChanged(speedLabel);
+            }
+        }
+    }
+
+    private static void notifyPlaybackStopped() {
+        for (final WeakReference<PlaybackListener> ref : playbackListeners) {
+            final PlaybackListener listener = ref.get();
+            if (listener != null) {
+                listener.onPlaybackStopped();
+            }
+        }
+    }
+
+    public static Message getCurrentlyPlayingMessage() {
+        return currentlyPlayingMessage;
+    }
+
+    public static boolean isCurrentlyPlaying() {
+        return player != null && player.isPlaying();
+    }
+
+    /** Current position of the live track, for a UI (the mini-player bar) that's syncing to an
+     * already-running/already-paused player instead of waiting for the next {@link
+     * PlaybackListener#onProgress} tick — e.g. right after attaching to a window. */
+    public static int getCurrentPositionMs() {
+        final MediaPlayer p = player;
+        return p == null ? 0 : p.getCurrentPosition();
+    }
+
+    public static int getDurationMs() {
+        final MediaPlayer p = player;
+        return p == null ? 0 : p.getDuration();
+    }
+
+    /** Pauses/resumes the current track without a bubble ViewHolder — for the persistent
+     * mini-player bar, which has its own play/pause button. */
+    public static void playPauseCurrentGlobally() {
+        final AudioPlayer instance = activeInstance;
+        if (instance != null) {
+            instance.playPauseCurrentNoViewHolder();
+        }
+    }
+
+    /** Seeks the current track without a bubble ViewHolder — for the persistent mini-player
+     * bar's own seek control. */
+    public static void seekCurrentGlobally(final int percentProgress) {
+        synchronized (LOCK) {
+            if (player == null || currentlyPlayingMessage == null) {
+                return;
+            }
+            final float percent = percentProgress / 100f;
+            final int duration = player.getDuration();
+            player.seekTo(Math.round(duration * percent));
+        }
+    }
+
+    public static String getSpeedLabel() {
+        return formatSpeed(playbackSpeed);
+    }
+
+    /** Cycles playback speed without a bubble button — for the mini-player bar's own speed
+     * control. Returns the new speed's display label so the caller can update its button text
+     * immediately rather than waiting on the next bubble-sync pass. */
+    public static String cycleSpeedGlobally() {
+        synchronized (LOCK) {
+            float next = SPEEDS[0];
+            for (int i = 0; i < SPEEDS.length; i++) {
+                if (playbackSpeed == SPEEDS[i]) {
+                    next = SPEEDS[(i + 1) % SPEEDS.length];
+                    break;
+                }
+            }
+            playbackSpeed = next;
+            if (player != null && player.isPlaying()) {
+                player.setPlaybackParams(new PlaybackParams().setSpeed(playbackSpeed));
+            }
+            final String label = formatSpeed(playbackSpeed);
+            final AudioPlayer instance = activeInstance;
+            if (instance != null) {
+                for (final WeakReference<RelativeLayout> ref : instance.audioPlayerLayouts) {
+                    final RelativeLayout layout = ref.get();
+                    if (layout != null) {
+                        ViewHolder.get(layout).speedToggle.setText(label);
+                    }
+                }
+            }
+            notifySpeedChanged(label);
+            return label;
+        }
+    }
+
+    /** Stops playback without a bubble ViewHolder — for the mini-player bar's close button. */
+    public static void stopCurrentGlobally() {
+        final AudioPlayer instance = activeInstance;
+        if (instance != null) {
+            instance.stop();
+        }
+    }
+
+    /** Pauses (without releasing) playback when the app itself leaves the foreground — keeps
+     * the track and position, so reopening the app brings the mini-player right back, paused
+     * exactly where it left off, instead of losing the track entirely. Actually playing audio
+     * while backgrounded (a foreground-service notification) is a separate, later step — this
+     * only prevents a quick minimize/reopen from discarding state. See the
+     * Application.ActivityLifecycleCallbacks counter registered in Conversations. */
+    public static void onAppBackgrounded() {
+        final AudioPlayer instance = activeInstance;
+        if (instance != null) {
+            instance.pauseForBackground();
+        }
+    }
+
+    private void pauseForBackground() {
+        synchronized (LOCK) {
+            if (player == null || !player.isPlaying()) {
+                return;
+            }
+            player.pause();
+            messageAdapter.flagScreenOff();
+            releaseProximityWakeLock();
+            sensorManager.unregisterListener(this);
+            stopRefresher(false);
+            notifyPlaybackStateChanged(currentlyPlayingMessage, false);
+        }
+    }
+
     private static String formatTime(final int ms) {
         return TimeFrameUtils.formatElapsedTime(ms, false);
     }
@@ -184,6 +370,7 @@ public class AudioPlayer
                 ViewHolder.get(layout).speedToggle.setText(label);
             }
         }
+        notifySpeedChanged(label);
     }
 
     private static String formatSpeed(float speed) {
@@ -226,6 +413,7 @@ public class AudioPlayer
             releaseProximityWakeLock();
             viewHolder.playPause.setIconResource(R.drawable.ic_play_arrow_24dp);
             viewHolder.playPause.setContentDescription(context.getString(R.string.play_audio));
+            notifyPlaybackStateChanged(currentlyPlayingMessage, false);
         } else {
             viewHolder.progress.setEnabled(true);
             if (playbackSpeed != 1.0f) {
@@ -237,10 +425,49 @@ public class AudioPlayer
             this.stopRefresher(true);
             viewHolder.playPause.setIconResource(R.drawable.ic_pause_24dp);
             viewHolder.playPause.setContentDescription(context.getString(R.string.pause_audio));
+            notifyPlaybackStateChanged(currentlyPlayingMessage, true);
         }
         return false;
     }
 
+    /** Same as {@link #playPauseCurrent(ViewHolder)} but for the mini-player bar, which has its
+     * own play/pause button instead of a message bubble's. Still keeps any visible bubble (e.g.
+     * the same conversation open in a tablet's secondary pane) in sync. */
+    private void playPauseCurrentNoViewHolder() {
+        synchronized (LOCK) {
+            if (player == null || currentlyPlayingMessage == null) {
+                return;
+            }
+            final boolean nowPlaying;
+            if (player.isPlaying()) {
+                player.pause();
+                messageAdapter.flagScreenOff();
+                releaseProximityWakeLock();
+                nowPlaying = false;
+            } else {
+                if (playbackSpeed != 1.0f) {
+                    player.setPlaybackParams(new PlaybackParams().setSpeed(playbackSpeed));
+                }
+                player.start();
+                messageAdapter.flagScreenOn();
+                acquireProximityWakeLock();
+                this.stopRefresher(true);
+                nowPlaying = true;
+            }
+            for (final WeakReference<RelativeLayout> ref : audioPlayerLayouts) {
+                final RelativeLayout layout = ref.get();
+                if (layout == null) {
+                    continue;
+                }
+                final ViewHolder viewHolder = ViewHolder.get(layout);
+                viewHolder.progress.setEnabled(nowPlaying);
+                viewHolder.playPause.setIconResource(
+                        nowPlaying ? R.drawable.ic_pause_24dp : R.drawable.ic_play_arrow_24dp);
+            }
+            notifyPlaybackStateChanged(currentlyPlayingMessage, nowPlaying);
+        }
+    }
+
     private void play(ViewHolder viewHolder, Message message, boolean earpiece, double progress) {
         if (play(viewHolder, message, earpiece)) {
             AudioPlayer.player.seekTo((int) (AudioPlayer.player.getDuration() * progress));
@@ -269,12 +496,16 @@ public class AudioPlayer
                     viewHolder.playPause.getContext().getString(R.string.pause_audio));
             sensorManager.registerListener(
                     this, proximitySensor, SensorManager.SENSOR_DELAY_NORMAL);
+            AudioPlayer.activeInstance = this;
+            notifyPlaybackStateChanged(message, true);
             return true;
         } catch (final Exception e) {
             messageAdapter.flagScreenOff();
             releaseProximityWakeLock();
             AudioPlayer.currentlyPlayingMessage = null;
+            AudioPlayer.activeInstance = null;
             sensorManager.unregisterListener(this);
+            notifyPlaybackStopped();
             return false;
         }
     }
@@ -339,12 +570,14 @@ public class AudioPlayer
             if (AudioPlayer.player == mediaPlayer) {
                 AudioPlayer.currentlyPlayingMessage = null;
                 AudioPlayer.player = null;
+                AudioPlayer.activeInstance = null;
             }
             mediaPlayer.release();
             messageAdapter.flagScreenOff();
             releaseProximityWakeLock();
             resetPlayerUi();
             sensorManager.unregisterListener(this);
+            notifyPlaybackStopped();
         }
     }
 
@@ -376,11 +609,13 @@ public class AudioPlayer
                 stopCurrent();
             }
             AudioPlayer.currentlyPlayingMessage = null;
+            AudioPlayer.activeInstance = null;
             sensorManager.unregisterListener(this);
             if (wakeLock != null && wakeLock.isHeld()) {
                 wakeLock.release();
             }
             wakeLock = null;
+            notifyPlaybackStopped();
         }
     }
 
@@ -401,22 +636,25 @@ public class AudioPlayer
     public void run() {
         synchronized (AudioPlayer.LOCK) {
             if (AudioPlayer.player != null) {
-                boolean renew = false;
                 final int current = player.getCurrentPosition();
                 final int duration = player.getDuration();
                 for (WeakReference<RelativeLayout> audioPlayer : audioPlayerLayouts) {
-                    renew |= refreshAudioPlayer(audioPlayer.get(), current, duration);
+                    refreshAudioPlayer(audioPlayer.get(), current, duration);
                 }
-                if (renew && AudioPlayer.player.isPlaying()) {
+                // Keep ticking while actually playing regardless of whether any bubble is
+                // visible — the mini-player bar needs progress updates even when the user has
+                // navigated away from every conversation showing this message.
+                notifyProgress(currentlyPlayingMessage, current, duration);
+                if (AudioPlayer.player.isPlaying()) {
                     handler.postDelayed(this, REFRESH_INTERVAL);
                 }
             }
         }
     }
 
-    private boolean refreshAudioPlayer(RelativeLayout audioPlayer, int current, int duration) {
+    private void refreshAudioPlayer(RelativeLayout audioPlayer, int current, int duration) {
         if (audioPlayer == null || audioPlayer.getVisibility() != View.VISIBLE) {
-            return false;
+            return;
         }
         final ViewHolder viewHolder = ViewHolder.get(audioPlayer);
         if (duration <= 0) {
@@ -427,7 +665,6 @@ public class AudioPlayer
         }
         viewHolder.runtime.setText(
                 String.format("%s / %s", formatTime(current), formatTime(duration)));
-        return true;
     }
 
     @Override
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/ConversationMenuConfigurator.java b/src/main/java/tel/xmpp/jabjab/ui/util/ConversationMenuConfigurator.java
index ecfe40c..3bbf045 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/ConversationMenuConfigurator.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/ConversationMenuConfigurator.java
@@ -68,8 +68,7 @@ public class ConversationMenuConfigurator {
             visible =
                     next != Message.ENCRYPTION_NONE
                             || conversation.isPrivateAndNonAnonymous()
-                            || conversation.getBooleanAttribute(
-                                    Conversation.ATTRIBUTE_FORMERLY_PRIVATE_NON_ANONYMOUS, false);
+                            || conversation.isFormerlyPrivateNonAnonymous();
         } else {
             visible = true;
         }
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/MucDetailsContextMenuHelper.java b/src/main/java/tel/xmpp/jabjab/ui/util/MucDetailsContextMenuHelper.java
index c473db9..8da7934 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/MucDetailsContextMenuHelper.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/MucDetailsContextMenuHelper.java
@@ -144,60 +144,60 @@ public final class MucDetailsContextMenuHelper {
             final MenuItem item, User user, final XmppActivity activity, final String fingerprint) {
         final Conversation conversation = user.getConversation();
         Jid jid = user.getRealJid();
-        switch (item.getItemId()) {
-            case R.id.action_contact_details:
-                final Jid realJid = user.getRealJid();
-                final Account account = conversation.getAccount();
-                final Contact contact =
-                        realJid == null ? null : account.getRoster().getContact(realJid);
-                if (contact != null) {
-                    if (contact.isSelf()) {
-                        activity.switchToAccount(account);
-                    } else {
-                        activity.switchToContactDetails(contact, fingerprint);
-                    }
-                }
-                return true;
-            case R.id.start_conversation:
-                startConversation(user, activity);
-                return true;
-            case R.id.give_admin_privileges:
-                changeAffiliationInConference(activity, conversation, jid, Affiliation.ADMIN);
-                return true;
-            case R.id.give_membership:
-            case R.id.remove_admin_privileges:
-            case R.id.revoke_owner_privileges:
-                changeAffiliationInConference(activity, conversation, jid, Affiliation.MEMBER);
-                return true;
-            case R.id.give_owner_privileges:
-                changeAffiliationInConference(activity, conversation, jid, Affiliation.OWNER);
-                return true;
-            case R.id.remove_membership:
-                changeAffiliationInConference(activity, conversation, jid, Affiliation.NONE);
-                return true;
-            case R.id.remove_from_room:
-                removeFromRoom(user, activity);
-                return true;
-            case R.id.send_private_message:
-                if (activity instanceof ConversationsActivity) {
-                    ConversationFragment conversationFragment = ConversationFragment.get(activity);
-                    if (conversationFragment != null) {
-                        conversationFragment.privateMessageWith(user.getFullJid());
-                        return true;
-                    }
-                }
-                activity.privateMsgInMuc(conversation, user.resource());
-                return true;
-            case R.id.invite:
-                // TODO use direct invites for public conferences
-                if (user.ranks(Affiliation.MEMBER)) {
-                    activity.xmppConnectionService.directInvite(conversation, jid.asBareJid());
+        final int itemId = item.getItemId();
+        if (itemId == R.id.action_contact_details) {
+            final Jid realJid = user.getRealJid();
+            final Account account = conversation.getAccount();
+            final Contact contact =
+                    realJid == null ? null : account.getRoster().getContact(realJid);
+            if (contact != null) {
+                if (contact.isSelf()) {
+                    activity.switchToAccount(account);
                 } else {
-                    activity.xmppConnectionService.invite(conversation, jid);
+                    activity.switchToContactDetails(contact, fingerprint);
                 }
-                return true;
-            default:
-                return false;
+            }
+            return true;
+        } else if (itemId == R.id.start_conversation) {
+            startConversation(user, activity);
+            return true;
+        } else if (itemId == R.id.give_admin_privileges) {
+            changeAffiliationInConference(activity, conversation, jid, Affiliation.ADMIN);
+            return true;
+        } else if (itemId == R.id.give_membership
+                || itemId == R.id.remove_admin_privileges
+                || itemId == R.id.revoke_owner_privileges) {
+            changeAffiliationInConference(activity, conversation, jid, Affiliation.MEMBER);
+            return true;
+        } else if (itemId == R.id.give_owner_privileges) {
+            changeAffiliationInConference(activity, conversation, jid, Affiliation.OWNER);
+            return true;
+        } else if (itemId == R.id.remove_membership) {
+            changeAffiliationInConference(activity, conversation, jid, Affiliation.NONE);
+            return true;
+        } else if (itemId == R.id.remove_from_room) {
+            removeFromRoom(user, activity);
+            return true;
+        } else if (itemId == R.id.send_private_message) {
+            if (activity instanceof ConversationsActivity) {
+                ConversationFragment conversationFragment = ConversationFragment.get(activity);
+                if (conversationFragment != null) {
+                    conversationFragment.privateMessageWith(user.getFullJid());
+                    return true;
+                }
+            }
+            activity.privateMsgInMuc(conversation, user.resource());
+            return true;
+        } else if (itemId == R.id.invite) {
+            // TODO use direct invites for public conferences
+            if (user.ranks(Affiliation.MEMBER)) {
+                activity.xmppConnectionService.directInvite(conversation, jid.asBareJid());
+            } else {
+                activity.xmppConnectionService.invite(conversation, jid);
+            }
+            return true;
+        } else {
+            return false;
         }
     }
 
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/SettingsSearchIndex.java b/src/main/java/tel/xmpp/jabjab/ui/util/SettingsSearchIndex.java
new file mode 100644
index 0000000..a94971e
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/SettingsSearchIndex.java
@@ -0,0 +1,245 @@
+package tel.xmpp.jabjab.ui.util;
+
+import android.content.Context;
+import android.content.res.Configuration;
+import android.content.res.Resources;
+import android.content.res.XmlResourceParser;
+import android.util.Log;
+
+import androidx.annotation.StringRes;
+import androidx.annotation.XmlRes;
+
+import org.xmlpull.v1.XmlPullParser;
+
+import tel.xmpp.jabjab.Config;
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.utils.CharSequences;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Small, hand-written, in-memory search index over every settings sub-screen's preference rows
+ * (title + summary), built once per process and cached — string resources don't change at
+ * runtime, so there's nothing to invalidate.
+ *
+ * <p>Matching checks every shipped locale's copy of a row's text, not just the one currently
+ * displayed, so a query typed in e.g. German finds a row even while the UI is showing English
+ * (see {@link #buildIndex(Context)}); the text actually shown for a match always comes from the
+ * current display locale.
+ */
+public class SettingsSearchIndex {
+
+    private static final List<ScreenDescriptor> SCREENS =
+            List.of(
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.MainSettingsFragment",
+                            R.xml.preferences_main,
+                            R.string.title_activity_settings),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.InterfaceSettingsFragment",
+                            R.xml.preferences_interface,
+                            R.string.pref_title_interface),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.InterfaceBubblesSettingsFragment",
+                            R.xml.preferences_interface_bubbles,
+                            R.string.pref_title_bubbles),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.SecuritySettingsFragment",
+                            R.xml.preferences_security,
+                            R.string.pref_title_security),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.PrivacySettingsFragment",
+                            R.xml.preferences_privacy,
+                            R.string.pref_privacy),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.AvailabilitySettingsFragment",
+                            R.xml.preferences_availability,
+                            R.string.pref_presence_settings),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.NotificationsSettingsFragment",
+                            R.xml.preferences_notifications,
+                            R.string.notifications),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.AttachmentsSettingsFragment",
+                            R.xml.preferences_attachments,
+                            R.string.pref_attachments),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.ConnectionSettingsFragment",
+                            R.xml.preferences_connection,
+                            R.string.pref_connection_options),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.BackupSettingsFragment",
+                            R.xml.preferences_backup,
+                            R.string.backup_and_restore),
+                    new ScreenDescriptor(
+                            "tel.xmpp.jabjab.ui.fragment.settings.UpSettingsFragment",
+                            R.xml.preferences_up,
+                            R.string.unified_push_distributor));
+
+    private static final Object LOCK = new Object();
+    private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
+    private static List<Entry> cachedIndex = null;
+
+    private SettingsSearchIndex() {}
+
+    /**
+     * Kicks off building the index on a background thread if it hasn't been built yet, so it's
+     * already warm by the time the user opens search. Safe to call repeatedly (e.g. every
+     * SettingsActivity#onCreate) — a no-op once the index exists.
+     */
+    public static void warmUp(final Context context) {
+        synchronized (LOCK) {
+            if (cachedIndex != null) {
+                return;
+            }
+        }
+        final Context appContext = context.getApplicationContext();
+        EXECUTOR.execute(() -> get(appContext));
+    }
+
+    /** Matches a query against every entry across all shipped locales. */
+    public static List<Entry> search(final Context context, final String query) {
+        final String trimmed = CharSequences.nullToEmpty(query).trim();
+        if (trimmed.isEmpty()) {
+            return List.of();
+        }
+        final List<Entry> matches = new ArrayList<>();
+        for (final Entry entry : get(context)) {
+            if (CharSequences.containsAll(entry.allLocaleText(), trimmed)) {
+                matches.add(entry);
+            }
+        }
+        return matches;
+    }
+
+    private static List<Entry> get(final Context context) {
+        synchronized (LOCK) {
+            if (cachedIndex != null) {
+                return cachedIndex;
+            }
+        }
+        final List<Entry> built = buildIndex(context.getApplicationContext());
+        synchronized (LOCK) {
+            cachedIndex = built;
+            return cachedIndex;
+        }
+    }
+
+    private static List<Entry> buildIndex(final Context appContext) {
+        final List<Resources> localeResources = allShippedLocaleResources(appContext);
+        final Resources currentResources = appContext.getResources();
+        final List<Entry> entries = new ArrayList<>();
+        for (final ScreenDescriptor screen : SCREENS) {
+            final String screenTitle = safeGetString(currentResources, screen.screenTitleRes());
+            for (final RawRow row : parseScreen(appContext, screen.preferencesXml())) {
+                final String title = safeGetString(currentResources, row.titleRes());
+                if (title.isEmpty()) {
+                    continue;
+                }
+                final String summary = safeGetString(currentResources, row.summaryRes());
+                final StringBuilder allText = new StringBuilder();
+                for (final Resources localeRes : localeResources) {
+                    allText.append(safeGetString(localeRes, row.titleRes()))
+                            .append(' ')
+                            .append(safeGetString(localeRes, row.summaryRes()))
+                            .append(' ');
+                }
+                entries.add(
+                        new Entry(
+                                row.key(),
+                                screen.fragmentClassName(),
+                                title,
+                                summary,
+                                screenTitle,
+                                allText.toString().toLowerCase(Locale.ROOT)));
+            }
+        }
+        return entries;
+    }
+
+    private static String safeGetString(final Resources resources, @StringRes final int resId) {
+        if (resId == 0) {
+            return "";
+        }
+        try {
+            return resources.getString(resId);
+        } catch (final Resources.NotFoundException e) {
+            return "";
+        }
+    }
+
+    private static List<Resources> allShippedLocaleResources(final Context appContext) {
+        final List<Resources> result = new ArrayList<>();
+        // Whatever is currently active (covers the base/English fallback even if getLocales()
+        // below doesn't separately enumerate it).
+        result.add(appContext.getResources());
+        for (final String tag : appContext.getAssets().getLocales()) {
+            if (tag == null || tag.isEmpty()) {
+                continue;
+            }
+            try {
+                final Configuration configuration =
+                        new Configuration(appContext.getResources().getConfiguration());
+                configuration.setLocale(Locale.forLanguageTag(tag));
+                result.add(appContext.createConfigurationContext(configuration).getResources());
+            } catch (final Exception e) {
+                Log.w(Config.LOGTAG, "SettingsSearchIndex: skipping locale " + tag, e);
+            }
+        }
+        return result;
+    }
+
+    private static List<RawRow> parseScreen(final Context context, @XmlRes final int xmlRes) {
+        final List<RawRow> result = new ArrayList<>();
+        XmlResourceParser parser = null;
+        try {
+            parser = context.getResources().getXml(xmlRes);
+            int eventType = parser.getEventType();
+            while (eventType != XmlPullParser.END_DOCUMENT) {
+                if (eventType == XmlPullParser.START_TAG) {
+                    String key = null;
+                    int titleRes = 0;
+                    int summaryRes = 0;
+                    for (int i = 0; i < parser.getAttributeCount(); i++) {
+                        final String name = parser.getAttributeName(i);
+                        if ("key".equals(name)) {
+                            key = parser.getAttributeValue(i);
+                        } else if ("title".equals(name)) {
+                            titleRes = parser.getAttributeResourceValue(i, 0);
+                        } else if ("summary".equals(name)) {
+                            summaryRes = parser.getAttributeResourceValue(i, 0);
+                        }
+                    }
+                    if (key != null && titleRes != 0) {
+                        result.add(new RawRow(key, titleRes, summaryRes));
+                    }
+                }
+                eventType = parser.next();
+            }
+        } catch (final Exception e) {
+            Log.w(Config.LOGTAG, "SettingsSearchIndex: failed to parse xml " + xmlRes, e);
+        } finally {
+            if (parser != null) {
+                parser.close();
+            }
+        }
+        return result;
+    }
+
+    private record ScreenDescriptor(
+            String fragmentClassName, @XmlRes int preferencesXml, @StringRes int screenTitleRes) {}
+
+    private record RawRow(String key, @StringRes int titleRes, @StringRes int summaryRes) {}
+
+    public record Entry(
+            String preferenceKey,
+            String fragmentClassName,
+            String title,
+            String summary,
+            String screenTitle,
+            String allLocaleText) {}
+}
diff --git a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
index 6254b56..1fab425 100644
--- a/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
+++ b/src/main/java/tel/xmpp/jabjab/ui/util/StickerStore.java
@@ -519,44 +519,9 @@ public class StickerStore {
 
     // Upload URLs are keyed by sender bare JID so that a sticker sent from account A
     // on server X never reuses an upload URL that belongs to account B on server Y.
-    public static String getStickerUploadUrl(final Pack pack, final File sticker,
-                                             final String accountJid) {
-        final JSONObject meta = readMeta(pack.dir());
-        if (meta == null) return null;
-        final JSONArray stickers = meta.optJSONArray("stickers");
-        if (stickers == null) return null;
-        for (int i = 0; i < stickers.length(); i++) {
-            final JSONObject entry = stickers.optJSONObject(i);
-            if (entry == null || !sticker.getName().equals(entry.optString("file"))) continue;
-            final JSONObject uploadUrls = entry.optJSONObject("uploadUrls");
-            if (uploadUrls == null) return null;
-            final String url = uploadUrls.optString(accountJid, "");
-            if (url.isEmpty()) return null;
-            // Reject stale prosody-filer URLs on upload.xmpp.tel — scoped to our domain
-            // so other servers that legitimately use /upload/ are not affected.
-            if (url.contains("upload.xmpp.tel/upload/")) {
-                Log.d(tel.xmpp.jabjab.Config.LOGTAG,
-                        "STICKER evicting stale prosody-filer URL for " + accountJid + ": " + url);
-                return null;
-            }
-            // Reject cached upload.xmpp.tel URLs for accounts that were never supposed to
-            // use that bypass (HttpUploadConnection now gates it on UPLOAD_BYPASS_DOMAIN,
-            // but a URL cached from before that check existed sticks around here forever
-            // and would keep bypassing the fix on every resend of the same sticker).
-            final boolean isBypassAccount =
-                    accountJid != null
-                            && accountJid.endsWith("@" + Config.UPLOAD_BYPASS_DOMAIN);
-            if (!isBypassAccount && url.contains(Config.UPLOAD_BYPASS_DOMAIN)) {
-                Log.d(Config.LOGTAG,
-                        "STICKER evicting non-bypass-account URL cached against "
-                                + Config.UPLOAD_BYPASS_DOMAIN + " for " + accountJid + ": " + url);
-                return null;
-            }
-            return url;
-        }
-        return null;
-    }
-
+    // This is write-only from the send path now (stickers always upload fresh instead of
+    // trying to skip a re-upload) — buildPepPackItem() below is the only reader, since it
+    // needs to list a self-made pack's stickers together when the pack itself is shared.
     public static void setStickerUploadUrl(final Pack pack, final File sticker,
                                            final String url, final String accountJid) {
         try {
@@ -590,8 +555,9 @@ public class StickerStore {
         } catch (Exception ignored) {}
     }
 
-    public static tel.xmpp.jabjab.xml.Element buildPepPackItem(final Pack pack) {
-        Log.d(Config.LOGTAG, "STICKER  building PEP pack item for pack=" + pack.displayName() + " dir=" + pack.dir());
+    public static tel.xmpp.jabjab.xml.Element buildPepPackItem(final Pack pack, final String accountJid) {
+        Log.d(Config.LOGTAG, "STICKER  building PEP pack item for pack=" + pack.displayName() + " dir=" + pack.dir()
+                + " account=" + accountJid);
         final JSONObject meta = readMeta(pack.dir());
         Log.d(Config.LOGTAG, "STICKER  meta=" + (meta != null ? meta.toString() : "null"));
         final tel.xmpp.jabjab.xml.Element item = new tel.xmpp.jabjab.xml.Element("item");
@@ -605,16 +571,20 @@ public class StickerStore {
                     final JSONObject entry = stickers.optJSONObject(i);
                     if (entry == null) continue;
                     String url = entry.optString("url", null);
-                    // For self-created stickers, fall back to any cached upload URL
-                    if (url == null || url.isEmpty()) {
+                    // For self-created stickers, fall back to the PUBLISHING account's own
+                    // confirmed upload — never a different account's cached entry. uploadUrls
+                    // is keyed by uploader JID precisely so that one account's (possibly
+                    // xmpp.tel-bypass-only, possibly since-deleted) URL never gets advertised
+                    // in a pack published by a different account that never actually uploaded
+                    // this file itself.
+                    if ((url == null || url.isEmpty()) && accountJid != null) {
                         final JSONObject uploadUrls = entry.optJSONObject("uploadUrls");
                         if (uploadUrls != null) {
-                            final java.util.Iterator<String> keys = uploadUrls.keys();
-                            if (keys.hasNext()) url = uploadUrls.optString(keys.next(), null);
+                            url = uploadUrls.optString(accountJid, null);
                         }
                     }
                     if (url == null || url.isEmpty()) {
-                        Log.d(Config.LOGTAG, "STICKER buildPepPackItem: skipping " + entry.optString("file") + " — no url");
+                        Log.d(Config.LOGTAG, "STICKER buildPepPackItem: skipping " + entry.optString("file") + " — no url for this account");
                         continue;
                     }
                     final tel.xmpp.jabjab.xml.Element stickerEl = packEl.addChild("sticker");
diff --git a/src/main/java/tel/xmpp/jabjab/ui/widget/AudioMiniPlayerView.java b/src/main/java/tel/xmpp/jabjab/ui/widget/AudioMiniPlayerView.java
new file mode 100644
index 0000000..d01bed3
--- /dev/null
+++ b/src/main/java/tel/xmpp/jabjab/ui/widget/AudioMiniPlayerView.java
@@ -0,0 +1,182 @@
+package tel.xmpp.jabjab.ui.widget;
+
+import android.content.Context;
+import android.content.Intent;
+import android.util.AttributeSet;
+import android.view.Gravity;
+import android.view.LayoutInflater;
+import android.widget.ImageButton;
+import android.widget.LinearLayout;
+import android.widget.SeekBar;
+import android.widget.TextView;
+
+import androidx.annotation.Nullable;
+
+import com.google.android.material.button.MaterialButton;
+import com.google.android.material.color.MaterialColors;
+
+import tel.xmpp.jabjab.R;
+import tel.xmpp.jabjab.entities.Conversation;
+import tel.xmpp.jabjab.entities.Message;
+import tel.xmpp.jabjab.ui.ConversationsActivity;
+import tel.xmpp.jabjab.ui.service.AudioPlayer;
+import tel.xmpp.jabjab.utils.TimeFrameUtils;
+
+/**
+ * Persistent "now playing" bar for voice notes (Telegram-style): stays visible across chats,
+ * bottom-nav tabs and (wherever it's included) other screens while a voice note keeps playing.
+ * Holds no playback state of its own — it's a pure observer of {@link AudioPlayer}'s static
+ * state via {@link AudioPlayer.PlaybackListener}, so staying GONE is its default, correct
+ * behavior whenever nothing is playing.
+ */
+public class AudioMiniPlayerView extends LinearLayout implements AudioPlayer.PlaybackListener {
+
+    private final MaterialButton playPauseButton;
+    private final MaterialButton speedToggleButton;
+    private final TextView titleView;
+    private final TextView timeView;
+    private final SeekBar progressBar;
+    private final ImageButton closeButton;
+
+    private boolean userIsSeeking = false;
+
+    public AudioMiniPlayerView(final Context context) {
+        this(context, null);
+    }
+
+    public AudioMiniPlayerView(final Context context, @Nullable final AttributeSet attrs) {
+        this(context, attrs, 0);
+    }
+
+    public AudioMiniPlayerView(
+            final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) {
+        super(context, attrs, defStyleAttr);
+        LayoutInflater.from(context).inflate(R.layout.view_audio_mini_player, this, true);
+        setOrientation(HORIZONTAL);
+        setGravity(Gravity.CENTER_VERTICAL);
+        setClickable(true);
+        setFocusable(true);
+        setBackgroundColor(
+                MaterialColors.getColor(
+                        this, com.google.android.material.R.attr.colorSurfaceContainer));
+        final int padH = (int) (12 * getResources().getDisplayMetrics().density);
+        final int padV = (int) (8 * getResources().getDisplayMetrics().density);
+        setPadding(padH, padV, padH, padV);
+        setVisibility(GONE);
+
+        this.playPauseButton = findViewById(R.id.mini_player_play_pause);
+        this.speedToggleButton = findViewById(R.id.mini_player_speed_toggle);
+        this.titleView = findViewById(R.id.mini_player_title);
+        this.timeView = findViewById(R.id.mini_player_time);
+        this.progressBar = findViewById(R.id.mini_player_progress);
+        this.closeButton = findViewById(R.id.mini_player_close);
+
+        this.playPauseButton.setOnClickListener(v -> AudioPlayer.playPauseCurrentGlobally());
+        this.speedToggleButton.setOnClickListener(v -> AudioPlayer.cycleSpeedGlobally());
+        this.closeButton.setOnClickListener(v -> AudioPlayer.stopCurrentGlobally());
+        this.progressBar.setOnSeekBarChangeListener(
+                new SeekBar.OnSeekBarChangeListener() {
+                    @Override
+                    public void onProgressChanged(
+                            final SeekBar seekBar, final int progress, final boolean fromUser) {
+                        if (fromUser) {
+                            AudioPlayer.seekCurrentGlobally(progress);
+                        }
+                    }
+
+                    @Override
+                    public void onStartTrackingTouch(final SeekBar seekBar) {
+                        userIsSeeking = true;
+                    }
+
+                    @Override
+                    public void onStopTrackingTouch(final SeekBar seekBar) {
+                        userIsSeeking = false;
+                    }
+                });
+        setOnClickListener(v -> openConversation());
+    }
+
+    @Override
+    protected void onAttachedToWindow() {
+        super.onAttachedToWindow();
+        AudioPlayer.addPlaybackListener(this);
+        // The player may already be running by the time this bar gets attached (e.g. rotation,
+        // or navigating into a screen that includes this view while a track is already playing)
+        // — reflect current state immediately instead of waiting for the next tick/event.
+        final Message message = AudioPlayer.getCurrentlyPlayingMessage();
+        if (message != null) {
+            bind(message);
+            onPlaybackStateChanged(message, AudioPlayer.isCurrentlyPlaying());
+            // Without this, a freshly (re)created bar — e.g. the Activity was recreated while
+            // backgrounded, even though AudioPlayer's static state survived in the process —
+            // shows the seek bar stuck at its XML default (0%) until the next onProgress tick,
+            // which only fires once playback actually resumes.
+            onProgress(message, AudioPlayer.getCurrentPositionMs(), AudioPlayer.getDurationMs());
+            speedToggleButton.setText(AudioPlayer.getSpeedLabel());
+        } else {
+            setVisibility(GONE);
+        }
+    }
+
+    @Override
+    protected void onDetachedFromWindow() {
+        AudioPlayer.removePlaybackListener(this);
+        super.onDetachedFromWindow();
+    }
+
+    private void bind(final Message message) {
+        setVisibility(VISIBLE);
+        final var conversation = message.getConversation();
+        titleView.setText(conversation instanceof Conversation c ? c.getName() : "");
+    }
+
+    private void openConversation() {
+        final Message message = AudioPlayer.getCurrentlyPlayingMessage();
+        if (message == null) {
+            return;
+        }
+        final Intent intent = new Intent(getContext(), ConversationsActivity.class);
+        intent.putExtra(ConversationsActivity.EXTRA_CONVERSATION, message.getConversation().getUuid());
+        intent.putExtra(ConversationsActivity.EXTRA_SCROLL_TO_MESSAGE, message.getUuid());
+        getContext().startActivity(intent);
+    }
+
+    @Override
+    public void onProgress(final Message message, final int currentMs, final int durationMs) {
+        post(
+                () -> {
+                    if (userIsSeeking) {
+                        return;
+                    }
+                    final int percent =
+                            durationMs <= 0 ? 0 : Math.min(100, currentMs * 100 / durationMs);
+                    progressBar.setProgress(percent);
+                    timeView.setText(
+                            String.format(
+                                    "%s / %s",
+                                    TimeFrameUtils.formatElapsedTime(currentMs, false),
+                                    TimeFrameUtils.formatElapsedTime(durationMs, false)));
+                });
+    }
+
+    @Override
+    public void onPlaybackStateChanged(final Message message, final boolean playing) {
+        post(
+                () -> {
+                    bind(message);
+                    playPauseButton.setIconResource(
+                            playing ? R.drawable.ic_pause_24dp : R.drawable.ic_play_arrow_24dp);
+                });
+    }
+
+    @Override
+    public void onSpeedChanged(final String speedLabel) {
+        post(() -> speedToggleButton.setText(speedLabel));
+    }
+
+    @Override
+    public void onPlaybackStopped() {
+        post(() -> setVisibility(GONE));
+    }
+}
diff --git a/src/main/java/tel/xmpp/jabjab/utils/Compatibility.java b/src/main/java/tel/xmpp/jabjab/utils/Compatibility.java
index 7dc4d41..2d65497 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/Compatibility.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/Compatibility.java
@@ -1,7 +1,5 @@
 package tel.xmpp.jabjab.utils;
 
-import static tel.xmpp.jabjab.receiver.SystemEventReceiver.EXTRA_NEEDS_FOREGROUND_SERVICE;
-
 import android.annotation.SuppressLint;
 import android.app.ActivityOptions;
 import android.content.Context;
@@ -18,6 +16,8 @@ import tel.xmpp.jabjab.Config;
 
 public class Compatibility {
 
+    public static final String EXTRA_NEEDS_FOREGROUND_SERVICE = "needs_foreground_service";
+
     public static boolean hasStoragePermission(final Context context) {
         return Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
                 || ContextCompat.checkSelfPermission(
diff --git a/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java b/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
index 89f405f..c668ec3 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/GeoHelper.java
@@ -4,11 +4,9 @@ import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
 import de.gultsch.common.MiniUri;
-import de.gultsch.common.Patterns;
 import tel.xmpp.jabjab.R;
 import tel.xmpp.jabjab.entities.Message;
 import tel.xmpp.jabjab.ui.ShowLocationActivity;
-import java.util.regex.Matcher;
 import org.osmdroid.util.GeoPoint;
 
 public class GeoHelper {
@@ -18,25 +16,16 @@ public class GeoHelper {
     }
 
     public static GeoPoint parseGeoPoint(String body) throws IllegalArgumentException {
-        final Matcher matcher = Patterns.URI_GEO.matcher(body);
-        if (!matcher.matches()) {
+        final var uri = MiniUri.asMiniUri(body);
+        if (!(uri instanceof MiniUri.Geo geo)) {
             throw new IllegalArgumentException("Invalid geo uri");
         }
-        final double latitude;
-        final double longitude;
-        try {
-            latitude = Double.parseDouble(matcher.group(1));
-            if (latitude > 90.0 || latitude < -90.0) {
-                throw new IllegalArgumentException("Invalid geo uri");
-            }
-            longitude = Double.parseDouble(matcher.group(2));
-            if (longitude > 180.0 || longitude < -180.0) {
-                throw new IllegalArgumentException("Invalid geo uri");
-            }
-        } catch (final NumberFormatException e) {
-            throw new IllegalArgumentException("Invalid geo uri", e);
+        final var latitude = geo.getLatitude();
+        final var longitude = geo.getLongitude();
+        if (latitude > 90.0 || latitude < -90.0 || longitude > 180.0 || longitude < -180.0) {
+            throw new IllegalArgumentException("Invalid geo uri");
         }
-        return new GeoPoint(latitude, longitude);
+        return geo.asGeoPoint();
     }
 
     public static void view(final Context context, final Message message) {
diff --git a/src/main/java/tel/xmpp/jabjab/utils/IP.java b/src/main/java/tel/xmpp/jabjab/utils/IP.java
index b0f88f0..c64e1da 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/IP.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/IP.java
@@ -1,20 +1,10 @@
 package tel.xmpp.jabjab.utils;
 
 import com.google.common.net.InetAddresses;
-import de.gultsch.common.Patterns;
 import java.net.InetAddress;
 
 public class IP {
 
-    public static boolean matches(final String server) {
-        return server != null
-                && (Patterns.IPV4.matcher(server).matches()
-                        || Patterns.IPV6.matcher(server).matches()
-                        || Patterns.IPV6_6HEX4DEC.matcher(server).matches()
-                        || Patterns.IPV6_HEX4_DECOMPRESSED.matcher(server).matches()
-                        || Patterns.IPV6_HEX_COMPRESSED.matcher(server).matches());
-    }
-
     public static String wrapIPv6(final String host) {
         if (InetAddresses.isInetAddress(host)) {
             final InetAddress inetAddress;
diff --git a/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java b/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
index a6ea2d1..eb5fc40 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/MimeUtils.java
@@ -331,6 +331,8 @@ public final class MimeUtils {
         add("image/vnd.djvu", "djv");
         add("image/vnd.wap.wbmp", "wbmp");
         add("image/webp", "webp");
+        add("image/x-adobe-dng", "dng");
+        add("image/x-raw-adobe", "dng");
         add("image/x-cmu-raster", "ras");
         add("image/x-coreldraw", "cdr");
         add("image/x-coreldrawpattern", "pat");
diff --git a/src/main/java/tel/xmpp/jabjab/utils/PermissionUtils.java b/src/main/java/tel/xmpp/jabjab/utils/PermissionUtils.java
index 78cb626..3578e86 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/PermissionUtils.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/PermissionUtils.java
@@ -4,17 +4,29 @@ import android.Manifest;
 import android.app.Activity;
 import android.content.pm.PackageManager;
 import android.os.Build;
-
 import androidx.core.app.ActivityCompat;
-
 import com.google.common.collect.ImmutableList;
 import com.google.common.primitives.Ints;
-
 import java.util.ArrayList;
 import java.util.List;
 
 public class PermissionUtils {
 
+    public static List<String> rtpPermissions(final boolean withCamera) {
+        final var builder = new ImmutableList.Builder<String>();
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
+            builder.add(Manifest.permission.ACCESS_LOCAL_NETWORK);
+        }
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+            builder.add(Manifest.permission.BLUETOOTH_CONNECT);
+        }
+        if (withCamera) {
+            builder.add(Manifest.permission.CAMERA);
+        }
+        builder.add(Manifest.permission.RECORD_AUDIO);
+        return builder.build();
+    }
+
     public static boolean allGranted(int[] grantResults) {
         for (int grantResult : grantResults) {
             if (grantResult != PackageManager.PERMISSION_GRANTED) {
@@ -56,17 +68,9 @@ public class PermissionUtils {
         return null;
     }
 
-    public static class PermissionResult {
-        public final String[] permissions;
-        public final int[] grantResults;
+    public record PermissionResult(String[] permissions, int[] grantResults) {}
 
-        public PermissionResult(String[] permissions, int[] grantResults) {
-            this.permissions = permissions;
-            this.grantResults = grantResults;
-        }
-    }
-
-    public static PermissionResult removeBluetoothConnect(
+    public static PermissionResult removeOptionalRtpPermissions(
             final String[] inPermissions, final int[] inGrantResults) {
         final List<String> outPermissions = new ArrayList<>();
         final List<Integer> outGrantResults = new ArrayList<>();
@@ -76,6 +80,13 @@ public class PermissionUtils {
                     continue;
                 }
             }
+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
+                if (inPermissions[i].equals(Manifest.permission.ACCESS_LOCAL_NETWORK)) {
+                    continue;
+                }
+            }
+
             outPermissions.add(inPermissions[i]);
             outGrantResults.add(inGrantResults[i]);
         }
diff --git a/src/main/java/tel/xmpp/jabjab/utils/Resolver.java b/src/main/java/tel/xmpp/jabjab/utils/Resolver.java
index afca016..5605ad1 100644
--- a/src/main/java/tel/xmpp/jabjab/utils/Resolver.java
+++ b/src/main/java/tel/xmpp/jabjab/utils/Resolver.java
@@ -143,10 +143,10 @@ public class Resolver {
     }
 
     private static List<Result> fromIpAddress(final String domain) {
-        if (IP.matches(domain)) {
+        if (InetAddresses.isUriInetAddress(domain)) {
             final InetAddress inetAddress;
             try {
-                inetAddress = InetAddresses.forString(domain);
+                inetAddress = InetAddresses.forUriString(domain);
             } catch (final IllegalArgumentException e) {
                 return Collections.emptyList();
             }
@@ -418,6 +418,13 @@ public class Resolver {
             return authenticated;
         }
 
+        public boolean isLocalAddress() {
+            return ip != null
+                    && (ip.isLoopbackAddress()
+                            || ip.isSiteLocalAddress()
+                            || ip.isLinkLocalAddress());
+        }
+
         @Override
         @NonNull
         public String toString() {
@@ -463,6 +470,7 @@ public class Resolver {
                 if (port == null || Strings.isNullOrEmpty(hostPart)) {
                     return null;
                 }
+                // TODO instead of unwrap check if uri ip
                 final String host = tel.xmpp.jabjab.utils.IP.unwrapIPv6(hostPart);
                 result.port = port;
                 if (InetAddresses.isInetAddress(host)) {
diff --git a/src/main/java/tel/xmpp/jabjab/xml/Namespace.java b/src/main/java/tel/xmpp/jabjab/xml/Namespace.java
index 4963195..529432f 100644
--- a/src/main/java/tel/xmpp/jabjab/xml/Namespace.java
+++ b/src/main/java/tel/xmpp/jabjab/xml/Namespace.java
@@ -135,4 +135,7 @@ public final class Namespace {
     public static final String FALLBACK_INDICATION = "urn:xmpp:fallback:0";
     public static final String EPHEMERAL_MESSAGES = "urn:xmpp:ephemeral:0";
     public static final String FASTEN = "urn:xmpp:fasten:0";
+    public static final String LIMITS = "urn:xmpp:stream-limits:0";
+    public static final String LAST = "jabber:iq:last";
+    public static final String REPORTING_ACCOUNT_AFFILIATIONS = "urn:xmpp:raa:0";
 }
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/Jid.java b/src/main/java/tel/xmpp/jabjab/xmpp/Jid.java
index ee0dd03..469e9b9 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/Jid.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/Jid.java
@@ -3,8 +3,8 @@ package tel.xmpp.jabjab.xmpp;
 import androidx.annotation.NonNull;
 import com.google.common.base.Splitter;
 import com.google.common.collect.Iterables;
+import com.google.common.net.InetAddresses;
 import tel.xmpp.jabjab.services.QuickConversationsService;
-import tel.xmpp.jabjab.utils.IP;
 import im.conversations.android.xmpp.model.stanza.Stanza;
 import java.io.Serializable;
 import java.net.IDN;
@@ -109,7 +109,7 @@ public abstract class Jid implements Comparable<Jid>, Serializable, CharSequence
                 }
             }
             return jid.isBareJid() ? bare : bare.withResource(jid.getResource());
-        } else if (IP.matches(domain)) {
+        } else if (InetAddresses.isUriInetAddress(domain)) {
             return jid;
         }
         throw new IllegalArgumentException("Invalid hostname");
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
index e514e54..dfb904e 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/XmppConnection.java
@@ -2,7 +2,10 @@ package tel.xmpp.jabjab.xmpp;
 
 import static tel.xmpp.jabjab.utils.Random.SECURE_RANDOM;
 
+import android.Manifest;
 import android.content.Context;
+import android.content.pm.PackageManager;
+import android.os.Build;
 import android.os.SystemClock;
 import android.security.KeyChain;
 import android.util.Base64;
@@ -11,6 +14,7 @@ import android.util.Pair;
 import android.util.SparseArray;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
 import com.google.common.base.Optional;
 import com.google.common.base.Preconditions;
 import com.google.common.base.Strings;
@@ -460,6 +464,12 @@ public class XmppConnection implements Runnable {
                                     + ": injected see-other-host on position 0");
                     results.add(0, seeOtherHost);
                 }
+                final var missingLocalNetworkPermission =
+                        Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
+                                && ContextCompat.checkSelfPermission(
+                                                mXmppConnectionService,
+                                                Manifest.permission.ACCESS_LOCAL_NETWORK)
+                                        != PackageManager.PERMISSION_GRANTED;
                 for (final Iterator<Resolver.Result> iterator = results.iterator();
                         iterator.hasNext(); ) {
                     final Resolver.Result result = iterator.next();
@@ -507,12 +517,14 @@ public class XmppConnection implements Runnable {
 
                         localSocket = new Socket();
                         final int timeout;
-                        if (new NetworkManager(mXmppConnectionService).getHint()
-                                == NetworkManager.Hint.ACTIVE) {
-                            timeout = Config.SOCKET_TIMEOUT;
-                        } else {
+                        if ((result.isLocalAddress() && missingLocalNetworkPermission)
+                                || new NetworkManager(mXmppConnectionService).getHint()
+                                        != NetworkManager.Hint.ACTIVE) {
                             timeout = Config.SOCKET_TIMEOUT_LOW;
+                        } else {
+                            timeout = Config.SOCKET_TIMEOUT;
                         }
+                        Log.d(Config.LOGTAG, "using timeout=" + timeout);
                         localSocket.connect(addr, timeout);
                         localSocket.setSoTimeout(timeout);
                         if (features.encryptionEnabled) {
@@ -543,14 +555,11 @@ public class XmppConnection implements Runnable {
                                         + ": thread was interrupted before beginning stream");
                         return;
                     } catch (final Throwable e) {
-                        Log.d(
-                                Config.LOGTAG,
-                                account.getJid().asBareJid().toString()
-                                        + ": "
-                                        + e.getMessage()
-                                        + "("
-                                        + e.getClass().getName()
-                                        + ")");
+                        if (result.isLocalAddress() && missingLocalNetworkPermission) {
+                            throw new StateChangingException(
+                                    Account.State.MISSING_LOCAL_NETWORK_PERMISSION);
+                        }
+                        Log.d(Config.LOGTAG, "could not connect", e);
                         if (!iterator.hasNext()) {
                             throw new UnknownHostException();
                         }
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/mam/MamReference.java b/src/main/java/tel/xmpp/jabjab/xmpp/mam/MamReference.java
index fdc3912..139f14d 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/mam/MamReference.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/mam/MamReference.java
@@ -1,37 +1,20 @@
 package tel.xmpp.jabjab.xmpp.mam;
 
-public class MamReference {
-
-    private final long timestamp;
-    private final String reference;
+public record MamReference(long timestamp, String reference) {
 
     public MamReference(long timestamp) {
-        this.timestamp = timestamp;
-        this.reference = null;
-    }
-
-    public MamReference(long timestamp, String reference) {
-        this.timestamp = timestamp;
-        this.reference = reference;
-    }
-
-    public long getTimestamp() {
-        return timestamp;
-    }
-
-    public String getReference() {
-        return reference;
+        this(timestamp, null);
     }
 
     public boolean greaterThan(MamReference b) {
-        return timestamp > b.getTimestamp();
+        return timestamp > b.timestamp();
     }
 
     public boolean greaterThan(long b) {
         return timestamp > b;
     }
 
-    public static MamReference max(MamReference a, MamReference b) {
+    public static MamReference max(final MamReference a, final MamReference b) {
         if (a != null && b != null) {
             return a.timestamp > b.timestamp ? a : b;
         } else if (a != null) {
@@ -42,25 +25,7 @@ public class MamReference {
     }
 
     public static MamReference max(MamReference a, long b) {
-        return max(a,new MamReference(b));
-    }
-
-    public static MamReference fromAttribute(String attr) {
-        if (attr == null) {
-            return new MamReference(0);
-        } else {
-            String[] attrs = attr.split(":");
-            try {
-                long timestamp = Long.parseLong(attrs[0]);
-                if (attrs.length >= 2) {
-                    return new MamReference(timestamp,attrs[1]);
-                } else {
-                    return new MamReference(timestamp);
-                }
-            } catch (Exception e) {
-                return new MamReference(0);
-            }
-        }
+        return max(a, new MamReference(b));
     }
 
     public MamReference timeOnly() {
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DisplayedManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DisplayedManager.java
index 5ad4941..b41cc5a 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/DisplayedManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/DisplayedManager.java
@@ -42,7 +42,9 @@ public class DisplayedManager extends AbstractManager {
         if (packet.fromAccount(account) && !selfAddressed) {
             final Conversation c = this.service.find(account, counterpart.asBareJid());
             final Message message =
-                    (c == null || id == null) ? null : c.findReceivedWithRemoteId(id);
+                    (c == null || id == null)
+                            ? null
+                            : c.findMessageWithUuidOrRemoteId(id, null, true);
             if (message != null && (query == null || query.isCatchup())) {
                 this.service.markReadUpTo(c, message);
             }
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java
index ffbc372..b235563 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/JingleMessageManager.java
@@ -15,7 +15,7 @@ import tel.xmpp.jabjab.xmpp.jingle.Media;
 import im.conversations.android.xmpp.model.hints.Store;
 import im.conversations.android.xmpp.model.jingle.Jingle;
 import im.conversations.android.xmpp.model.jingle.Reason;
-import im.conversations.android.xmpp.model.jingle.apps.rtp.Description;
+import im.conversations.android.xmpp.model.jingle.apps.rtp.RtpDescription;
 import im.conversations.android.xmpp.model.jmi.Accept;
 import im.conversations.android.xmpp.model.jmi.Device;
 import im.conversations.android.xmpp.model.jmi.Finish;
@@ -173,7 +173,7 @@ public class JingleMessageManager extends AbstractManager {
         packet.setId(JingleRtpConnection.JINGLE_MESSAGE_PROPOSE_ID_PREFIX + sessionId);
         final var propose = packet.addExtension(new Propose(sessionId));
         for (final var m : media) {
-            final var description = propose.addExtension(new Description());
+            final var description = propose.addExtension(new RtpDescription());
             description.setMedia(m);
         }
         packet.addExtension(new Request());
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/MessageArchiveManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/MessageArchiveManager.java
index 5e59f38..ba4de23 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/MessageArchiveManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/MessageArchiveManager.java
@@ -69,15 +69,15 @@ public class MessageArchiveManager extends AbstractManager {
         }
         long endCatchup = connection.getLastSessionEstablished();
         final Query query;
-        if (mamReference.getTimestamp() == 0) {
+        if (mamReference.timestamp() == 0) {
             return;
-        } else if (endCatchup - mamReference.getTimestamp() >= Config.MAM_MAX_CATCHUP) {
+        } else if (endCatchup - mamReference.timestamp() >= Config.MAM_MAX_CATCHUP) {
             long startCatchup = endCatchup - Config.MAM_MAX_CATCHUP;
             List<Conversation> conversations = mXmppConnectionService.getConversations();
             for (Conversation conversation : conversations) {
                 if (conversation.getMode() == Conversation.MODE_SINGLE
                         && conversation.getAccount() == getAccount()
-                        && startCatchup > conversation.getLastMessageTransmitted().getTimestamp()) {
+                        && startCatchup > conversation.getLastMessageTransmitted().timestamp()) {
                     this.query(conversation, startCatchup, true);
                 }
             }
@@ -92,7 +92,7 @@ public class MessageArchiveManager extends AbstractManager {
     }
 
     public void catchupMUC(final Conversation conversation) {
-        if (conversation.getLastMessageTransmitted().getTimestamp() < 0
+        if (conversation.getLastMessageTransmitted().timestamp() < 0
                 && conversation.countMessages() == 0) {
             query(conversation, new MamReference(0), 0, true);
         } else {
@@ -101,7 +101,7 @@ public class MessageArchiveManager extends AbstractManager {
     }
 
     public Query query(final Conversation conversation) {
-        if (conversation.getLastMessageTransmitted().getTimestamp() < 0
+        if (conversation.getLastMessageTransmitted().timestamp() < 0
                 && conversation.countMessages() == 0) {
             return query(conversation, new MamReference(0), System.currentTimeMillis(), false);
         } else {
@@ -150,7 +150,7 @@ public class MessageArchiveManager extends AbstractManager {
             } else {
                 startActual = start;
             }
-            if (start.getTimestamp() == 0) {
+            if (start.timestamp() == 0) {
                 query = new Query(conversation, startActual, end, false);
                 query.reference = conversation.getFirstMamReference();
             } else {
@@ -161,11 +161,7 @@ public class MessageArchiveManager extends AbstractManager {
                                     System.currentTimeMillis() - Config.MAM_MAX_CATCHUP);
                     if (maxCatchup.greaterThan(startActual)) {
                         Query reverseCatchup =
-                                new Query(
-                                        conversation,
-                                        startActual,
-                                        maxCatchup.getTimestamp(),
-                                        false);
+                                new Query(conversation, startActual, maxCatchup.timestamp(), false);
                         this.queries.add(reverseCatchup);
                         this.execute(reverseCatchup);
                     }
@@ -586,10 +582,10 @@ public class MessageArchiveManager extends AbstractManager {
         }
 
         Query(MamReference start, long end) {
-            if (start.getReference() != null) {
-                this.reference = start.getReference();
+            if (start.reference() != null) {
+                this.reference = start.reference();
             } else {
-                this.start = start.getTimestamp();
+                this.start = start.timestamp();
             }
             this.end = end;
             this.queryId = new BigInteger(50, SECURE_RANDOM).toString(32);
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java
index 011f2c4..ca8347f 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/MultiUserChatManager.java
@@ -55,7 +55,6 @@ import im.conversations.android.xmpp.model.muc.admin.Item;
 import im.conversations.android.xmpp.model.muc.admin.MucAdmin;
 import im.conversations.android.xmpp.model.muc.owner.Destroy;
 import im.conversations.android.xmpp.model.muc.owner.MucOwner;
-import im.conversations.android.xmpp.model.muc.user.Invite;
 import im.conversations.android.xmpp.model.muc.user.MucUser;
 import im.conversations.android.xmpp.model.occupant.OccupantId;
 import im.conversations.android.xmpp.model.pgp.Signed;
@@ -234,7 +233,7 @@ public class MultiUserChatManager extends AbstractManager {
         final MucOptions mucOptions = getOrCreateState(conversation);
         if (mucOptions.nonanonymous()
                 && !mucOptions.membersOnly()
-                && !conversation.getBooleanAttribute("accept_non_anonymous", false)) {
+                && !conversation.isAcceptNonAnonymous()) {
             synchronized (this.inProgressConferenceJoins) {
                 this.inProgressConferenceJoins.remove(conversation);
             }
@@ -262,7 +261,7 @@ public class MultiUserChatManager extends AbstractManager {
             history.setMaxStanzas(0);
         } else {
             // Fallback to muc history
-            history.setSince(conversation.getLastMessageTransmitted().getTimestamp());
+            history.setSince(conversation.getLastMessageTransmitted().timestamp());
         }
         available(joinJid, mucOptions.nonanonymous(), x);
         if (!joinJid.equals(conversation.getAddress())) {
@@ -1031,7 +1030,7 @@ public class MultiUserChatManager extends AbstractManager {
 
         if (configuration.get("muc#roomconfig_whois") instanceof String whois
                 && whois.equals("anyone")) {
-            conversation.setAttribute("accept_non_anonymous", true);
+            conversation.setAcceptNonAnonymous(true);
             getDatabase().updateConversation(conversation);
         }
 
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java
index 6a3c9f0..fbb7c54 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/ReactionManager.java
@@ -87,7 +87,8 @@ public class ReactionManager extends AbstractManager {
             }
         } else {
             final Message message;
-            final var inMemoryMessage = conversation.findMessageWithUuidOrRemoteId(reactingTo);
+            final var inMemoryMessage =
+                    conversation.findMessageWithUuidOrRemoteId(reactingTo, null, null);
             if (inMemoryMessage != null) {
                 message = inMemoryMessage;
             } else {
@@ -205,11 +206,7 @@ public class ReactionManager extends AbstractManager {
                             conversation.getAccount().getJid(),
                             occupantId);
         } else {
-            if (message.isCarbon() || message.getStatus() == Message.STATUS_RECEIVED) {
-                reactToId = message.getRemoteMsgId();
-            } else {
-                reactToId = message.getUuid();
-            }
+            reactToId = message.getMessageId();
             typeGroupChat = false;
             if (isPrivateMessage) {
                 reactTo = message.getCounterpart();
diff --git a/src/main/java/tel/xmpp/jabjab/xmpp/manager/RosterManager.java b/src/main/java/tel/xmpp/jabjab/xmpp/manager/RosterManager.java
index 7d44b5e..91191eb 100644
--- a/src/main/java/tel/xmpp/jabjab/xmpp/manager/RosterManager.java
+++ b/src/main/java/tel/xmpp/jabjab/xmpp/manager/RosterManager.java
@@ -165,8 +165,7 @@ public class RosterManager extends AbstractManager implements Roster {
                 contact.getOption(Contact.Options.TO) && contact.getOption(Contact.Options.FROM);
         if (!contact.getOption(Contact.Options.DIRTY_PUSH)) {
             contact.setServerName(name);
-            // TODO use item.getGroups()
-            contact.parseGroupsFromElement(item);
+            contact.setGroups(item.getGroups());
         }
         if (subscription == Item.Subscription.REMOVE) {
             contact.resetOption(Contact.Options.IN_ROSTER);
diff --git a/src/main/res/layout-w1024dp-h640dp/activity_conversations.xml b/src/main/res/layout-w1024dp-h640dp/activity_conversations.xml
index 32b56ff..825ffb2 100644
--- a/src/main/res/layout-w1024dp-h640dp/activity_conversations.xml
+++ b/src/main/res/layout-w1024dp-h640dp/activity_conversations.xml
@@ -25,13 +25,24 @@
                 android:layout_weight="1618" />
         </LinearLayout>
 
-        <com.google.android.material.bottomnavigation.BottomNavigationView
-            android:id="@+id/bottom_nav"
+        <LinearLayout
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:layout_gravity="bottom"
-            app:labelVisibilityMode="labeled"
-            app:menu="@menu/bottom_nav_main" />
+            android:orientation="vertical">
+
+            <tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView
+                android:id="@+id/audio_mini_player"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content" />
+
+            <com.google.android.material.bottomnavigation.BottomNavigationView
+                android:id="@+id/bottom_nav"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                app:labelVisibilityMode="labeled"
+                app:menu="@menu/bottom_nav_main" />
+        </LinearLayout>
 
     </FrameLayout>
 </layout>
diff --git a/src/main/res/layout/actionview_settings_search.xml b/src/main/res/layout/actionview_settings_search.xml
new file mode 100644
index 0000000..8f60af2
--- /dev/null
+++ b/src/main/res/layout/actionview_settings_search.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:addStatesFromChildren="true"
+                android:focusable="true"
+                android:gravity="center">
+
+    <EditText
+        android:id="@+id/search_field"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:focusable="true"
+        android:layout_marginHorizontal="12dp"
+        android:hint="@string/search_settings"
+        android:imeOptions="flagNoExtractUi|actionSearch"
+        android:inputType="text|textNoSuggestions"/>
+
+</RelativeLayout>
diff --git a/src/main/res/layout/activity_blog.xml b/src/main/res/layout/activity_blog.xml
index 06f582a..345cf29 100644
--- a/src/main/res/layout/activity_blog.xml
+++ b/src/main/res/layout/activity_blog.xml
@@ -156,6 +156,13 @@
 
     </androidx.coordinatorlayout.widget.CoordinatorLayout>
 
+    <!-- GONE by default (see AudioMiniPlayerView) — only takes space while a voice note
+         started elsewhere is still playing. -->
+    <tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView
+        android:id="@+id/audio_mini_player"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+
     <com.google.android.material.bottomnavigation.BottomNavigationView
         android:id="@+id/bottom_nav"
         android:layout_width="match_parent"
diff --git a/src/main/res/layout/activity_channel_discovery.xml b/src/main/res/layout/activity_channel_discovery.xml
index 9744651..dcd4e1d 100644
--- a/src/main/res/layout/activity_channel_discovery.xml
+++ b/src/main/res/layout/activity_channel_discovery.xml
@@ -126,6 +126,13 @@
 
         </androidx.coordinatorlayout.widget.CoordinatorLayout>
 
+        <!-- GONE by default (see AudioMiniPlayerView) — only takes space while a voice note
+             started elsewhere is still playing. -->
+        <tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView
+            android:id="@+id/audio_mini_player"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content" />
+
         <com.google.android.material.bottomnavigation.BottomNavigationView
             android:id="@+id/bottom_nav"
             android:layout_width="match_parent"
diff --git a/src/main/res/layout/activity_contact_details.xml b/src/main/res/layout/activity_contact_details.xml
index 8ea228e..ad9f3ff 100644
--- a/src/main/res/layout/activity_contact_details.xml
+++ b/src/main/res/layout/activity_contact_details.xml
@@ -23,7 +23,8 @@
 
         <ScrollView
             android:layout_width="fill_parent"
-            android:layout_height="fill_parent">
+            android:layout_height="0dp"
+            android:layout_weight="1">
 
             <LinearLayout
                 android:id="@+id/details_main_layout"
@@ -518,5 +519,12 @@
                 </com.google.android.material.card.MaterialCardView>
             </LinearLayout>
         </ScrollView>
+
+        <!-- GONE by default (see AudioMiniPlayerView) — only takes space while a voice note
+             started elsewhere is still playing. -->
+        <tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView
+            android:id="@+id/audio_mini_player"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content" />
     </LinearLayout>
 </layout>
diff --git a/src/main/res/layout/activity_conversations.xml b/src/main/res/layout/activity_conversations.xml
index 26ada5f..f8c4d4e 100644
--- a/src/main/res/layout/activity_conversations.xml
+++ b/src/main/res/layout/activity_conversations.xml
@@ -12,13 +12,30 @@
             android:layout_width="match_parent"
             android:layout_height="match_parent" />
 
-        <com.google.android.material.bottomnavigation.BottomNavigationView
-            android:id="@+id/bottom_nav"
+        <!-- Bottom-docked (not top) so it never overlaps whichever fragment's own toolbar/
+             search bar is currently showing in main_fragment — sits directly above bottom_nav
+             instead, staying visible across every chat/tab switch since it lives at this
+             Activity level rather than inside any one fragment. GONE by default (see
+             AudioMiniPlayerView), so it takes no space and there's no layout shift when
+             nothing is playing. -->
+        <LinearLayout
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:layout_gravity="bottom"
-            app:labelVisibilityMode="labeled"
-            app:menu="@menu/bottom_nav_main" />
+            android:orientation="vertical">
+
+            <tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView
+                android:id="@+id/audio_mini_player"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content" />
+
+            <com.google.android.material.bottomnavigation.BottomNavigationView
+                android:id="@+id/bottom_nav"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                app:labelVisibilityMode="labeled"
+                app:menu="@menu/bottom_nav_main" />
+        </LinearLayout>
 
     </FrameLayout>
 </layout>
diff --git a/src/main/res/layout/activity_settings.xml b/src/main/res/layout/activity_settings.xml
index 170d979..66dedcf 100644
--- a/src/main/res/layout/activity_settings.xml
+++ b/src/main/res/layout/activity_settings.xml
@@ -1,6 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <layout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto">
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
 
     <androidx.coordinatorlayout.widget.CoordinatorLayout
         android:layout_width="match_parent"
@@ -26,5 +27,28 @@
             android:layout_height="match_parent"
             android:fillViewport="true"
             app:layout_behavior="@string/appbar_scrolling_view_behavior" />
+
+        <!-- Settings search results, shown over fragment_container while the toolbar's search
+             action view (see menu/activity_settings.xml) is expanded and has a non-empty query.
+             Hidden (and cleared) the rest of the time. -->
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/settings_search_results"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:background="?attr/colorSurface"
+            android:scrollbars="vertical"
+            android:visibility="gone"
+            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
+            app:layout_behavior="@string/appbar_scrolling_view_behavior"
+            tools:listitem="@layout/item_settings_search_result" />
+
+        <!-- Bottom-docked so a voice note started before opening Settings keeps a visible,
+             controllable bar here too (see AudioMiniPlayerView) — GONE by default, no layout
+             shift when nothing is playing. -->
+        <tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView
+            android:id="@+id/audio_mini_player"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_gravity="bottom" />
     </androidx.coordinatorlayout.widget.CoordinatorLayout>
 </layout>
\ No newline at end of file
diff --git a/src/main/res/layout/activity_stories.xml b/src/main/res/layout/activity_stories.xml
index bfad405..17e9a3a 100644
--- a/src/main/res/layout/activity_stories.xml
+++ b/src/main/res/layout/activity_stories.xml
@@ -113,6 +113,13 @@
 
     </androidx.coordinatorlayout.widget.CoordinatorLayout>
 
+    <!-- GONE by default (see AudioMiniPlayerView) — only takes space while a voice note
+         started elsewhere is still playing. -->
+    <tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView
+        android:id="@+id/audio_mini_player"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content" />
+
     <com.google.android.material.bottomnavigation.BottomNavigationView
         android:id="@+id/bottom_nav"
         android:layout_width="match_parent"
diff --git a/src/main/res/layout/item_settings_search_result.xml b/src/main/res/layout/item_settings_search_result.xml
new file mode 100644
index 0000000..321fba5
--- /dev/null
+++ b/src/main/res/layout/item_settings_search_result.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <LinearLayout
+        android:id="@+id/wrapper"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="?selectableItemBackground"
+        android:minHeight="56dp"
+        android:orientation="vertical"
+        android:paddingHorizontal="16dp"
+        android:paddingVertical="10dp">
+
+        <TextView
+            android:id="@+id/title"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:maxLines="1"
+            android:textAppearance="?textAppearanceBodyMedium"
+            tools:text="Send read receipts" />
+
+        <TextView
+            android:id="@+id/breadcrumb"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:maxLines="1"
+            android:textAppearance="?textAppearanceCaption"
+            android:textColor="?android:attr/textColorSecondary"
+            tools:text="Privacy" />
+    </LinearLayout>
+</layout>
diff --git a/src/main/res/layout/view_audio_mini_player.xml b/src/main/res/layout/view_audio_mini_player.xml
new file mode 100644
index 0000000..1f6ad02
--- /dev/null
+++ b/src/main/res/layout/view_audio_mini_player.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Inflated by tel.xmpp.jabjab.ui.widget.AudioMiniPlayerView into itself (a LinearLayout it
+     configures programmatically) — these are its direct children, not a standalone screen. -->
+<merge xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/mini_player_play_pause"
+        style="?attr/materialIconButtonOutlinedStyle"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        app:icon="@drawable/ic_play_arrow_24dp"
+        app:iconSize="22dp" />
+
+    <LinearLayout
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="8dp"
+        android:layout_weight="1"
+        android:orientation="vertical">
+
+        <TextView
+            android:id="@+id/mini_player_title"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:ellipsize="end"
+            android:maxLines="1"
+            android:textAppearance="?textAppearanceBodyMedium"
+            tools:text="Alice" />
+
+        <SeekBar
+            android:id="@+id/mini_player_progress"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="2dp"
+            android:max="100"
+            android:paddingVertical="0dp" />
+    </LinearLayout>
+
+    <TextView
+        android:id="@+id/mini_player_time"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginHorizontal="4dp"
+        android:textAppearance="?textAppearanceCaption"
+        tools:text="0:12 / 0:45" />
+
+    <com.google.android.material.button.MaterialButton
+        android:id="@+id/mini_player_speed_toggle"
+        style="?attr/materialIconButtonOutlinedStyle"
+        android:layout_width="36dp"
+        android:layout_height="36dp"
+        android:layout_marginEnd="4dp"
+        android:gravity="center"
+        android:insetTop="0dp"
+        android:insetBottom="0dp"
+        android:insetLeft="0dp"
+        android:insetRight="0dp"
+        android:paddingHorizontal="0dp"
+        android:text="1×"
+        android:textSize="11sp"
+        app:iconPadding="0dp" />
+
+    <ImageButton
+        android:id="@+id/mini_player_close"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:background="?attr/selectableItemBackgroundBorderless"
+        android:contentDescription="@string/dismiss"
+        android:padding="8dp"
+        android:src="@drawable/ic_close_24dp" />
+
+</merge>
diff --git a/src/main/res/menu/activity_settings.xml b/src/main/res/menu/activity_settings.xml
new file mode 100644
index 0000000..9e39796
--- /dev/null
+++ b/src/main/res/menu/activity_settings.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+      xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item
+        android:id="@+id/action_search"
+        app:actionLayout="@layout/actionview_settings_search"
+        android:icon="@drawable/ic_search_24dp"
+        app:showAsAction="collapseActionView|always"
+        android:title="@string/search_settings"/>
+</menu>
diff --git a/src/main/res/menu/message_context.xml b/src/main/res/menu/message_context.xml
index 48559eb..8611f0d 100644
--- a/src/main/res/menu/message_context.xml
+++ b/src/main/res/menu/message_context.xml
@@ -86,6 +86,11 @@
         android:icon="@drawable/ic_lock_open_outline_24dp"
         android:title="@string/retry_decryption"
         android:visible="false" />
+    <item
+        android:id="@+id/view_edit_history"
+        android:icon="@drawable/ic_schedule_24dp"
+        android:title="@string/view_edit_history"
+        android:visible="false" />
     <item
         android:id="@+id/correct_message"
         android:icon="@drawable/ic_edit_24dp"
diff --git a/src/main/res/values-ar/strings.xml b/src/main/res/values-ar/strings.xml
index 1aac25a..ce47851 100644
--- a/src/main/res/values-ar/strings.xml
+++ b/src/main/res/values-ar/strings.xml
@@ -1602,6 +1602,11 @@
     <string name="translation_backend_offline_device">على الجهاز (تطبيق offline-translator)</string>
     <string name="translation_backend_google">ترجمة Google (عامة)</string>
     <string name="translation_feature_banner_text">تمت إضافة ميزة ترجمة جديدة. اضغط هنا لمعرفة المزيد وكيفية إعدادها.</string>
+    <string name="animated_banner_feature_banner_text">يمكن الآن جعل شعارات الملف الشخصي متحركة (GIF/WebP). اضغط هنا لإعداد واحد.</string>
+    <string name="reaction_notifications_feature_banner_text">أصبحت الآن تتلقى إشعارًا عند تفاعل أحدهم مع رسائلك. اضغط هنا لإدارة ذلك.</string>
+    <string name="message_correction_feature_banner_text">أصبح تعديل الرسائل أكثر قوة. يمكنك تصحيح أي رسالة (وليس الأخيرة فقط)، ثم الضغط مطولاً عليها لعرض سجل التعديلات.</string>
+    <string name="settings_search_feature_banner_text">أصبح للإعدادات الآن شريط بحث — ابحث عن أي خيار بالاسم، حتى بلغة مختلفة. اضغط هنا لتجربته.</string>
+    <string name="audio_mini_player_feature_banner_text">تستمر الرسائل الصوتية في التشغيل الآن عند التنقل بين المحادثات أو الشاشات، مع شريط تشغيل صغير للتحكم بها.</string>
     <string name="dismiss">إغلاق</string>
     <string name="pref_offline_translator_info_title">احصل على الترجمة على الجهاز</string>
     <string name="pref_offline_translator_info_summary">ثبّت تطبيق offline-translator من F-Droid أو GitHub لترجمة الرسائل دون إرسالها إلى أي مكان. اضغط لتنزيل أحدث إصدار APK مباشرة.</string>
@@ -1609,4 +1614,80 @@
     <string name="offline_translator_apk_not_found">تعذر العثور على ملف APK في أحدث إصدار — سيتم فتح صفحة الإصدارات بدلاً من ذلك</string>
     <string name="pref_offline_translator_installed_title">تم تثبيت المترجم على الجهاز</string>
     <string name="pref_offline_translator_installed_summary">تطبيق offline-translator مثبّت — يمكن ترجمة الرسائل بالكامل على هذا الجهاز.</string>
+    <string name="view_edit_history">عرض سجل التعديلات</string>
+    <string name="edit_history">سجل التعديلات</string>
+    <string name="no_lan_permission">امنح %1$s إمكانية الوصول إلى الشبكة المحلية (الأجهزة القريبة).</string>
+    <string name="missing_local_network_permission">إذن الشبكة المحلية مطلوب</string>
+    <string name="grant_permission">منح الإذن</string>
+    <string name="open_settings">فتح الإعدادات</string>
+    <string name="reaction_notification_text">تفاعل بـ %1$s مع \"%2$s\"</string>
+    <string name="pref_notify_on_reaction">إشعارات التفاعلات</string>
+    <string name="pref_notify_on_reaction_summary">الإشعار عند تفاعل أحدهم مع إحدى رسائلك.</string>
+    <string name="action_delete_banner">إزالة الشعار</string>
+    <string name="delete_banner">إزالة الشعار</string>
+    <string name="delete_banner_message">سيؤدي هذا إلى إزالة شعار ملفك الشخصي لجميع من يمكنهم رؤيته.</string>
+    <string name="banner_has_been_published">تم نشر الشعار</string>
+    <string name="error_banner_too_large">الصورة كبيرة جدًا (الحد الأقصى 5 ميغابايت)</string>
+    <string name="error_publish_banner_converting">تعذّرت معالجة هذه الصورة</string>
+    <string name="publish_banner_title">شعار الملف الشخصي</string>
+    <string name="tap_to_choose_banner">اضغط لاختيار شعار الملف الشخصي</string>
+    <string name="attach_sticker_failed">تعذّر إرسال الملصق</string>
+    <string name="backup_history_all_messages">جميع الرسائل</string>
+    <string name="command_cancelled">تم إلغاء الأمر</string>
+    <string name="command_completed">تم إكمال الأمر</string>
+    <string name="command_result">النتيجة</string>
+    <string name="commands_empty">لا توجد أوامر متاحة</string>
+    <string name="commands_load_failed">تعذّر تحميل الأوامر</string>
+    <string name="commands_loading">جارٍ تحميل الأوامر…</string>
+    <string name="comma_separated_values">قيم مفصولة بفواصل</string>
+    <string name="contact_is_verified">موثّق</string>
+    <string name="contact_is_verified_toast">هذا حساب موثّق</string>
+    <string name="contact_uses_jabjab">يستخدم JabJab</string>
+    <string name="contact_uses_jabjab_toast">جهة اتصالك تستخدم JabJab</string>
+    <string name="crash_report_account">الإرسال من: %1$s</string>
+    <string name="field_required">مطلوب</string>
+    <string name="import_sticker_pack">استيراد حزمة ملصقات</string>
+    <string name="jump_back">العودة</string>
+    <string name="jump_to_reply">الانتقال إلى الرد</string>
+    <string name="no_backup_files_found_hint">لم يتم العثور على ملفات نسخ احتياطي هنا.\nاضغط على أيقونة السحابة في الزاوية العلوية للبحث عن ملف نسخ احتياطي لاستعادته.</string>
+    <string name="no_pinned_messages">لا توجد رسائل مثبتة</string>
+    <string name="no_reply_found">لم يتم العثور على رد على هذه الرسالة</string>
+    <string name="open_sticker_pack">فتح حزمة الملصقات</string>
+    <string name="pin_message">تثبيت الرسالة</string>
+    <string name="pinned_message">رسالة مثبتة</string>
+    <string name="pinned_messages">الرسائل المثبتة</string>
+    <string name="pinned_messages_counter">%1$d/%2$d</string>
+    <string name="pinned_messages_next">عرض الرسالة المثبتة التالية</string>
+    <string name="pinned_messages_previous">عرض الرسالة المثبتة السابقة</string>
+    <string name="pref_backup_message_history">سجل الرسائل المراد تضمينه</string>
+    <string name="pref_color_theme_strawberry">فراولة</string>
+    <string name="pref_message_menu_haptic_feedback">الاستجابة اللمسية</string>
+    <string name="pref_message_menu_haptic_feedback_summary">الاهتزاز عند فتح قائمة الرسائل أو اختيار رد فعل سريع</string>
+    <string name="profile_field_status">الحالة</string>
+    <string name="profile_saved_offline">تم الحفظ — سيتم النشر عند العودة إلى الاتصال</string>
+    <string name="send_pack_link">إرسال إلى جهات الاتصال</string>
+    <string name="share_to_story_label">قصص JabJab</string>
+    <string name="status">الحالة</string>
+    <string name="sticker_pack_up_to_date">حزمة الملصقات محدّثة بالفعل</string>
+    <string name="tab_chat">الدردشة</string>
+    <string name="tab_commands">الأوامر</string>
+    <string name="tap_to_install_sticker_pack">اضغط لتثبيت حزمة الملصقات</string>
+    <string name="tap_to_open_sticker_pack">اضغط لفتح حزمة الملصقات</string>
+    <string name="telegram_import_needs_token">يتطلب استيراد حزم ملصقات تيليجرام رمز بوت. أضف واحدًا في الإعدادات، ثم اضغط على الرابط مرة أخرى.</string>
+    <string name="translation_already_system_language">هذه الرسالة بلغتك بالفعل — اختر لغة هدف</string>
+    <string name="translation_disclaimer_offline_device">عبر الترجمة على الجهاز</string>
+    <string name="unpin_message">إلغاء تثبيت الرسالة</string>
+    <string name="update_download_complete">تم تنزيل التحديث</string>
+    <string name="update_download_failed_text">اضغط لإعادة المحاولة</string>
+    <string name="update_download_failed_title">فشل تنزيل التحديث</string>
+    <string name="update_needs_install_permission">اضغط للسماح بتثبيت التحديثات</string>
+    <string name="update_ready_to_install">اضغط للتثبيت</string>
+    <plurals name="sticker_pack_updated">
+        <item quantity="zero">لم تتم إضافة أي ملصقات جديدة</item>
+        <item quantity="one">تمت إضافة ملصق جديد واحد</item>
+        <item quantity="two">تمت إضافة ملصقين جديدين</item>
+        <item quantity="few">تمت إضافة %d ملصقات جديدة</item>
+        <item quantity="many">تمت إضافة %d ملصقًا جديدًا</item>
+        <item quantity="other">تمت إضافة %d ملصق جديد</item>
+    </plurals>
 </resources>
diff --git a/src/main/res/values-bg/strings.xml b/src/main/res/values-bg/strings.xml
index 8549d15..7258043 100644
--- a/src/main/res/values-bg/strings.xml
+++ b/src/main/res/values-bg/strings.xml
@@ -906,4 +906,26 @@
     <string name="tab_joined_channels">Присъединени</string>
     <string name="tab_discover_channels">Открий</string>
     <string name="no_joined_channels_hint">Няма канали или групови чатове.\nИзползвайте раздела Открий, за да намерите и се присъедините към публични канали.</string>
+    <string name="view_edit_history">Преглед на историята на промените</string>
+    <string name="edit_history">История на промените</string>
+    <string name="message_correction_feature_banner_text">Редактирането на съобщения стана още по-мощно. Коригирайте всяко съобщение (не само последното), след което го задръжте, за да видите историята му на промени.</string>
+    <string name="settings_search_feature_banner_text">Настройките вече имат лента за търсене — намерете всяка опция по име, дори на друг език. Докоснете тук, за да опитате.</string>
+    <string name="audio_mini_player_feature_banner_text">Гласовите съобщения вече продължават да се възпроизвеждат, когато превключвате чатове или екрани, с малка лента за управление на възпроизвеждането.</string>
+    <string name="no_lan_permission">Предоставете на %1$s достъп до локалната мрежа (близки устройства).</string>
+    <string name="missing_local_network_permission">Изисква се разрешение за локална мрежа</string>
+    <string name="grant_permission">Предостави разрешение</string>
+    <string name="open_settings">Отвори настройки</string>
+    <string name="reaction_notification_text">реагира с %1$s на \"%2$s\"</string>
+    <string name="pref_notify_on_reaction">Известия за реакции</string>
+    <string name="pref_notify_on_reaction_summary">Известявай, когато някой реагира на едно от вашите съобщения.</string>
+    <string name="reaction_notifications_feature_banner_text">Вече получавате известие, когато някой реагира на вашите съобщения. Докоснете тук, за да управлявате това.</string>
+    <string name="animated_banner_feature_banner_text">Банерите на профила вече могат да бъдат анимирани (GIF/WebP). Докоснете тук, за да настроите.</string>
+    <string name="action_delete_banner">Премахни банера</string>
+    <string name="delete_banner">Премахни банера</string>
+    <string name="delete_banner_message">Това премахва вашия банер на профила за всички, които могат да го виждат.</string>
+    <string name="banner_has_been_published">Банерът е публикуван</string>
+    <string name="error_banner_too_large">Изображението е твърде голямо (макс. 5 MB)</string>
+    <string name="error_publish_banner_converting">Това изображение не можа да бъде обработено</string>
+    <string name="publish_banner_title">Банер на профила</string>
+    <string name="tap_to_choose_banner">Докоснете, за да изберете банер на профила</string>
 </resources>
diff --git a/src/main/res/values-bn-rIN/strings.xml b/src/main/res/values-bn-rIN/strings.xml
index 92a44be..428de17 100644
--- a/src/main/res/values-bn-rIN/strings.xml
+++ b/src/main/res/values-bn-rIN/strings.xml
@@ -121,4 +121,26 @@
     <string name="tab_joined_channels">যোগ দেওয়া হয়েছে</string>
     <string name="tab_discover_channels">আবিষ্কার করুন</string>
     <string name="no_joined_channels_hint">এখনও কোনো চ্যানেল বা গ্রুপ চ্যাট নেই।\nসর্বজনীন চ্যানেল খুঁজতে এবং যোগ দিতে আবিষ্কার ট্যাব ব্যবহার করুন।</string>
+    <string name="view_edit_history">সম্পাদনার ইতিহাস দেখুন</string>
+    <string name="edit_history">সম্পাদনার ইতিহাস</string>
+    <string name="message_correction_feature_banner_text">বার্তা সম্পাদনা এখন আরও শক্তিশালী হয়ে উঠেছে। যেকোনো বার্তা সংশোধন করুন (শুধু শেষটি নয়), তারপর এর সম্পাদনার ইতিহাস দেখতে দীর্ঘক্ষণ চাপুন।</string>
+    <string name="settings_search_feature_banner_text">এখন সেটিংসে একটি সার্চ বার আছে — যেকোনো অপশন নাম দিয়ে খুঁজুন, এমনকি ভিন্ন ভাষায়ও। এটি চেষ্টা করতে এখানে ট্যাপ করুন।</string>
+    <string name="audio_mini_player_feature_banner_text">আপনি চ্যাট বা স্ক্রিন পরিবর্তন করলেও এখন ভয়েস মেসেজ চলতেই থাকে, নিয়ন্ত্রণের জন্য একটি ছোট প্লেয়ার বার সহ।</string>
+    <string name="no_lan_permission">%1$s-কে স্থানীয় নেটওয়ার্কে (কাছাকাছি ডিভাইস) অ্যাক্সেস দিন।</string>
+    <string name="missing_local_network_permission">স্থানীয় নেটওয়ার্ক অনুমতি প্রয়োজন</string>
+    <string name="grant_permission">অনুমতি দিন</string>
+    <string name="open_settings">সেটিংস খুলুন</string>
+    <string name="reaction_notification_text">\"%2$s\"-এ %1$s প্রতিক্রিয়া জানিয়েছে</string>
+    <string name="pref_notify_on_reaction">প্রতিক্রিয়া বিজ্ঞপ্তি</string>
+    <string name="pref_notify_on_reaction_summary">কেউ আপনার কোনো বার্তায় প্রতিক্রিয়া জানালে জানান।</string>
+    <string name="reaction_notifications_feature_banner_text">এখন কেউ আপনার বার্তায় প্রতিক্রিয়া জানালে আপনি বিজ্ঞপ্তি পাবেন। এটি পরিচালনা করতে এখানে ট্যাপ করুন।</string>
+    <string name="animated_banner_feature_banner_text">প্রোফাইল ব্যানার এখন অ্যানিমেটেড হতে পারে (GIF/WebP)। একটি সেট করতে এখানে ট্যাপ করুন।</string>
+    <string name="action_delete_banner">ব্যানার সরান</string>
+    <string name="delete_banner">ব্যানার সরান</string>
+    <string name="delete_banner_message">এটি করলে যারা এটি দেখতে পান তাদের সবার জন্য আপনার প্রোফাইল ব্যানার সরিয়ে দেওয়া হবে।</string>
+    <string name="banner_has_been_published">ব্যানার প্রকাশিত হয়েছে</string>
+    <string name="error_banner_too_large">ছবিটি খুব বড় (সর্বোচ্চ ৫ এমবি)</string>
+    <string name="error_publish_banner_converting">এই ছবিটি প্রক্রিয়া করা যায়নি</string>
+    <string name="publish_banner_title">প্রোফাইল ব্যানার</string>
+    <string name="tap_to_choose_banner">প্রোফাইল ব্যানার বেছে নিতে ট্যাপ করুন</string>
 </resources>
diff --git a/src/main/res/values-ca/strings.xml b/src/main/res/values-ca/strings.xml
index 28f1d8a..02272bb 100644
--- a/src/main/res/values-ca/strings.xml
+++ b/src/main/res/values-ca/strings.xml
@@ -1115,4 +1115,26 @@
     <string name="tab_joined_channels">Units</string>
     <string name="tab_discover_channels">Descobreix</string>
     <string name="no_joined_channels_hint">Encara no hi ha canals ni xats de grup.\nFes servir la pestanya Descobreix per trobar i unir-te a canals públics.</string>
+    <string name="view_edit_history">Veure historial d\'edicions</string>
+    <string name="edit_history">Historial d\'edicions</string>
+    <string name="message_correction_feature_banner_text">Editar missatges ara és més potent. Corregeix qualsevol missatge (no només l\'últim) i després manté\'l premut per veure el seu historial d\'edicions.</string>
+    <string name="settings_search_feature_banner_text">Configuració ara té una barra de cerca — troba qualsevol opció pel nom, fins i tot en un altre idioma. Toca aquí per provar-ho.</string>
+    <string name="audio_mini_player_feature_banner_text">Els missatges de veu ara continuen reproduint-se quan canvies de xat o de pantalla, amb una petita barra de reproductor per controlar-los.</string>
+    <string name="no_lan_permission">Concedeix a %1$s accés a la xarxa local (dispositius propers).</string>
+    <string name="missing_local_network_permission">Cal permís de xarxa local</string>
+    <string name="grant_permission">Concedeix el permís</string>
+    <string name="open_settings">Obre la configuració</string>
+    <string name="reaction_notification_text">ha reaccionat amb %1$s a \"%2$s\"</string>
+    <string name="pref_notify_on_reaction">Notificacions de reaccions</string>
+    <string name="pref_notify_on_reaction_summary">Notifica quan algú reaccioni a un dels teus missatges.</string>
+    <string name="reaction_notifications_feature_banner_text">Ara reps una notificació quan algú reacciona als teus missatges. Toca aquí per gestionar-ho.</string>
+    <string name="animated_banner_feature_banner_text">Els bàners de perfil ara poden ser animats (GIF/WebP). Toca aquí per configurar-ne un.</string>
+    <string name="action_delete_banner">Elimina el bàner</string>
+    <string name="delete_banner">Elimina el bàner</string>
+    <string name="delete_banner_message">Això elimina el teu bàner de perfil per a tothom qui el pugui veure.</string>
+    <string name="banner_has_been_published">Bàner publicat</string>
+    <string name="error_banner_too_large">La imatge és massa gran (màx. 5 MB)</string>
+    <string name="error_publish_banner_converting">No s\'ha pogut processar aquesta imatge</string>
+    <string name="publish_banner_title">Bàner de perfil</string>
+    <string name="tap_to_choose_banner">Toca per triar un bàner de perfil</string>
 </resources>
diff --git a/src/main/res/values-cs/strings.xml b/src/main/res/values-cs/strings.xml
index a75b2fe..e538b8d 100644
--- a/src/main/res/values-cs/strings.xml
+++ b/src/main/res/values-cs/strings.xml
@@ -1464,4 +1464,192 @@
         <item quantity="many">Jste si jisti, že chcete smazat tyto soubory?</item>
         <item quantity="other">Jste si jisti, že chcete smazat tyto soubory?</item>
     </plurals>
+    <string name="view_edit_history">Zobrazit historii úprav</string>
+    <string name="edit_history">Historie úprav</string>
+    <string name="message_correction_feature_banner_text">Úprava zpráv je teď mnohem silnější. Opravte libovolnou zprávu (ne jen tu poslední) a poté ji podržte, abyste zobrazili historii úprav.</string>
+    <string name="settings_search_feature_banner_text">Nastavení má nyní vyhledávací lištu — najděte jakoukoli možnost podle názvu, i v jiném jazyce. Klepnutím sem to vyzkoušíte.</string>
+    <string name="audio_mini_player_feature_banner_text">Hlasové zprávy teď hrají dál i při přepnutí chatu nebo obrazovky, s malým panelem přehrávače pro jejich ovládání.</string>
+    <string name="no_lan_permission">Udělte aplikaci %1$s přístup k místní síti (zařízením v okolí).</string>
+    <string name="missing_local_network_permission">Je vyžadováno oprávnění k místní síti</string>
+    <string name="grant_permission">Udělit oprávnění</string>
+    <string name="open_settings">Otevřít nastavení</string>
+    <string name="reaction_notification_text">reagoval(a) %1$s na \"%2$s\"</string>
+    <string name="pref_notify_on_reaction">Oznámení o reakcích</string>
+    <string name="pref_notify_on_reaction_summary">Upozornit, když někdo zareaguje na jednu z vašich zpráv.</string>
+    <string name="reaction_notifications_feature_banner_text">Nyní dostáváte upozornění, když někdo zareaguje na vaše zprávy. Klepnutím sem to spravujete.</string>
+    <string name="animated_banner_feature_banner_text">Profilové bannery mohou být nyní animované (GIF/WebP). Klepnutím sem jej nastavíte.</string>
+    <string name="action_delete_banner">Odstranit banner</string>
+    <string name="delete_banner">Odstranit banner</string>
+    <string name="delete_banner_message">Tímto se odstraní váš profilový banner pro všechny, kdo jej mohou vidět.</string>
+    <string name="banner_has_been_published">Banner zveřejněn</string>
+    <string name="error_banner_too_large">Obrázek je příliš velký (max. 5 MB)</string>
+    <string name="error_publish_banner_converting">Tento obrázek se nepodařilo zpracovat</string>
+    <string name="publish_banner_title">Profilový banner</string>
+    <string name="tap_to_choose_banner">Klepnutím vyberte profilový banner</string>
+    <string name="attach_sticker_failed">Odeslání nálepky se nezdařilo</string>
+    <string name="backup_history_all_messages">Všechny zprávy</string>
+    <string name="command_cancelled">Příkaz zrušen</string>
+    <string name="command_completed">Příkaz dokončen</string>
+    <string name="command_result">Výsledek</string>
+    <string name="commands_empty">Žádné dostupné příkazy</string>
+    <string name="commands_load_failed">Příkazy se nepodařilo načíst</string>
+    <string name="commands_loading">Načítání příkazů…</string>
+    <string name="comma_separated_values">Hodnoty oddělené čárkou</string>
+    <string name="contact_is_verified">Ověřeno</string>
+    <string name="contact_is_verified_toast">Toto je ověřený účet</string>
+    <string name="contact_uses_jabjab">Používá JabJab</string>
+    <string name="contact_uses_jabjab_toast">Váš kontakt používá JabJab</string>
+    <string name="crash_report_account">Odesláno z: %1$s</string>
+    <string name="field_required">Povinné</string>
+    <string name="import_sticker_pack">Importovat balíček nálepek</string>
+    <string name="jump_back">Skočit zpět</string>
+    <string name="jump_to_reply">Přejít na odpověď</string>
+    <string name="no_backup_files_found_hint">Zde nebyly nalezeny žádné soubory zálohy.\nKlepnutím na ikonu cloudu nahoře vyhledejte soubor zálohy k obnovení.</string>
+    <string name="no_pinned_messages">Žádné připnuté zprávy</string>
+    <string name="no_reply_found">Odpověď na tuto zprávu nebyla nalezena</string>
+    <string name="open_sticker_pack">Otevřít balíček nálepek</string>
+    <string name="pin_message">Připnout zprávu</string>
+    <string name="pinned_message">Připnutá zpráva</string>
+    <string name="pinned_messages">Připnuté zprávy</string>
+    <string name="pinned_messages_counter">%1$d/%2$d</string>
+    <string name="pinned_messages_next">Zobrazit další připnutou zprávu</string>
+    <string name="pinned_messages_previous">Zobrazit předchozí připnutou zprávu</string>
+    <string name="pref_backup_message_history">Historie zpráv k zahrnutí</string>
+    <string name="pref_color_theme_strawberry">Jahoda</string>
+    <string name="pref_message_menu_haptic_feedback">Haptická odezva</string>
+    <string name="pref_message_menu_haptic_feedback_summary">Vibrovat při otevření nabídky zprávy nebo výběru rychlé reakce</string>
+    <string name="profile_field_status">Stav</string>
+    <string name="profile_saved_offline">Uloženo — zveřejní se po opětovném připojení</string>
+    <string name="send_pack_link">Odeslat kontaktům</string>
+    <string name="share_to_story_label">JabJab Stories</string>
+    <string name="status">Stav</string>
+    <string name="sticker_pack_up_to_date">Balíček nálepek je již aktuální</string>
+    <string name="tab_chat">Chat</string>
+    <string name="tab_commands">Příkazy</string>
+    <string name="tap_to_install_sticker_pack">Klepnutím nainstalujte balíček nálepek</string>
+    <string name="tap_to_open_sticker_pack">Klepnutím otevřete balíček nálepek</string>
+    <string name="telegram_import_needs_token">Import balíčků nálepek z Telegramu vyžaduje token bota. Přidejte jej v Nastavení a poté znovu klepněte na odkaz.</string>
+    <string name="translation_already_system_language">Tato zpráva je již ve vašem jazyce — vyberte cílový jazyk</string>
+    <string name="translation_disclaimer_offline_device">pomocí překladu v zařízení</string>
+    <string name="unpin_message">Odepnout zprávu</string>
+    <string name="update_download_complete">Aktualizace stažena</string>
+    <string name="update_download_failed_text">Klepnutím zkusíte znovu</string>
+    <string name="update_download_failed_title">Stahování aktualizace se nezdařilo</string>
+    <string name="update_needs_install_permission">Klepnutím povolíte instalaci aktualizací</string>
+    <string name="update_ready_to_install">Klepnutím nainstalujte</string>
+    <plurals name="sticker_pack_updated">
+        <item quantity="one">Přidána %d nová nálepka</item>
+        <item quantity="few">Přidány %d nové nálepky</item>
+        <item quantity="many">Přidáno %d nové nálepky</item>
+        <item quantity="other">Přidáno %d nových nálepek</item>
+    </plurals>
+    <string name="action_set_ephemeral_timer">Mizející zprávy</string>
+    <string name="blog">Blog</string>
+    <string name="days_ago">před %d dny</string>
+    <string name="debug_blogs_deleted">Všechny příspěvky blogu odstraněny</string>
+    <string name="debug_category_info">Diagnostika</string>
+    <string name="debug_category_pubsub">Data PubSub</string>
+    <string name="debug_copied">Zkopírováno do schránky</string>
+    <string name="debug_copy_build_info_summary">Zkopírovat verzi, zařízení a podrobnosti sestavení do schránky</string>
+    <string name="debug_copy_build_info_title">Kopírovat informace o sestavení</string>
+    <string name="debug_delete_blogs_confirm">Tímto se trvale odstraní VŠECHNY vaše příspěvky na blogu ze serveru. Pokračovat?</string>
+    <string name="debug_delete_blogs_summary">Stáhnout všechny záznamy blogu z vašeho uzlu PubSub na všech účtech</string>
+    <string name="debug_delete_blogs_title">Odstranit všechny mé příspěvky na blogu</string>
+    <string name="debug_delete_stories_confirm">Tímto se trvale odstraní VŠECHNY vaše příspěvky stories ze serveru. Ostatní uživatelé je již neuvidí. Pokračovat?</string>
+    <string name="debug_delete_stories_summary">Stáhnout všechny položky stories z vašeho uzlu PubSub na všech účtech</string>
+    <string name="debug_delete_stories_title">Odstranit všechny mé stories</string>
+    <string name="debug_fetch_failed">Nepodařilo se načíst položky ze serveru</string>
+    <string name="debug_log_accounts_summary">Zaznamenat stav připojení účtu do logcat (tag: jabjab)</string>
+    <string name="debug_log_accounts_title">Zaznamenat stav účtu</string>
+    <string name="debug_menu_summary">Nebezpečné nástroje pro reset dat</string>
+    <string name="debug_menu_title">Možnosti pro vývojáře</string>
+    <string name="debug_no_accounts">Žádné dostupné účty</string>
+    <string name="debug_nothing_to_delete">Není co mazat</string>
+    <string name="debug_refetch_summary">Vynutit nové načtení stories od všech kontaktů (otevřete obrazovku Stories pro spuštění)</string>
+    <string name="debug_refetch_title">Znovu načíst stories</string>
+    <string name="debug_stories_deleted">Všechny stories odstraněny</string>
+    <string name="debug_working">Zpracovává se…</string>
+    <string name="ephemeral_timer_15m">15 minut</string>
+    <string name="ephemeral_timer_1d">1 den</string>
+    <string name="ephemeral_timer_1h">1 hodina</string>
+    <string name="ephemeral_timer_1m">1 minuta</string>
+    <string name="ephemeral_timer_1w">1 týden</string>
+    <string name="ephemeral_timer_30m">30 minut</string>
+    <string name="ephemeral_timer_30s">30 sekund</string>
+    <string name="ephemeral_timer_3h">3 hodiny</string>
+    <string name="ephemeral_timer_5m">5 minut</string>
+    <string name="ephemeral_timer_current">Aktuálně: %s</string>
+    <string name="ephemeral_timer_disabled">Mizející zprávy vypnuty</string>
+    <string name="ephemeral_timer_off">Vypnuto</string>
+    <string name="ephemeral_timer_set">Mizející zprávy nastaveny na %s</string>
+    <string name="forward_to_contacts">Přeposlat více kontaktům…</string>
+    <string name="hours_ago">před %d hodinami</string>
+    <string name="messages_selected">vybráno</string>
+    <string name="multi_share_done">Odesláno %1$d kontaktům</string>
+    <string name="multi_share_no_contacts">Nenalezeny žádné kontakty</string>
+    <string name="multi_share_pick_account">Odeslat z účtu</string>
+    <string name="multi_share_search_hint">Hledat kontakty…</string>
+    <string name="multi_share_self">Já (%s)</string>
+    <string name="multi_share_send">Odeslat</string>
+    <string name="multi_share_sending">Odesílání %1$d kontaktům…</string>
+    <string name="multi_share_send_to">Odeslat %1$d kontaktům</string>
+    <string name="multi_share_title">Sdílet s kontakty</string>
+    <string name="notification_ephemeral_message">🔥 Mizející zpráva</string>
+    <string name="notification_new_message">Nová zpráva</string>
+    <string name="pref_category_encryption_options">Možnosti šifrování</string>
+    <string name="pref_category_notifications">Oznámení</string>
+    <string name="pref_hide_notification_content">Skrýt obsah zprávy v oznámeních</string>
+    <string name="pref_hide_notification_content_summary">Zobrazit \"Nová zpráva\" místo skutečného textu. Automaticky zapnuto při aktivním zámku aplikace.</string>
+    <string name="pref_hide_openpgp">Skrýt možnost OpenPGP</string>
+    <string name="pref_hide_openpgp_summary">Odebere možnost šifrování OpenPGP z konverzací. Většina uživatelů potřebuje pouze OMEMO.</string>
+    <string name="restore_minimize_hint">Obnovuje se vaše záloha. U velkých účtů to může trvat několik minut. Tuto obrazovku můžete minimalizovat a sledovat panel oznámení. JabJab vás upozorní, až bude hotovo.</string>
+    <string name="retract_message">Stáhnout</string>
+    <string name="see_all_posts">Zobrazit všechny příspěvky</string>
+    <string name="select_messages">Vybrat zprávy</string>
+    <string name="stories">Stories</string>
+    <string name="story_phase_compressing">Komprimace videa…</string>
+    <string name="story_phase_preparing">Příprava…</string>
+    <string name="story_phase_publishing">Zveřejňování…</string>
+    <string name="story_phase_trimming">Ořezávání videa…</string>
+    <string name="story_phase_uploading">Nahrávání…</string>
+    <string name="story_phase_uploading_video">Nahrávání videa</string>
+    <string name="story_video_will_compress">Video má %1$s — před nahráním bude komprimováno</string>
+    <string name="update_available_with_code">K dispozici je aktualizace %1$s (%2$d)</string>
+    <string name="update_cancel">Zrušit</string>
+    <string name="update_downloading_progress">Stahování… %d %%</string>
+    <string name="wizard_privacy_disappearing_desc">Nastavte časovač pro libovolnou konverzaci, aby se zprávy po přečtení automaticky mazaly. Zapnete pomocí ikony hodin v panelu chatu.</string>
+    <string name="wizard_privacy_disappearing_title">Mizející zprávy</string>
+    <string name="wizard_privacy_retraction_desc">Dlouhým stiskem libovolné odeslané zprávy ji stáhnete. Zpráva bude odstraněna z obou stran konverzace.</string>
+    <string name="wizard_privacy_retraction_title">Stažení zpráv</string>
+    <string name="wizard_privacy_translation_desc">Dlouze stiskněte libovolnou přijatou zprávu a klepněte na Přeložit, abyste si ji přečetli ve svém jazyce. Překlady se odesílají do Google Translate — text zprávy opustí vaše zařízení.</string>
+    <string name="wizard_privacy_translation_title">Překlad zpráv</string>
+    <string name="wizard_security_omemo_summary">Nové konverzace budou začínat se zapnutým šifrováním OMEMO. Stále jej můžete vypnout pro jednotlivé chaty.</string>
+    <string name="wizard_security_subtitle">Nastavte zámek aplikace a výchozí možnosti šifrování.</string>
+    <string name="wizard_security_title">Zabezpečení</string>
+    <string name="dismiss">Zavřít</string>
+    <string name="offline_translator_apk_not_found">V nejnovější verzi se nepodařilo najít APK — místo toho se otevře stránka verzí</string>
+    <string name="offline_translator_fetching">Získává se nejnovější verze…</string>
+    <string name="pref_category_translation">Překlad</string>
+    <string name="pref_offline_translator_info_summary">Nainstalujte aplikaci offline-translator z F-Droid nebo GitHubu a překládejte zprávy, aniž byste je kamkoli odesílali. Klepnutím stáhnete nejnovější verzi APK přímo.</string>
+    <string name="pref_offline_translator_info_title">Získat překlad v zařízení</string>
+    <string name="pref_offline_translator_installed_summary">Aplikace offline-translator je nainstalována — zprávy lze překládat zcela na tomto zařízení.</string>
+    <string name="pref_offline_translator_installed_title">Překladač v zařízení nainstalován</string>
+    <string name="pref_translation_backend_priority_summary">Pořadí, ve kterém se zkoušejí překladatelské služby</string>
+    <string name="pref_translation_backend_priority_title">Priorita služby</string>
+    <string name="translation_backend_google">Google Translate (veřejný)</string>
+    <string name="translation_backend_offline_device">V zařízení (aplikace offline-translator)</string>
+    <string name="translation_backend_private">Soukromý (Qwen3.6)</string>
+    <string name="translation_feature_banner_text">Byla přidána nová funkce překladu v zařízení. Klepnutím sem zjistíte více a jak ji nastavit.</string>
+    <plurals name="stickers_count">
+        <item quantity="one">1 nálepka</item>
+        <item quantity="few">%d nálepky</item>
+        <item quantity="many">%d nálepky</item>
+        <item quantity="other">%d nálepek</item>
+    </plurals>
+    <plurals name="stickers_imported">
+        <item quantity="one">Importována 1 nálepka</item>
+        <item quantity="few">Importovány %d nálepky</item>
+        <item quantity="many">Importováno %d nálepky</item>
+        <item quantity="other">Importováno %d nálepek</item>
+    </plurals>
 </resources>
diff --git a/src/main/res/values-da-rDK/strings.xml b/src/main/res/values-da-rDK/strings.xml
index 4a28d8a..2ea3775 100644
--- a/src/main/res/values-da-rDK/strings.xml
+++ b/src/main/res/values-da-rDK/strings.xml
@@ -1421,4 +1421,26 @@
     <string name="ephemeral_upload_always_allow">Send altid uden at spørge</string>
     <string name="story_caption_hint">Tilføj en billedtekst…</string>
     <string name="story_post_button">Udgiv</string>
+    <string name="view_edit_history">Vis redigeringshistorik</string>
+    <string name="edit_history">Redigeringshistorik</string>
+    <string name="message_correction_feature_banner_text">Redigering af beskeder er nu blevet endnu mere kraftfuld. Ret enhver besked (ikke kun den seneste), og hold den derefter nede for at se dens redigeringshistorik.</string>
+    <string name="settings_search_feature_banner_text">Indstillinger har nu en søgelinje — find enhver indstilling ved navn, selv på et andet sprog. Tryk her for at prøve det.</string>
+    <string name="audio_mini_player_feature_banner_text">Talebeskeder fortsætter nu med at afspille, når du skifter chat eller skærm, med en lille afspiller-bjælke til at styre dem.</string>
+    <string name="no_lan_permission">Giv %1$s adgang til det lokale netværk (enheder i nærheden).</string>
+    <string name="missing_local_network_permission">Tilladelse til lokalt netværk kræves</string>
+    <string name="grant_permission">Giv tilladelse</string>
+    <string name="open_settings">Åbn indstillinger</string>
+    <string name="reaction_notification_text">reagerede med %1$s på \"%2$s\"</string>
+    <string name="pref_notify_on_reaction">Reaktionsnotifikationer</string>
+    <string name="pref_notify_on_reaction_summary">Giv besked, når nogen reagerer på en af dine beskeder.</string>
+    <string name="reaction_notifications_feature_banner_text">Du får nu besked, når nogen reagerer på dine beskeder. Tryk her for at administrere dette.</string>
+    <string name="animated_banner_feature_banner_text">Profilbannere kan nu være animerede (GIF/WebP). Tryk her for at sætte et op.</string>
+    <string name="action_delete_banner">Fjern banner</string>
+    <string name="delete_banner">Fjern banner</string>
+    <string name="delete_banner_message">Dette fjerner dit profilbanner for alle, der kan se det.</string>
+    <string name="banner_has_been_published">Banner offentliggjort</string>
+    <string name="error_banner_too_large">Billedet er for stort (maks. 5 MB)</string>
+    <string name="error_publish_banner_converting">Kunne ikke behandle dette billede</string>
+    <string name="publish_banner_title">Profilbanner</string>
+    <string name="tap_to_choose_banner">Tryk for at vælge et profilbanner</string>
 </resources>
diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml
index 027cf8b..80c527c 100644
--- a/src/main/res/values-de/strings.xml
+++ b/src/main/res/values-de/strings.xml
@@ -538,7 +538,7 @@
     <string name="type_web">Webbrowser</string>
     <string name="type_console">Kommandozeile</string>
     <string name="payment_required">Bezahlung erforderlich</string>
-    <string name="missing_internet_permission">Berechtigung zur Nutzung des Internets erteilen</string>
+    <string name="missing_internet_permission">Internetberechtigung erforderlich</string>
     <string name="me">Ich</string>
     <string name="contact_asks_for_presence_subscription">Kontakt möchte Online-Status abonnieren</string>
     <string name="allow">Erlauben</string>
@@ -1546,6 +1546,11 @@
     <string name="translation_backend_offline_device">Auf dem Gerät (offline-translator App)</string>
     <string name="translation_backend_google">Google Übersetzer (öffentlich)</string>
     <string name="translation_feature_banner_text">Neue Übersetzungsfunktion hinzugefügt. Tippen Sie hier, um mehr zu erfahren und sie einzurichten.</string>
+    <string name="animated_banner_feature_banner_text">Profil-Banner können jetzt animiert sein (GIF/WebP). Tippen Sie hier, um eines einzurichten.</string>
+    <string name="reaction_notifications_feature_banner_text">Sie werden jetzt benachrichtigt, wenn jemand auf Ihre Nachrichten reagiert. Tippen Sie hier, um dies zu verwalten.</string>
+    <string name="message_correction_feature_banner_text">Das Bearbeiten von Nachrichten ist jetzt leistungsfähiger. Korrigieren Sie jede Nachricht (nicht nur die letzte) und halten Sie sie dann gedrückt, um den Änderungsverlauf anzuzeigen.</string>
+    <string name="settings_search_feature_banner_text">Die Einstellungen haben jetzt eine Suchleiste — finden Sie jede Option über ihren Namen, auch in einer anderen Sprache. Tippen Sie hier, um es auszuprobieren.</string>
+    <string name="audio_mini_player_feature_banner_text">Sprachnachrichten werden jetzt weiter abgespielt, wenn Sie Chat oder Bildschirm wechseln, mit einer kleinen Player-Leiste zur Steuerung.</string>
     <string name="dismiss">Schließen</string>
     <string name="pref_offline_translator_info_title">Übersetzung auf dem Gerät einrichten</string>
     <string name="pref_offline_translator_info_summary">Installiere die offline-translator-App von F-Droid oder GitHub, um Nachrichten zu übersetzen, ohne sie zu versenden. Tippen, um die neueste APK direkt herunterzuladen.</string>
@@ -1553,4 +1558,76 @@
     <string name="offline_translator_apk_not_found">Keine APK in der neuesten Version gefunden – öffne stattdessen die Release-Seite</string>
     <string name="pref_offline_translator_installed_title">Übersetzer auf dem Gerät installiert</string>
     <string name="pref_offline_translator_installed_summary">Die offline-translator-App ist installiert – Nachrichten können vollständig auf diesem Gerät übersetzt werden.</string>
+    <string name="edit_history">Änderungsverlauf</string>
+    <string name="view_edit_history">Änderungen anzeigen</string>
+    <string name="no_lan_permission">%1$s Zugriff auf das lokale Netzwerk (Geräte in der Nähe) gewähren.</string>
+    <string name="missing_local_network_permission">LAN-Berechtigung erforderlich</string>
+    <string name="grant_permission">Berechtigung erteilen</string>
+    <string name="open_settings">Einstellungen öffnen</string>
+    <string name="reaction_notification_text">hat mit %1$s auf \"%2$s\" reagiert</string>
+    <string name="pref_notify_on_reaction">Reaktionsbenachrichtigungen</string>
+    <string name="pref_notify_on_reaction_summary">Benachrichtigen, wenn jemand auf eine deiner Nachrichten reagiert.</string>
+    <string name="action_delete_banner">Banner entfernen</string>
+    <string name="delete_banner">Banner entfernen</string>
+    <string name="delete_banner_message">Dadurch wird dein Profil-Banner für alle entfernt, die es sehen können.</string>
+    <string name="banner_has_been_published">Banner veröffentlicht</string>
+    <string name="error_banner_too_large">Bild ist zu groß (max. 5 MB)</string>
+    <string name="error_publish_banner_converting">Dieses Bild konnte nicht verarbeitet werden</string>
+    <string name="publish_banner_title">Profil-Banner</string>
+    <string name="tap_to_choose_banner">Tippen, um ein Profil-Banner auszuwählen</string>
+    <string name="attach_sticker_failed">Sticker konnte nicht gesendet werden</string>
+    <string name="backup_history_all_messages">Alle Nachrichten</string>
+    <string name="command_cancelled">Befehl abgebrochen</string>
+    <string name="command_completed">Befehl abgeschlossen</string>
+    <string name="command_result">Ergebnis</string>
+    <string name="commands_empty">Keine Befehle verfügbar</string>
+    <string name="commands_load_failed">Befehle konnten nicht geladen werden</string>
+    <string name="commands_loading">Befehle werden geladen…</string>
+    <string name="comma_separated_values">Kommagetrennte Werte</string>
+    <string name="contact_is_verified">Verifiziert</string>
+    <string name="contact_is_verified_toast">Dies ist ein verifiziertes Konto</string>
+    <string name="contact_uses_jabjab">Nutzt JabJab</string>
+    <string name="contact_uses_jabjab_toast">Dein Kontakt nutzt JabJab</string>
+    <string name="crash_report_account">Gesendet von: %1$s</string>
+    <string name="field_required">Erforderlich</string>
+    <string name="import_sticker_pack">Sticker-Paket importieren</string>
+    <string name="jump_back">Zurückspringen</string>
+    <string name="jump_to_reply">Zur Antwort springen</string>
+    <string name="no_backup_files_found_hint">Hier wurden keine Sicherungsdateien gefunden.\nTippe auf das Cloud-Symbol oben, um nach einer Sicherungsdatei zu suchen.</string>
+    <string name="no_pinned_messages">Keine angehefteten Nachrichten</string>
+    <string name="no_reply_found">Keine Antwort auf diese Nachricht gefunden</string>
+    <string name="open_sticker_pack">Sticker-Paket öffnen</string>
+    <string name="pin_message">Nachricht anheften</string>
+    <string name="pinned_message">Angeheftete Nachricht</string>
+    <string name="pinned_messages">Angeheftete Nachrichten</string>
+    <string name="pinned_messages_counter">%1$d/%2$d</string>
+    <string name="pinned_messages_next">Nächste angeheftete Nachricht anzeigen</string>
+    <string name="pinned_messages_previous">Vorherige angeheftete Nachricht anzeigen</string>
+    <string name="pref_backup_message_history">Einzuschließender Nachrichtenverlauf</string>
+    <string name="pref_color_theme_strawberry">Erdbeere</string>
+    <string name="pref_message_menu_haptic_feedback">Haptisches Feedback</string>
+    <string name="pref_message_menu_haptic_feedback_summary">Vibrieren beim Öffnen des Nachrichtenmenüs oder Auswählen einer Schnellreaktion</string>
+    <string name="profile_field_status">Status</string>
+    <string name="profile_saved_offline">Gespeichert — wird veröffentlicht, sobald du wieder online bist</string>
+    <string name="send_pack_link">An Kontakte senden</string>
+    <string name="share_to_story_label">JabJab-Storys</string>
+    <string name="status">Status</string>
+    <string name="sticker_pack_up_to_date">Sticker-Paket bereits aktuell</string>
+    <string name="tab_chat">Chat</string>
+    <string name="tab_commands">Befehle</string>
+    <string name="tap_to_install_sticker_pack">Tippen, um Sticker-Paket zu installieren</string>
+    <string name="tap_to_open_sticker_pack">Tippen, um Sticker-Paket zu öffnen</string>
+    <string name="telegram_import_needs_token">Für den Import von Telegram-Sticker-Paketen wird ein Bot-Token benötigt. Füge einen in den Einstellungen hinzu und tippe den Link erneut an.</string>
+    <string name="translation_already_system_language">Diese Nachricht ist bereits in deiner Sprache — wähle eine Zielsprache</string>
+    <string name="translation_disclaimer_offline_device">per geräteinterner Übersetzung</string>
+    <string name="unpin_message">Nachricht loslösen</string>
+    <string name="update_download_complete">Update heruntergeladen</string>
+    <string name="update_download_failed_text">Tippen, um es erneut zu versuchen</string>
+    <string name="update_download_failed_title">Update-Download fehlgeschlagen</string>
+    <string name="update_needs_install_permission">Tippen, um die Installation von Updates zu erlauben</string>
+    <string name="update_ready_to_install">Tippen, um zu installieren</string>
+    <plurals name="sticker_pack_updated">
+        <item quantity="one">%d neuer Sticker hinzugefügt</item>
+        <item quantity="other">%d neue Sticker hinzugefügt</item>
+    </plurals>
 </resources>
diff --git a/src/main/res/values-el/strings.xml b/src/main/res/values-el/strings.xml
index 0c38186..a385e98 100644
--- a/src/main/res/values-el/strings.xml
+++ b/src/main/res/values-el/strings.xml
@@ -1099,4 +1099,26 @@
     <string name="tab_joined_channels">Συνδεδεμένα</string>
     <string name="tab_discover_channels">Ανακάλυψη</string>
     <string name="no_joined_channels_hint">Δεν υπάρχουν κανάλια ή ομαδικές συνομιλίες ακόμα.\nΧρησιμοποιήστε την καρτέλα Ανακάλυψη για να βρείτε και να συνδεθείτε σε δημόσια κανάλια.</string>
+    <string name="view_edit_history">Προβολή ιστορικού επεξεργασίας</string>
+    <string name="edit_history">Ιστορικό επεξεργασίας</string>
+    <string name="message_correction_feature_banner_text">Η επεξεργασία μηνυμάτων έγινε ακόμα πιο ισχυρή. Διορθώστε οποιοδήποτε μήνυμα (όχι μόνο το τελευταίο) και έπειτα πατήστε παρατεταμένα για να δείτε το ιστορικό επεξεργασίας του.</string>
+    <string name="settings_search_feature_banner_text">Οι Ρυθμίσεις έχουν πλέον γραμμή αναζήτησης — βρείτε οποιαδήποτε επιλογή με το όνομά της, ακόμη και σε άλλη γλώσσα. Πατήστε εδώ για να το δοκιμάσετε.</string>
+    <string name="audio_mini_player_feature_banner_text">Τα φωνητικά μηνύματα συνεχίζουν πλέον να αναπαράγονται όταν αλλάζετε συνομιλία ή οθόνη, με μια μικρή γραμμή αναπαραγωγής για τον έλεγχό τους.</string>
+    <string name="no_lan_permission">Δώστε στο %1$s πρόσβαση στο τοπικό δίκτυο (κοντινές συσκευές).</string>
+    <string name="missing_local_network_permission">Απαιτείται άδεια τοπικού δικτύου</string>
+    <string name="grant_permission">Παραχώρηση άδειας</string>
+    <string name="open_settings">Άνοιγμα ρυθμίσεων</string>
+    <string name="reaction_notification_text">αντέδρασε με %1$s στο \"%2$s\"</string>
+    <string name="pref_notify_on_reaction">Ειδοποιήσεις αντιδράσεων</string>
+    <string name="pref_notify_on_reaction_summary">Ειδοποίηση όταν κάποιος αντιδράσει σε ένα από τα μηνύματά σας.</string>
+    <string name="reaction_notifications_feature_banner_text">Τώρα ειδοποιείστε όταν κάποιος αντιδράσει στα μηνύματά σας. Πατήστε εδώ για διαχείριση.</string>
+    <string name="animated_banner_feature_banner_text">Τα banner προφίλ μπορούν τώρα να είναι κινούμενα (GIF/WebP). Πατήστε εδώ για να ρυθμίσετε ένα.</string>
+    <string name="action_delete_banner">Αφαίρεση banner</string>
+    <string name="delete_banner">Αφαίρεση banner</string>
+    <string name="delete_banner_message">Αυτό αφαιρεί το banner προφίλ σας για όλους όσους μπορούν να το δουν.</string>
+    <string name="banner_has_been_published">Το banner δημοσιεύτηκε</string>
+    <string name="error_banner_too_large">Η εικόνα είναι πολύ μεγάλη (μέγ. 5 MB)</string>
+    <string name="error_publish_banner_converting">Δεν ήταν δυνατή η επεξεργασία αυτής της εικόνας</string>
+    <string name="publish_banner_title">Banner προφίλ</string>
+    <string name="tap_to_choose_banner">Πατήστε για να επιλέξετε banner προφίλ</string>
 </resources>
diff --git a/src/main/res/values-eo/strings.xml b/src/main/res/values-eo/strings.xml
index 35d926a..7584948 100644
--- a/src/main/res/values-eo/strings.xml
+++ b/src/main/res/values-eo/strings.xml
@@ -38,10 +38,10 @@
     <string name="invalid_muc_nick">Nevalida kromnomo</string>
     <string name="admin">Administranto</string>
     <string name="owner">Posedanto</string>
-    <string name="moderator">Babilestro</string>
+    <string name="moderator">Kunvenestro</string>
     <string name="participant">Partoprenanto</string>
     <string name="visitor">Vizitanto</string>
-    <string name="remove_contact_text">Ĉu vi volas forigi %s-on el via kontaktlisto? La babilejo kun tiu ĉi kontakto ne forigitos.</string>
+    <string name="remove_contact_text">Ĉu vi volas forigi kontakton %s el via kontaktlisto? La babilejo kun tiu ĉi kontakto ne forigitos.</string>
     <string name="block_contact_text">Ĉu vi volas bari %s-on por sendi mesaĝojn al vi?</string>
     <string name="unblock_contact_text">Ĉu vi volas malbari %s-on kaj permesi ŝ/lin sendi mesaĝojn al vi?</string>
     <string name="block_domain_text">Ĉu bari ĉiujn kontaktojn el %s?</string>
@@ -84,10 +84,1113 @@
     <string name="clear_conversation_history">Forviŝi babilejan historion</string>
     <string name="clear_histor_msg">Ĉu vi volas forigi ĉiujn mesaĝojn en tiu ĉi babilejo?\n\n<b>Averto:</b> Tio ĉi ne influos mesaĝojn konservitajn ĉe aliaj aparatoj aŭ serviloj.</string>
     <string name="delete_file_dialog">Forigi dosieron</string>
-    <string name="archive_this_chat">Forigi babilejo post tiu</string>
+    <string name="archive_this_chat">Forigi babilejon poste</string>
     <string name="choose_presence">Elektu aparaton</string>
     <string name="send_unencrypted_message">Sendi neĉifrotan mesaĝon</string>
     <string name="send_message">Sendi mesaĝon</string>
+    <string name="start_group_chat">Komenci grupan babilejon</string>
+    <string name="delete_file_dialog_shared">Ĉi tiu dosiero estis kunhavita kun aliaj babilejoj.</string>
+    <string name="delete_file_dialog_confirm">Ĉu vi certas, ke vi volas forigi ĉi tiun dosieron?</string>
+    <string name="delete_file_dialog_copies">Ĉi tiu ago ne forigos kopiojn de tiu ĉi dosiero, konservitajn ĉe aliaj aparatoj aŭ serviloj.</string>
+    <plurals name="delete_files_dialog">
+        <item quantity="one">Forigi dosieron</item>
+        <item quantity="other">Forigi %d dosierojn</item>
+    </plurals>
+    <plurals name="delete_files_dialog_msg">
+        <item quantity="one">Ĉu vi certas, ke vi volas forigi ĉi tiun dosieron?</item>
+        <item quantity="other">Ĉu vi certas, ke vi volas forigi ĉi tiujn dosierojn?</item>
+    </plurals>
+    <string name="send_message_to_x">Sendi mesaĝon al %s</string>
+    <string name="send_encrypted_message">Sendi ĉifritan mesaĝon</string>
+    <string name="send_omemo_x509_message">Sendi v\\OMEMO-ĉifritan mesaĝon</string>
+    <string name="your_nick_has_been_changed">Via kromnomo estis ŝanĝita</string>
+    <string name="send_unencrypted">Sendi sen ĉifrado</string>
+    <string name="decryption_failed">Malĉifrado malsukcesis. Eble vi ne havas taŭgan privatan ŝlosilon.</string>
+    <string name="openkeychain_required">OpenKeychain</string>
+    <string name="openkeychain_required_long"><![CDATA[%1$s uzas la aplikaĵon <b>OpenKeychain</b> por ĉifri kaj malĉifri mesaĝojn, kaj kontroli viajn publikajn ŝlosilojn.<br><br>Ĝi estas distribuita kun permesilo GPLv3+ kaj havebla ĉe deponejoj F-Droid kaj Google Play.<br><br><small>(Bonvolu restartigi la aplikaĵon %1$s poste.)</small>]]></string>
+    <string name="restart">Restartigi</string>
+    <string name="install">Instali</string>
+    <string name="openkeychain_not_installed">Bonvolu instali aplikaĵon OpenKeychain</string>
+    <string name="offering">oferado…</string>
+    <string name="waiting">atendado…</string>
+    <string name="no_pgp_key">Neniu ŝlosilo OpenPGP trovita</string>
+    <string name="contact_has_no_pgp_key">Ĉifrado de via mesaĝo malsukcesis, ĉar via kontaktulo ne anoncas sian publikan ŝlosilon.\n\n<small>Bonvolu peti vian kontaktulon instali kaj agordi programon, kiu provizas servojn de OpenPGP.</small></string>
+    <string name="no_pgp_keys">Neniuj ŝlosiloj OpenPGP trovitaj</string>
+    <string name="contacts_have_no_pgp_keys">Ĉifrado de via mesaĝo malsukcesis, ĉar viaj kontaktuloj ne anoncas siajn publikajn ŝlosilojn.\n\n<small>Bonvolu peti ilin instali kaj agordi programon, kiu provizas servojn de OpenPGP.</small></string>
+    <string name="pref_general">Ĝeneralaj</string>
+    <string name="pref_accept_files">Akcepti dosierojn</string>
+    <string name="pref_accept_files_summary">Aŭtomate akcepti dosierojn, pli malgrandajn ol…</string>
+    <string name="pref_attachments">Kunsendaĵoj</string>
+    <string name="pref_notification_settings">Sciigo</string>
+    <string name="pref_vibrate">Vibri</string>
+    <string name="pref_vibrate_summary">Vibri, kiam nova mesaĝo alvenas</string>
+    <string name="pref_led">LED-sciigo</string>
+    <string name="account_status_unknown">Nekonata</string>
+    <string name="account_status_disabled">Portempe malŝaltita</string>
+    <string name="account_state_logged_out">Ne salutita</string>
+    <string name="account_status_online">Enreta</string>
+    <string name="account_status_connecting">Konektado…</string>
+    <string name="account_status_offline">Ne konektita</string>
+    <string name="account_status_unauthorized">Ne rajtigita</string>
+    <string name="account_status_not_found">Servilo ne trovita</string>
+    <string name="account_status_no_internet">Neniu interreto</string>
+    <string name="account_status_airplane_mode">Aviadila reĝimo</string>
+    <string name="account_status_connection_timeout">Konekto eltempiĝis</string>
+    <string name="account_status_regis_fail">Registrado fiaskis</string>
+    <string name="account_status_regis_conflict">Uzantnomo jam uzata</string>
+    <string name="account_status_regis_success">Registrado plenumita</string>
+    <string name="account_status_regis_not_sup">Registrado ne subtenata de servilo</string>
+    <string name="account_status_regis_invalid_captcha">Nevalida CAPTCHA</string>
+    <string name="account_status_regis_invalid_token">Nevalida ĵetono de registrado</string>
+    <string name="account_status_tls_error">Negoco per TLS fiaskis</string>
+    <string name="account_status_tls_error_domain">Domajno ne kontroleblas</string>
+    <string name="account_status_tls_error_protocol">Malforta sekureco de transporto (neniu TLS 1.3)</string>
+    <string name="account_status_tls_error_untrusted">Nefidinda atestilo</string>
+    <string name="account_status_policy_violation">Rompo de sekureca politiko</string>
+    <string name="account_status_incompatible_server">Nekongrua servilo</string>
+    <string name="account_status_incompatible_client">Nekongrua kliento</string>
+    <string name="account_status_stream_error">Eraro de fluo</string>
+    <string name="account_status_stream_opening_error">Eraro de fluokomenco</string>
+    <string name="encryption_choice_unencrypted">Neĉifrita</string>
+    <string name="encryption_choice_otr">OTR</string>
+    <string name="encryption_choice_pgp">OpenPGP</string>
+    <string name="encryption_choice_omemo">OMEMO</string>
+    <string name="mgmt_account_delete">Forigi konton</string>
+    <string name="mgmt_account_disable">Portempe malŝalti</string>
+    <string name="mgmt_account_publish_avatar">Publikigi profilbildon</string>
+    <string name="mgmt_account_publish_pgp">Publikigi publikan ŝlosilon de OpenPGP</string>
+    <string name="unpublish_pgp">Forigi publikan ŝlosilon de OpenPGP</string>
+    <string name="unpublish_pgp_message">Ĉu vi certas, ke vi volas forigi vian publikan ŝlosilon de OpenPGP el via anonco de ĉeesto?\nViaj kontaktuloj neniam plu eblos sendi OpenPGP-ĉifritajn mesaĝojn al vi.</string>
+    <string name="openpgp_has_been_published">Publika ŝlosilo de OpenPGP publikigita.</string>
+    <string name="mgmt_account_enable">Ŝalti konton</string>
+    <string name="mgmt_account_delete_confirm_text">Ĉu vi certas, ke vi volas forigi vian konton? Forigado de via konto forigas ĉiun vian historion de babilado</string>
+    <string name="account_settings_jabber_id">XMPP-adreso</string>
+    <string name="block_jabber_id">Bloki XMPP-adreson</string>
+    <string name="account_settings_example_jabber_id">uzantnomo@example.com</string>
+    <string name="password">Pasvorto</string>
+    <string name="invalid_jid">Ĉi tio ne estas valida XMPP-adreso</string>
+    <string name="error_out_of_memory">Memoro elĉerpita. Bildo tro granda</string>
+    <string name="add_phone_book_text">Ĉu vi volas aldoni kontakton %s al via adresaro?</string>
+    <string name="server_info_show_more">Informoj pri servilo</string>
+    <string name="server_info_mam">XEP-0313: Kontrolo de arkivado de mesaĝoj</string>
+    <string name="server_info_occupant_id">XEP-0421: Identigiloj por babilejuloj</string>
+    <string name="server_info_moderated_message_retraction">XEP-0425: Retraktado de mesaĝoj, forigitaj de kunvenestroj</string>
+    <string name="server_info_carbon_messages">XEP-0280: Kopiado de mesaĝo-malnetoj</string>
+    <string name="server_info_csi">XEP-0352: Indikado de klienta stato</string>
+    <string name="server_info_blocking">XEP-0191: Komando de blokado</string>
+    <string name="server_info_roster_version">XEP-0237: Versiado de kontaktaroj</string>
+    <string name="server_info_stream_management">XEP-0198: Kontrolado de fluoj</string>
+    <string name="server_info_external_service_discovery">XEP-0215: Malkovrado de eksteraj servoj</string>
+    <string name="server_info_pep">XEP-0163: Sendado de personaj eventoj (profilbildoj / OMEMO)</string>
+    <string name="server_info_http_upload">XEP-0363: Alŝutado de dosieroj per HTTP</string>
+    <string name="server_info_bind2">XEP-0386: Simpligita konektado 2</string>
+    <string name="server_info_sasl2">XEP-0388: Etendebla SASL-profilo</string>
+    <string name="server_info_push">XEP-0357: Tujaj sciigoj</string>
+    <string name="server_info_available">havebla</string>
+    <string name="server_info_unavailable">nehavebla</string>
+    <string name="missing_public_keys">Mankas anoncoj de publikaj ŝlosiloj</string>
+    <string name="install_openkeychain">Ĉifrita mesaĝo. Bonvolu instali aplikaĵon OpenKeychain por malĉifri ĝin.</string>
+    <string name="openpgp_messages_found">Novaj OpenPGP-ĉifritaj mesaĝoj trovitaj</string>
+    <string name="openpgp_key_id">Identigilo de OpenPGP-ŝlosilo</string>
+    <string name="omemo_fingerprint">Fingropremo de OMEMO</string>
+    <string name="omemo_fingerprint_x509">v\\Fingropremo de OMEMO</string>
+    <string name="omemo_fingerprint_selected_message">Fingropremo de OMEMO (origino de mesaĝo)</string>
+    <string name="omemo_fingerprint_x509_selected_message">Fingropremo de v\\OMEMO (origino de mesaĝo)</string>
+    <string name="other_devices">Aliaj aparatoj</string>
+    <string name="trust_omemo_fingerprints">Fidu fingropremojn de OMEMO</string>
+    <string name="fetching_keys">Elŝutado de ŝlosiloj…</string>
+    <string name="done">Farita</string>
+    <string name="decrypt">Malĉifri</string>
+    <string name="group_chats">Grupaj babilejoj</string>
+    <string name="search">Serĉi</string>
+    <string name="enter_country_code_and_phone_number">Quicksy sendos SMS-mesaĝon (kostas laŭ via retoperatoro) por kontroli vian poŝtelefonnumeron. Enigu vian landokodon kaj telefonnumeron:</string>
+    <string name="we_will_be_verifying"><![CDATA[Ni kontrolos la telefonnumeron<br><br><b>%s</b><br><br>Ĉu ĝi estas bona, aŭ ĉu vi volas redakti la numeron?]]></string>
+    <string name="not_a_valid_phone_number">%s ne estas valida telefonnumero.</string>
+    <string name="please_enter_your_phone_number">Bonvolu enigi vian telefonnumeron.</string>
+    <string name="search_countries">Serĉi landojn</string>
+    <string name="verify_x">Kontroli: %s</string>
+    <string name="we_have_sent_you_an_sms_to_x"><![CDATA[Ni sendis SMS-mesaĝon por vi al numero <b>%s</b>.]]></string>
+    <string name="we_have_sent_you_another_sms">Ni sendis alian SMS-mesaĝon al vi kun 6-cifra kodo.</string>
+    <string name="please_enter_pin_below">Bonvolu enigi la 6-cifran kodon sube.</string>
+    <string name="resend_sms">Resendi SMS-mesaĝon</string>
+    <string name="resend_sms_in">Resendi SMS-mesaĝon (%s)</string>
+    <string name="wait_x">Bonvolu atendi (%s)</string>
+    <string name="back">Malantaŭen</string>
+    <string name="abort_registration_procedure">Ĉu vi certas, ke vi volas aborti la registradon?</string>
+    <string name="yes">Jes</string>
+    <string name="no">Ne</string>
+    <string name="possible_pin">Ebla PIN-kodo aŭtomate algluita el tondujo.</string>
+    <string name="please_enter_pin">Bonvolu enigi vian 6-cifran PIN-kodon.</string>
+    <string name="verifying">Kontrolado…</string>
+    <string name="requesting_sms">Petado por SMS-mesaĝo…</string>
+    <string name="incorrect_pin">La PIN-kodo, kiujn vi enigis, estas malĝusta.</string>
+    <string name="pin_expired">La PIN-kodo, kiun vi enigis, eltempiĝis.</string>
+    <string name="unknown_api_error_network">Nekonata reteraro.</string>
+    <string name="unknown_api_error_response">Nekonata respondo el servilo.</string>
+    <string name="unable_to_connect_to_server">Ne povas konekti al servilo.</string>
+    <string name="unable_to_establish_secure_connection">Ne povas starigi sekuran konekton.</string>
+    <string name="unable_to_find_server">Ne povas trovi servilon.</string>
+    <string name="something_went_wrong_processing_your_request">Io misokazis dum procesado de via peto.</string>
+    <string name="invalid_user_input">Nevalida uzanta enigo</string>
+    <string name="temporarily_unavailable">Portempe nedisponebla. Klopodu denove poste.</string>
+    <string name="no_network_connection">Neniu reta konekto.</string>
+    <string name="try_again_in_x">Bonvolu klopodi denove %s post</string>
+    <string name="rate_limited">Vi estas ofteco-limigita</string>
+    <string name="too_many_attempts">Tro da provoj</string>
+    <string name="the_app_is_out_of_date">Vi uzas tro malnovan version de ĉi tiu aplikaĵo.</string>
+    <string name="update">Ĝisdatigi</string>
+    <string name="logged_in_with_another_device">Ĉi tiu telefonnumero estas nune salutita per alia aparato.</string>
+    <string name="enter_your_name_instructions">Bonvolu entajpi vian nomon, por ke tiuj, kiuj ne havas vin en ties adresaro, scias, kiu vi estas.</string>
+    <string name="your_name">Via nomo</string>
+    <string name="enter_your_name">Enigu vian nomon</string>
+    <string name="no_name_set_instructions">Uzu la butonon \"redakti\" por agordi vian nomon.</string>
+    <string name="reject_request">Malakcepti peton</string>
+    <string name="install_orbot">Instali aplikaĵon Orbot</string>
+    <string name="start_orbot">Komenci aplikaĵon Orbot</string>
+    <string name="no_market_app_installed">Neniu aplikaĵo-deponejo instalita.</string>
+    <string name="group_chat_will_make_your_jabber_id_public">Ĉi tiu publika babilejo publikigos vian XMPP-adreson</string>
+    <string name="ebook">bitlibro</string>
+    <string name="video_original">Origina (nedensigita)</string>
+    <string name="open_with">Malfermi per…</string>
+    <string name="set_profile_picture">Profilbildo</string>
+    <string name="choose_account">Elekti konton</string>
+    <string name="restore_backup">Restaŭri savkopion</string>
+    <string name="restore">Restaŭri</string>
+    <string name="enter_password_to_restore">Enigu vian pasvorton por la konto \"%s\" por restaŭri la savkopion.</string>
+    <string name="restore_omemo_key">Restaŭri OMEMO-ŝlosilojn</string>
+    <string name="restore_warning">Ne restaŭru la OMEMO-ŝlosilojn, se vi klopodas kloni (uzi samtempe ĉe multaj aparatoj) la instalaĵon. Restaŭrado de OMEMO-ŝlosiloj estas nur intencita por migradoj, aŭ se vi perdis la originan aparaton.</string>
+    <string name="restore_warning_continued">Restaŭru nun tiujn savkopiojn, kiujn vi mem kreis.</string>
+    <string name="unable_to_restore_backup">Ne povas restaŭri savkopion.</string>
+    <string name="unable_to_decrypt_backup">Ne povas malĉifri savkopion. Ĉu la pasvorto estas ĝusta?</string>
+    <string name="backup_channel_name">Savkopiado &amp; restaŭrado</string>
+    <string name="enter_jabber_id">Enigu XMPP-adreson</string>
+    <string name="create_group_chat">Krei grupan babilejon</string>
+    <string name="join_public_channel">Aligi al publika babilejo</string>
+    <string name="create_private_group_chat">Krei privatan grupan babilejon</string>
+    <string name="create_public_channel">Krei publikan babilejon</string>
+    <string name="create_dialog_channel_name">Nomo de babilejo</string>
+    <string name="xmpp_address">XMPP-adreso</string>
+    <string name="please_enter_name">Bonvolu enigi nomon por la babilejo</string>
+    <string name="please_enter_xmpp_address">Bonvolu provizi XMPP-adreson</string>
+    <string name="this_is_an_xmpp_address">Ĉi tio estas XMPP-adreso. Bonvolu provizi nomon.</string>
+    <string name="creating_channel">Kreado de publika babilejo…</string>
+    <string name="channel_already_exists">Ĉi tiu babilejo jam ekzistas</string>
+    <string name="joined_an_existing_channel">Vi aligis al ekzistanta babilejo</string>
+    <string name="unable_to_set_channel_configuration">Ne povas konservi agordojn de babilejo</string>
+    <string name="allow_participants_to_edit_subject">Permesi al ĉiuj redakti la temon</string>
+    <string name="allow_participants_to_invite_others">Permesi al ĉiuj inviti aliulojn</string>
+    <string name="anyone_can_edit_subject">Ĉiu povas redakti la temon.</string>
+    <string name="owners_can_edit_subject">La posedantoj povas redakti la temon.</string>
+    <string name="admins_can_edit_subject">Administrantoj povas redakti la temon.</string>
+    <string name="owners_can_invite_others">Posedantoj povas inviti aliulojn.</string>
+    <string name="anyone_can_invite_others">Ĉiu povas inviti aliulojn.</string>
+    <string name="jabber_ids_are_visible_to_admins">XMPP-adresoj estas videblaj por la administrantoj.</string>
+    <string name="jabber_ids_are_visible_to_anyone">XMPP-adresoj estas videblaj por ĉiu.</string>
+    <string name="no_users_hint_channel">Ĉi tiu publika babilejo havas neniujn partoprenantojn. Invitu viajn kontaktulojn aŭ uzu la butonon \"konigi\" por distribui ties XMPP-adreson.</string>
+    <string name="no_users_hint_group_chat">Ĉi tiu privata grupa babilejo havas neniujn partoprenantojn.</string>
+    <string name="manage_permission">Agordi privilegiojn</string>
+    <string name="search_participants">Serĉi partoprenantojn</string>
+    <string name="file_too_large">Dosiero tro granda</string>
+    <string name="attach">Kunsendi</string>
+    <string name="send">Sendi</string>
+    <string name="discover_channels">Malkovri babilejojn</string>
+    <string name="search_channels">Serĉi babilejojn</string>
+    <string name="channel_discovery_opt_in_title">Ebla rompo de privateco!</string>
+    <string name="channel_discover_opt_in_message">Malkovrado de babilejoj uzas eksteran servon, nomatan &lt;a href=https://search.jabber.network&gt;search.jabber.network&lt;/a&gt;.&lt;br&gt;&lt;br&gt;Uzado de tiu ĉi funkcio transsendas vian IP-adreson kaj serĉvortojn al tiu servo. Rigardu ties &lt;a href=https://search.jabber.network/privacy&gt;privatecan politikon&lt;/a&gt; (anglalingve) por pli da informo.</string>
+    <string name="i_already_have_an_account">Mi ankaŭ havas konton</string>
+    <string name="add_existing_account">Aldoni ekzistantan konton</string>
+    <string name="register_new_account">Registri novan konton</string>
+    <string name="this_looks_like_a_domain">Ĉi tiu aspektas kiel domajna adreso</string>
+    <string name="add_anway">Aldoni tamen</string>
+    <string name="this_looks_like_channel">Ĉi tio aspektas kiel adreso de babilejo</string>
+    <string name="share_backup_files">Konigi dosierojn de savkopio</string>
+    <string name="conversations_backup">Savkopio de Conversations</string>
+    <string name="event">Evento</string>
+    <string name="open_backup">Malfermi savkopion</string>
+    <string name="not_a_backup_file">La dosiero, kiun vi elektis, ne estas dosiero de savkopio de Conversations</string>
+    <string name="outdated_backup_file_format">Vi provas enporti savkopian dosieron de tro malnova formato</string>
+    <string name="non_quicksy_backup">Quicksy povas restaŭri nur savkopiojn de kontoj ĉe quicksy.im</string>
+    <string name="account_already_setup">Ĉi tiu konto ankaŭ estis agordita</string>
+    <string name="please_enter_password">Bonvolu enigi la pasvorton por tiu ĉi konto</string>
+    <string name="unable_to_perform_this_action">Ne povas fari tiun ĉi agon</string>
+    <string name="open_join_dialog">Aligi al publika babilejo…</string>
+    <string name="sharing_application_not_grant_permission">La aplikaĵo de konigado ne permesis atingi tiun ĉi dosieron.</string>
+    <string name="group_chats_and_channels"><![CDATA[Grupaj &amp; publikaj babilejoj]]></string>
+    <string name="jabber_network">jabber.network</string>
+    <string name="local_server">Loka servilo</string>
+    <string name="pref_channel_discovery_summary">Plimulto da uzantoj devus elekti servon ‘jabber.network’ for pli bonaj sugestoj, kiuj inkluzivas la ĉiun publikan XMPP-ekosistemon.</string>
+    <string name="pref_channel_discovery">Metodo por malkovrado de babilejoj</string>
+    <string name="backup">Savkopii</string>
+    <string name="category_about">Pri</string>
+    <string name="pref_never_send_crash_summary">Sendante spurojn de stako, vi helpas programadon de tiu ĉi aplikaĵo</string>
+    <string name="pref_confirm_messages">Legokonfirmoj</string>
+    <string name="pref_confirm_messages_summary">Kiam ŝaltita, viaj kontaktuloj vidos, kiam vi legis ties mesaĝojn.</string>
+    <string name="pref_prevent_screenshots">Malebligi ekrankopiojn</string>
+    <string name="pref_prevent_screenshots_summary">Kaŝi la enhavon de la aplikaĵo en aplikaĵo-ŝanĝilo kaj bloki ekrankopiadon</string>
+    <string name="pref_ui_options">Uzanta interfaco</string>
+    <string name="openpgp_error">Aplikaĵo OpenKeychain produktis eraron.</string>
+    <string name="bad_key_for_encryption">Malbona ŝlosilo por ĉifrado.</string>
+    <string name="accept">Akcepti</string>
+    <string name="error">Eraro okazis</string>
+    <string name="recording_error">Eraro</string>
+    <string name="your_account">Via konto</string>
+    <string name="send_presence_updates">Sendi ĝisdatigojn de ĉeesto</string>
+    <string name="receive_presence_updates">Ricevi ĝisdatigojn de ĉeesto</string>
+    <string name="ask_for_presence_updates">Peti por ĝisdatigoj de ĉeesto</string>
+    <string name="preemptively_grant">Anticipe akcepti peton de abonado</string>
+    <string name="error_not_an_image_file">Dosiero, kiun vi elektis, ne estas bildo</string>
+    <string name="error_compressing_image">Ne povas konverti bildo-dosieron</string>
+    <string name="error_file_not_found">Dosiero ne trovita</string>
+    <string name="error_io_exception">Ĝenerala eraro de eneligo. Eble, mankas libera spaco en via aparato?</string>
+    <string name="error_security_exception_during_image_copy">La aplikaĵo, kiun vi uzis por elekti tiun ĉi bildon ne provizis sufiĉajn permesojn por legi la dosieron.\n\n<small>Uzu alian dosieradministrilon por elekti la bildon</small>.</string>
+    <string name="error_security_exception">La aplikaĵo, kiun vi elektis por konigi tiun ĉi dosieron, ne provizis sufiĉajn permesojn.</string>
+    <string name="pref_led_summary">Lumpulsi sciigan lumon, kiam nova mesaĝo alvenas</string>
+    <string name="pref_ringtone">Vokosono</string>
+    <string name="pref_notification_sound">Sono de sciigo</string>
+    <string name="pref_notification_sound_summary">Sono de sciigo por novaj mesaĝoj</string>
+    <string name="pref_call_ringtone_summary">Sono por alvenantaj vokoj</string>
+    <string name="pref_notification_grace_period">Periodo de preventado de troaj sciigoj</string>
+    <string name="pref_notification_grace_period_summary">Tempolongo, dum kiu sciigoj estas silentigitaj post detektado de aktiveco sur unu de viaj aliaj aparatoj.</string>
+    <string name="pref_advanced_options">Altnivelaj</string>
+    <string name="pref_send_crash_reports">Sendi fiaskoraportojn</string>
+    <string name="enter_contact">Enigu kontaktulon</string>
+    <string name="delete_contact">Forigi kontakton</string>
+    <string name="view_contact_details">Vidi detalojn de kontakto</string>
+    <string name="block_contact">Bloki kontakton</string>
+    <string name="unblock_contact">Malbloki kontakton</string>
+    <string name="create">Krei</string>
+    <string name="select">Elekti</string>
+    <string name="contact_already_exists">Kontakto jam ekzistas</string>
+    <string name="join">Aliĝi</string>
+    <string name="channel_full_jid_example">babilejo@conference.example.com/kromnomo</string>
+    <string name="channel_bare_jid_example">babilejo@conference.example.com</string>
+    <string name="save_as_bookmark">Konservi kiel legosigno</string>
+    <string name="delete_bookmark">Forigi legosignon</string>
+    <string name="destroy_room">Detrui grupan babilejon</string>
+    <string name="destroy_channel">Detrui publikan babilejon</string>
+    <string name="destroy_room_dialog">Ĉu vi certas, ke vi volas detrui tiun ĉi grupan babilejon?\n\n<b>Averto:</b> La grupa babilejo estos tute forigita en la servilo.</string>
+    <string name="destroy_channel_dialog">Ĉu vi certas, ke vi volas detrui tiun ĉi publikan babilejon?\n\n<b>Averto:</b> La publika babilejo estos tute forigita en la servilo.</string>
+    <string name="could_not_destroy_room">Ne povas detrui grupan babilejon</string>
+    <string name="could_not_destroy_channel">Ne povas detrui publikan babilejon</string>
+    <string name="action_edit_subject">Redakti temon de grupa babilejo</string>
+    <string name="topic">Temo</string>
+    <string name="joining_conference">Aligado al grupa babilejo…</string>
+    <string name="leave">Foriri</string>
+    <string name="contact_added_you">Kontaktulo aldonis vin al kontaktlisto</string>
+    <string name="add_back">Aldoni returne</string>
+    <string name="contact_has_read_up_to_this_point">%s legis ĝis tiu punkto</string>
+    <string name="contacts_have_read_up_to_this_point">%s legis ĝis tiu punkto</string>
+    <string name="contacts_and_n_more_have_read_up_to_this_point">%1$s +%2$d aliaj legis ĝis tiu punkto</string>
+    <string name="everyone_has_read_up_to_this_point">Ĉiu legis ĝis tiu punkto</string>
+    <string name="publish">Publikigi</string>
+    <string name="touch_to_choose_picture">Frapetu la profilbildon por elekti bildon de galerio</string>
+    <string name="publishing">Publikigado…</string>
+    <string name="error_publish_avatar_server_reject">La servilo rifuzis vian publikaĵon</string>
+    <string name="error_publish_avatar_converting">Ne povas konverti vian bildon</string>
+    <string name="error_saving_avatar">Ne povas konservi profilbildon al disko</string>
+    <string name="or_long_press_for_default">(aŭ premu longtempe por reagordi defaŭlton)</string>
+    <string name="error_publish_avatar_no_server_support">Via servilo ne subtenas publikigadon de profilbildoj</string>
+    <string name="private_message">flustris</string>
+    <string name="private_message_to">al %s</string>
+    <string name="send_private_message_to">Sendi privatan mesaĝon al %s</string>
+    <string name="connect">Konekti</string>
+    <string name="account_already_exists">Ĉi tiu konto jam ekzistas</string>
+    <string name="next">Sekven</string>
+    <string name="server_info_session_established">Seanco starigita</string>
+    <string name="skip">Preterpasi</string>
+    <string name="disable_notifications">Malŝalti sciigojn</string>
+    <string name="enable">Ŝalti</string>
+    <string name="conference_requires_password">Grupa babilejo postulas pasvorton</string>
+    <string name="enter_password">Enigu pasvorton</string>
+    <string name="request_presence_updates">Bonvolu peti ĝisdatigojn de ĉeesto el via kontaktulo unue.\n\n<small>Ĉi tio estos uzata por eltrovi, kiun aplikaĵon via kontaktulo uzas</small>.</string>
+    <string name="request_now">Peti nun</string>
+    <string name="ignore">Ignori</string>
+    <string name="without_mutual_presence_updates"><b>Averto:</b> Sendado sen ambaŭflankaj ĝisdatigoj de ĉeesto povas kaŭzi neatenditajn problemojn.\n\n<small>Iru al “Detaloj de kontakto” por kontroli viaj abonoj de ĉeesto.</small></string>
+    <string name="pref_security_settings">Sekureco</string>
+    <string name="pref_allow_message_correction">Korektaĵoj de mesaĝoj</string>
+    <string name="pref_allow_message_correction_summary">Permesi al viaj kontaktuloj retroefike redakti siajn mesaĝojn</string>
+    <string name="pref_expert_options">Ekspertaj agordoj</string>
+    <string name="pref_expert_options_summary">Bonvolu esti singarda kun tiuj agordoj</string>
+    <string name="title_activity_about_x">Pri %s</string>
+    <string name="title_pref_quiet_hours">Silentaj horoj</string>
+    <string name="title_pref_quiet_hours_start_time">Komenca tempo</string>
+    <string name="title_pref_quiet_hours_end_time">Fina tempo</string>
+    <string name="title_pref_enable_quiet_hours">Ŝalti silentajn horojn</string>
+    <string name="pref_quiet_hours_summary">Sciigoj estos silentigitaj dum silentaj horoj</string>
+    <string name="pref_expert_options_other">Aliaj</string>
+    <string name="pref_autojoin">Sinkronigi legosignojn</string>
+    <string name="account_details">Detaloj de konto</string>
+    <string name="confirm">Konfirmi</string>
+    <string name="try_again">Provi denove</string>
+    <string name="pref_keep_foreground_service">Servo de aktiva procezado</string>
+    <string name="pref_keep_foreground_service_summary">Evitigas, ke la operaciumo devige fermas vian konekton</string>
+    <string name="pref_create_backup">Krei savkopion</string>
+    <string name="pref_create_backup_summary">Savkopioj estos konservitaj ĉe %s</string>
+    <string name="notification_create_backup_title">Kreado de savkopiaj dosieroj</string>
+    <string name="notification_backup_created_title">Via savkopio estis kreita</string>
+    <string name="notification_backup_created_subtitle">La savkopiaj dosieroj estis konervitaj ĉe %s</string>
+    <string name="restoring_backup">Restaŭrado de savkopio</string>
+    <string name="notification_restored_backup_title">Via savkopio estis restaŭrita</string>
+    <string name="notification_restored_backup_subtitle">Ne forgesu ŝalti la konton.</string>
+    <string name="receiving_x_file">Ricevado de %1$s (%2$d%% plenumita)</string>
+    <string name="download_x_file">Elŝuti “%s”</string>
+    <string name="delete_x_file">Forigi “%s”</string>
+    <string name="file">dosiero</string>
+    <string name="open_x_file">Malfermi “%s”</string>
+    <string name="sending_file">sendado (%1$d%% plenumita)</string>
+    <string name="preparing_file">Pretigado por konigado de dosiero</string>
+    <string name="x_file_offered_for_download">%s oferita por elŝuto</string>
+    <string name="cancel_transmission">Resigni transsendadon</string>
+    <string name="file_transmission_failed">ne povas konigi dosieron</string>
+    <string name="file_transmission_cancelled">transsendo de dosiero rezignita</string>
+    <string name="file_deleted">Dosiero forigita</string>
+    <string name="no_application_found_to_open_file">Neniu aplikaĵo trovita por malfermi dosieron</string>
+    <string name="no_application_found_to_open_link">Neniu aplikaĵo trovita por malfermi ligilon</string>
+    <string name="no_application_found_to_view_contact">Neniu aplikaĵo trovita por vidi kontakton</string>
+    <string name="pref_show_dynamic_tags">Dinamikaj etikedoj</string>
+    <string name="pref_show_dynamic_tags_summary">Montri neŝanĝeblajn etikedojn sub kontaktoj</string>
+    <string name="enable_notifications">Ŝalti sciigojn</string>
+    <string name="no_conference_server_found">Neniu servilo por grupaj babilejoj trovita</string>
+    <string name="account_image_description">Profilbildo de konto</string>
+    <string name="copy_omemo_clipboard_description">Kopii fingropremon OMEMO al tondujo</string>
+    <string name="regenerate_omemo_key">Regeneri ŝlosilon de OMEMO</string>
+    <string name="clear_other_devices">Vakigi aparatojn</string>
+    <string name="clear_other_devices_desc">Ĉu vi certas, ke vi volas vakigi ĉiujn aliajn aparatojn de la anonco de OMEMO? Je la sekva fojo, kiam viaj aparatoj konektos, ili reanoncos sin, sed povos ne ricevi mesaĝojn, senditajn intertempe.</string>
+    <string name="error_no_keys_to_trust_server_error">Estas neniuj uzeblaj ŝlosiloj haveblaj por tiu ĉi kontakto.\nNe povis venigi novajn ŝlosilojn el la servilo. Eble io misokazis ĉe la servilo de via kontakto?</string>
+    <string name="error_no_keys_to_trust_presence">Estas neniuj uzeblaj ŝlosiloj haveblaj por tiu ĉi kontakto.\nCertigu, ke vi ambaŭ havas ĉeesto-abonon.</string>
+    <string name="error_trustkeys_title">Io misokazis</string>
+    <string name="fetching_history_from_server">Elŝutado de historio el la servilo</string>
+    <string name="no_more_history_on_server">Neniu plu historio ĉe servilo</string>
+    <string name="updating">Ĝisdatigado…</string>
+    <string name="password_changed">Pasvorto ŝanĝita!</string>
+    <string name="could_not_change_password">Ne povis ŝanĝi pasvorton</string>
+    <string name="change_password">Ŝanĝi pasvorton</string>
+    <string name="current_password">Aktuala pasvorto</string>
+    <string name="new_password">Nova pasvorto</string>
+    <string name="password_should_not_be_empty">Pasvorto ne povas esti malplena</string>
+    <string name="enable_all_accounts">Ŝalti ĉiujn kontojn</string>
+    <string name="disable_all_accounts">Malŝalti ĉiujn kontojn</string>
+    <string name="perform_action_with">Agi per</string>
+    <string name="outcast">Eksano</string>
+    <string name="member">Ano</string>
+    <string name="remove_from_room">Forigi de grupa babilejo</string>
+    <string name="remove_from_channel">Forigi de publika babilejo</string>
+    <string name="could_not_change_affiliation">Ne povas ŝanĝi anecon de %s</string>
+    <string name="ban_now">Forbari nun</string>
+    <string name="could_not_change_role">Ne povas ŝanĝi rolon de %s</string>
+    <string name="conference_options">Agordo de privata grupa babilejo</string>
+    <string name="channel_options">Agordo de publika babilejo</string>
+    <string name="members_only">Privata, nur por anoj</string>
+    <string name="non_anonymous">Videbligi XMPP-adresojn por ĉiuj</string>
+    <string name="moderated">Igi publikan babilejon kontrolebla</string>
+    <string name="you_are_not_participating">Vi ne partoprenas</string>
+    <string name="modified_conference_options">Agordoj de babilejo ŝanĝitaj!</string>
+    <string name="could_not_modify_conference_options">Ne povas ŝanĝi agordojn de babilejo</string>
+    <string name="never">Neniam</string>
+    <string name="until_further_notice">Ĝis onta avizo</string>
+    <string name="snooze">Portempe ignori</string>
+    <string name="reply">Respondi</string>
+    <string name="mark_as_read">Marki kiel legitan</string>
+    <string name="pref_input_options">Enigo</string>
+    <string name="pref_enter_is_send">Eniga klavo sendas</string>
+    <string name="pref_enter_is_send_summary">Uzi la enigan klavon por sendi mesaĝon. Vi povas ĉiam uzi stirklavon + enigan klavon por sendi mesaĝon, eĉ se ĉi tiu agordo estas malŝaltita.</string>
+    <string name="pref_display_enter_key">Montri enigan klavon</string>
+    <string name="pref_display_enter_key_summary">Anstataŭi emoĝian klavon per eniga klavo</string>
+    <string name="audio">aŭdiaĵo</string>
+    <string name="video">videaĵo</string>
+    <string name="image">bildo</string>
+    <string name="vector_graphic">vektora grafikaĵo</string>
+    <string name="multimedia_file">plurmediaĵo</string>
+    <string name="pdf_document">PDF-dokumento</string>
+    <string name="word_document">Word-dokumento</string>
+    <string name="sheet_music">muzikaj notoj</string>
+    <string name="apk">aplikaĵo por Android</string>
+    <string name="audiobook">sonlibro</string>
+    <string name="vcard">kontakto</string>
+    <string name="avatar_has_been_published">Profilbildo publikigita!</string>
+    <string name="sending_x_file">Sendado de %s</string>
+    <string name="offering_x_file">Oferado de %s</string>
+    <string name="hide_offline">Kaŝi malkonektitajn</string>
+    <string name="contact_is_typing">%s tajpas…</string>
+    <string name="contact_has_stopped_typing">%s ĉesis tajpi</string>
+    <string name="its_time_for_contact">Estas ^1 por ^2</string>
+    <string name="contact_has_paused_their_notifications">%s paŭzis siajn sciigojn</string>
+    <string name="contacts_are_typing">%s tajpas…</string>
+    <string name="contacts_have_stopped_typing">%s ĉesis tajpi</string>
+    <string name="pref_chat_states">Sciigoj pri tajpado</string>
+    <string name="pref_chat_states_summary">Sciigi viajn kontaktulojn, kiam vi skribas mesaĝojn al ili</string>
+    <string name="show_location">Montri lokon</string>
+    <string name="no_application_found_to_display_location">Neniu aplikaĵo trovita por montrado de loko</string>
+    <string name="location">Loko</string>
+    <string name="title_undo_swipe_out_chat">Babilado arkivita</string>
+    <string name="title_undo_swipe_out_group_chat">Foriris al privata grupa babilejo</string>
+    <string name="title_undo_swipe_out_channel">Foriris al publika babilejo</string>
+    <string name="pref_dont_trust_system_cas_title">Ne fidi operaciumajn atestilojn</string>
+    <string name="pref_dont_trust_system_cas_summary">Ĉiuj atestiloj devas esti permane aprobitaj</string>
+    <string name="pref_remove_trusted_certificates_title">Forigi atestilojn</string>
+    <string name="pref_remove_trusted_certificates_summary">Forigi permane aprobitajn atestilojn</string>
+    <string name="toast_no_trusted_certs">Neniuj permane aprobitaj atestiloj</string>
+    <string name="dialog_manage_certs_title">Forigi atestilojn</string>
+    <string name="dialog_manage_certs_positivebutton">Forigi elekton</string>
+    <plurals name="toast_delete_certificates">
+        <item quantity="one">%d atestilo forigita</item>
+        <item quantity="other">%d atestiloj forigitaj</item>
+    </plurals>
+    <string name="pref_quick_action">Rapida ago</string>
+    <string name="choose_quick_action">Elektu rapidan agon</string>
+    <string name="search_contacts">Serĉi kontaktojn</string>
+    <string name="search_group_chats">Serĉi grupajn babilejojn</string>
+    <string name="send_private_message">Sendi privatan mesaĝon</string>
+    <string name="user_has_left_conference">%1$s foriris el la grupa babilejo</string>
+    <string name="username">Uzantnomo</string>
+    <string name="username_hint">Uzantnomo</string>
+    <string name="invalid_username">Ĉi tio ne estas valida uzantnomo</string>
+    <string name="download_failed_server_not_found">Elŝutado fiaskis: servilo ne trovita</string>
+    <string name="download_failed_file_not_found">Elŝutado fiaskis: dosiero ne trovita</string>
+    <string name="download_failed_could_not_connect">Elŝutado fiaskis: ne povis konekti al servilo</string>
+    <string name="download_failed_could_not_establish_tls">Ne povis starigi sekuran konekton</string>
+    <string name="download_failed_could_not_write_file">Elŝutado fiaskis: ne povis skribi dosieron</string>
+    <string name="download_failed_invalid_file">Elŝutado fiaskis: nevalida dosiero</string>
+    <string name="account_status_tor_unavailable">Tor-reto nedisponebla</string>
+    <string name="server_info_broken">Nefunkcianta</string>
+    <string name="pref_presence_settings">Ĉeesto</string>
+    <string name="pref_presence_settings_summary">“For” dum la aparato estas ŝlosita, sinkronigi kun operaciuma reĝimo “Ne interrompu”</string>
+    <string name="pref_away_when_screen_off">“For” dum la aparato estas ŝlosita</string>
+    <string name="pref_away_when_screen_off_summary">Anonci sin kiel “for” dum la aparato estas ŝlosita</string>
+    <string name="pref_show_connection_options">Nomo kaj pordo de gastiganto</string>
+    <string name="pref_show_connection_options_summary">Montri etenditajn agordojn de konekto dum agordado de konto</string>
+    <string name="hostname_example">xmpp.example.com</string>
+    <string name="action_add_account_with_certificate">Ensaluti per atestilo</string>
+    <string name="unable_to_parse_certificate">Ne povas interpreti atestilon</string>
+    <string name="mam_prefs">Agordoj de arkivado</string>
+    <string name="server_side_mam_prefs">Agordoj de ĉe-servila arkivado</string>
+    <string name="fetching_mam_prefs">Elŝutado de agordoj de arkivado. Bonvolu atendi…</string>
+    <string name="unable_to_fetch_mam_prefs">Ne povas elŝuti agordojn de arkivado</string>
+    <string name="captcha_required">CAPTCHA bezonata</string>
+    <string name="captcha_hint">Enigu tekston de la bildo supre</string>
+    <string name="certificate_chain_is_not_trusted">Nefidinda ĉeno de atestiloj</string>
+    <string name="jid_does_not_match_certificate">XMPP-adreso ne kongruas kun atestilo</string>
+    <string name="action_renew_certificate">Renovigi atestilon</string>
+    <string name="error_fetching_omemo_key">Eraro dum elŝutado de ŝlosilo de OMEMO!</string>
+    <string name="verified_omemo_key_with_certificate">OMEMO-ŝlosilo konfirmita per atestilo!</string>
+    <string name="device_does_not_support_certificates">Via aparato ne subtenas elektadon de klientaj atestiloj!</string>
+    <string name="pref_connection_options">Konektado</string>
+    <string name="pref_use_tor">Konekti per reto Tor</string>
+    <string name="pref_use_tor_summary">Tuneli ĉiujn konektojn tra la reto Tor. Postulas aplikaĵon Orbot</string>
+    <string name="account_settings_hostname">Nomo de gastiganto</string>
+    <string name="account_settings_port">Porto</string>
+    <string name="hostname_or_onion">Adreso de servilo aŭ .onion-adreso</string>
+    <string name="not_a_valid_port">Ĉi tio ne estas valida numero de pordo</string>
+    <string name="not_valid_hostname">Ĉi tio ne estas valida nomo de aparato</string>
+    <string name="connected_accounts">%1$d de %2$d kontoj konektitaj</string>
+    <plurals name="x_messages">
+        <item quantity="one">%d mesaĝo</item>
+        <item quantity="other">%d mesaĝoj</item>
+    </plurals>
+    <string name="load_more_messages">Ŝarĝi pli da mesaĝoj</string>
+    <string name="shared_file_with_x">Dosiero kunhavita kun %s</string>
+    <string name="shared_image_with_x">Bildo kunhavita kun %s</string>
+    <string name="shared_images_with_x">Bildoj kunhavitaj kun %s</string>
+    <string name="shared_text_with_x">Teksto kunhavita kun %s</string>
+    <string name="no_storage_permission">Permesi al %1$s atingi eksteran memoron</string>
+    <string name="no_camera_permission">Permesu al %1$s atingi eksteran memoron</string>
+    <string name="no_contacts_permission">%1$s bezonas atingon al via kontaktlisto</string>
+    <string name="quicksy_wants_your_consent">Quicksy petas vin por permeso uzi viajn datumojn</string>
+    <string name="sync_with_contacts">Integrado de kontaktlistoj</string>
+    <string name="sync_with_contacts_long">%1$s traktas vian kontaktliston loke, ĉe via aparato, por montri al vi nomojn kaj profilbildojn por kongruantaj kontaktoj sur XMPP..\n\nNeniuj datumoj de via kontaktlisto estas senditaj ekster!</string>
+    <string name="notify_on_all_messages">Sciigi por ĉiuj mesaĝoj</string>
+    <string name="notify_only_when_highlighted">Sciigi nur por mencioj</string>
+    <string name="notify_never">Sciigi neniam</string>
+    <string name="notify_paused">Sciigoj paŭzitaj</string>
+    <string name="pref_picture_compression">Densigado de bildoj</string>
+    <string name="pref_picture_compression_summary">Sugesto: uzu ‘Elekti dosieron’ anstataŭ ‘Elekti bildon’ por sendi unuopajn bildojn sen densigo, malgraŭ tiu ĉi agordo.</string>
+    <string name="always">Ĉiam</string>
+    <string name="large_images_only">Nur grandaj bildoj</string>
+    <string name="battery_optimizations_enabled">Optimumigo de baterio ŝaltita</string>
+    <string name="battery_optimizations_enabled_explained">Via aparato aplikas fortan optimumigon de baterio-uzado por %1$s, kiu povas kaŭzi malfruigon de sciigoj aŭ eĉ perdigon de mesaĝoj.\nEstas rekomendita malŝalti ĝin.</string>
+    <string name="battery_optimizations_enabled_dialog">Via aparato aplikas fortan optimumigon de baterio-uzado por %1$s, kiu povas kaŭzi malfruigon de sciigoj aŭ eĉ perdigon de mesaĝoj.\n\nLa aplikaĵo nun petos vin malŝalti ĝin.</string>
+    <string name="disable">Malŝalti</string>
+    <string name="selection_too_large">Elektita areo estas tro granda</string>
+    <string name="no_accounts">(neniuj aktivaj kontoj)</string>
+    <string name="this_field_is_required">Ĉi tiu enigokampo estas bezonata</string>
+    <string name="correct_message">Redakti</string>
+    <string name="send_corrected_message">Sendi redaktitan mesaĝon</string>
+    <string name="no_keys_just_confirm">Vi jam fidis la fingropremon de ĉi tiu persono. Elektante “Farita”, vi nur konfirmas, ke %s estas ano de tiu ĉi grupa babilejo.</string>
+    <string name="this_account_is_disabled">Vi malŝaltis tiun ĉi konton</string>
+    <string name="this_account_is_logged_out">Ĉi tiu konto ne estas salutita</string>
+    <string name="security_error_invalid_file_access">Eraro de sekureco: nevalida atingo de dosiero!</string>
+    <string name="no_application_to_share_uri">Neniu aplikaĵo trovita por konigi URI-on</string>
+    <string name="share_uri_with">Konigi URI-on kun…</string>
+    <string name="welcome_header">Aliĝu al la Konversacio</string>
+    <string name="welcome_header_quicksy">Bonvenon al Quicksy!</string>
+    <string name="agree_and_continue">Konsenti kaj daŭrigi</string>
+    <string name="magic_create_text">Ni helpos vin krei konton ĉe la servilo conversations.im.\nUzante conservations.im kiel provizilo, vi povos komuniki kun uzantoj de aliaj proviziloj, sendante al ili vian plenan XMPP-adreson.</string>
+    <string name="your_full_jid_will_be">Via plena XMPP-adreso estos: %s</string>
+    <string name="create_account">Krei konton</string>
+    <string name="use_own_provider">Uzi mian propran provizilon</string>
+    <string name="pick_your_username">Elektu vian uzantnomon</string>
+    <string name="pref_manually_change_presence">Kontroli ĉeeston permane</string>
+    <string name="pref_manually_change_presence_summary">Agordu vian ĉeeston dum redaktado de via statusa mesaĝo.</string>
+    <string name="status_message">Statusa mesaĝo</string>
+    <string name="presence_chat">Libera por babilado</string>
+    <string name="presence_online">Konektita</string>
+    <string name="presence_away">For</string>
+    <string name="presence_xa">Okupata</string>
+    <string name="presence_dnd">Ne interrompu</string>
+    <string name="presence_offline">Nekonektita</string>
+    <string name="secure_password_generated">Sekura pasvorto estis generita</string>
+    <string name="device_does_not_support_battery_op">Via aparato ne subtenas malŝalton de bateria optimumigo</string>
+    <string name="registration_please_wait">Registrado fiaskis: Klopodu denove poste</string>
+    <string name="registration_password_too_weak">Registrado fiaskis: Pasvorto tro malforta</string>
+    <string name="choose_participants">Elektu partoprenantojn</string>
+    <string name="creating_conference">Kreado de grupa babilejo…</string>
+    <string name="invite_again">Inviti denove</string>
+    <string name="gp_disable">Malŝalti</string>
+    <string name="gp_short">Mallonga</string>
+    <string name="gp_medium">Meza</string>
+    <string name="gp_long">Longa</string>
+    <string name="pref_broadcast_last_activity">Montri lastan aktivtempon</string>
+    <string name="pref_broadcast_last_activity_summary">Permesi al viaj kontaktuloj vidi la lasta tempo, kiam vi aktive uzis la aplikaĵon</string>
+    <string name="pref_privacy">Privateco</string>
+    <string name="pref_theme_options">Etoso</string>
+    <string name="pref_theme_options_summary">Elektu la koloraron</string>
+    <string name="pref_theme_automatic">Aŭtomata</string>
+    <string name="pref_theme_light">Hela</string>
+    <string name="pref_theme_dark">Malhela</string>
+    <string name="pref_use_colorful_bubbles">Koloraj vezikoj de babilado</string>
+    <string name="pref_use_colorful_bubbles_summary">Uzi distingajn kolorojn de fono por senditaj kaj ricevitaj mesaĝoj</string>
+    <string name="unable_to_connect_to_keychain">Ne povas konekti al la aplikaĵo OpenKeychain</string>
+    <string name="this_device_is_no_longer_in_use">Ĉi tiu aparato ne estas uzata plu</string>
+    <string name="type_pc">Komputilo</string>
+    <string name="type_phone">Poŝtelefono</string>
+    <string name="type_tablet">Tabula komputilo</string>
+    <string name="type_web">Retumilo</string>
+    <string name="type_console">Konzolo</string>
+    <string name="payment_required">Pago bezonata</string>
+    <string name="reconnect_on_other_host">Rekonekti ĉe alia servilo</string>
+    <string name="missing_internet_permission">Permeso por uzi interreton bezonata</string>
+    <string name="me">Mi</string>
+    <string name="contact_asks_for_presence_subscription">Kontakto petas por abono de ĉeesto</string>
+    <string name="allow">Permesi</string>
+    <string name="no_permission_to_place_call">Neniu permeso por fari telefonvokon</string>
+    <string name="no_permission_to_access_x">Neniu permeso por atingo al %s</string>
+    <string name="remote_server_not_found">Fora servilo ne trovita</string>
+    <string name="remote_server_timeout">Fora servilo eltempiĝis</string>
+    <string name="unable_to_update_account">Ne povas ĝisdatigi konton</string>
+    <string name="report_jid_as_spammer">Raporti ĉi tiun XMPP-adreson kiel spamiston.</string>
+    <string name="pref_delete_omemo_identities">Forigi identecojn de OMEMO</string>
+    <string name="pref_delete_omemo_identities_summary">Regeneri viajn ŝlosilojn de OMEMO. Ĉiuj viaj kontaktuloj devas denove vin konfirmi. Nur uzu ĝin kiel lasta opcio.</string>
+    <string name="delete_selected_keys">Forigi elektitajn ŝlosilojn</string>
+    <string name="error_publish_avatar_offline">Vi devas esti konektita por publikigi vian profilbildon.</string>
+    <string name="show_error_message">Montri eraro-mesaĝon</string>
+    <string name="error_message">Eraro-mesaĝo</string>
+    <string name="data_saver_enabled">Datumoŝparilo ŝaltita</string>
+    <string name="data_saver_enabled_explained">Via operaciumo preventas al %1$s atingi la interreton dum neaktiveco. Por ricevi sciigojn de novaj mesaĝoj, vi devas permesi al %1$s nerestriktitan atingon en datumoŝparila reĝimo.\n%1$s malgraŭe klopodos ŝpari uzadon de interretaj datumoj.</string>
+    <string name="device_does_not_support_data_saver">Via aparato ne subtenas malŝaltadon de datumŝparila reĝimo por %1$s.</string>
+    <string name="error_unable_to_create_temporary_file">Ne povas krei portempan dosieron</string>
+    <string name="this_device_has_been_verified">Ĉi tiu aparato estis konfirmita</string>
+    <string name="copy_fingerprint">Kopii fingropremon</string>
+    <string name="all_omemo_keys_have_been_verified">Vi konfirmis ĉiujn OMEMO-ŝlosilojn en via posedo</string>
+    <string name="barcode_does_not_contain_fingerprints_for_this_chat">La QR-kodo ne enhavas fingropremojn por tiu ĉi babilejo.</string>
+    <string name="verified_fingerprints">Konfirmitaj fingropremoj</string>
+    <string name="pref_autojoin_summary">Ŝalti flagon “autojoin” (aŭtomata kunligado) dum enirado al aŭ elirado de publika babilejo, kaj reagi al modifoj, faritaj de aliaj klientoj.</string>
+    <string name="toast_message_omemo_fingerprint">Fingropremo OMEMO kopiita al tondujo</string>
+    <string name="conference_banned">Vi estas forbarita de tiu ĉi grupa babilejo</string>
+    <string name="conference_members_only">Tiu ĉi grupa babilejo estas nur por membroj</string>
+    <string name="conference_kicked">Vi estis forpelita de tiu ĉi grupa babilejo</string>
+    <string name="conference_shutdown">La grupa babilejo estis fermita</string>
+    <string name="conference_unknown_error">Vi ne estas plu en tiu ĉi grupa babilejo</string>
+    <string name="conference_technical_problems">Vi eliris de tiu ĉi grupa babilejo pro teknikaj kialoj</string>
+    <string name="using_account">per konto %s</string>
+    <string name="hosted_on">gastigita ĉe %s</string>
+    <string name="checking_x">Kontrolado de %s sur HTTP-gastiganto</string>
+    <string name="not_connected_try_again">Vi ne estas konektita. Provu denove poste</string>
+    <string name="check_x_filesize">Kontroli grandon de %s</string>
+    <string name="check_x_filesize_on_host">Kontroli grandon de %1$s sur %2$s</string>
+    <string name="message_options">Agordoj de mesaĝoj</string>
+    <string name="quote">Citi</string>
+    <string name="paste_as_quote">Alglui kiel citaĵo</string>
+    <string name="copy_original_url">Kopii originalan retadreson</string>
+    <string name="send_again">Sendi denove</string>
+    <string name="retry_with_p2p">Reprovi samtavole</string>
+    <string name="file_url">Retadreso de dosiero</string>
+    <string name="url_copied_to_clipboard">Retadreso kopiita al tondujo</string>
+    <string name="uri_copied_to_clipboard">URI kopiita al tondujo</string>
+    <string name="uri">URI</string>
+    <string name="jabber_id_copied_to_clipboard">XMPP-adreso kopiita al tondujo</string>
+    <string name="error_message_copied_to_clipboard">Erarmesaĝo kopiita al tondujo</string>
+    <string name="web_address">reta adreso</string>
+    <string name="scan_qr_code">Skani QR-kodon</string>
+    <string name="show_qr_code">Montri QR-kodon</string>
+    <string name="show_block_list">Montri baritliston</string>
+    <string name="conference_creation_failed">Ne povis krei grupan babilejon</string>
+    <string name="account_status_host_unknown">Ne responsa por domajno</string>
+    <string name="use_camera_icon_to_scan_barcode">Uzu kameraon por skanado de QR-kodo de kontaktulo</string>
+    <string name="please_wait_for_keys_to_be_fetched">Bonvolu atendi por elŝutado de ŝlosiloj</string>
+    <string name="share_as_uri">Konigi XMPP-retadreson</string>
+    <string name="share_invitation">Konigi inviton</string>
+    <string name="pref_blind_trust_before_verification">Blinde fidi antaŭ konfirmado</string>
+    <string name="pref_blind_trust_before_verification_summary">Fidi novajn aparatojn de nekonformitaj kontaktoj, sed peti por permana konfirmo por konformitaj kontaktoj.</string>
+    <string name="blindly_trusted_omemo_keys">OMEMO-ŝlosiloj blinde fiditaj. Ĉi tio signifas, ke la kontakto povas esti iun alian, aŭ iu interkaptas la babiladon.</string>
+    <string name="not_trusted">Nefidita</string>
+    <string name="invalid_barcode">Nevalida QR-kodo</string>
+    <string name="pref_clean_cache_summary">Vakigi dosierujon de kaŝmemoro (uzata de kamerao-aplikaĵo)</string>
+    <string name="pref_clean_cache">Vakigi kaŝmemoron</string>
+    <string name="pref_clean_private_storage">Vakigi privatan memoron</string>
+    <string name="pref_clean_private_storage_summary">Vakigi privatan memoron, en kiu dosieroj estas konservataj (ili povas esti reelŝutitaj al la servilo)</string>
+    <string name="i_followed_this_link_from_a_trusted_source">Mi sekvis ĉi tiun ligilon de fidinda fonto</string>
+    <string name="verifying_omemo_keys_trusted_source">Post alklakado sur la ligilo, vi konfirmos la OMEMO-ŝlosilojn de %1$s. Ĉi tio estas sekura nur, se vi sekvis la ligilon de fidinda fonto, en kiu nur %2$s povis publikigi la ligilon.</string>
+    <string name="verifying_omemo_keys_trusted_source_account">Post alklakado sur la ligilo, vi konfirmos OMEMO-ŝlosilojn de via konto. Ĉi tio estas sekura nur, se vi sekvis la ligilon de fidinda fonto, en kiu nur vi povis publikigi la ligilon.</string>
+    <string name="continue_btn">Daŭrigi</string>
+    <string name="verify_omemo_keys">Kontroli OMEMO-ŝlosilojn</string>
+    <string name="show_inactive_devices">Montri neaktivajn</string>
+    <string name="hide_inactive_devices">Kaŝi neaktivajn</string>
+    <string name="distrust_omemo_key">Malfidi aparaton</string>
+    <string name="distrust_omemo_key_text">Ĉu vi certas, ke vi volas forigi la konfirmon de ĉi tiu aparato?\nĈi tiu aparato, kaj mesaĝojn de ĝi, estos markitaj, kiel nefidindaj.</string>
+    <plurals name="seconds">
+        <item quantity="one">%d sekundo</item>
+        <item quantity="other">%d sekundoj</item>
+    </plurals>
+    <plurals name="minutes">
+        <item quantity="one">%d minuto</item>
+        <item quantity="other">%d minutoj</item>
+    </plurals>
+    <plurals name="hours">
+        <item quantity="one">%d horo</item>
+        <item quantity="other">%d horoj</item>
+    </plurals>
+    <plurals name="days">
+        <item quantity="one">%d tago</item>
+        <item quantity="other">%d tagoj</item>
+    </plurals>
+    <plurals name="weeks">
+        <item quantity="one">%d semajno</item>
+        <item quantity="other">%d semajnoj</item>
+    </plurals>
+    <plurals name="months">
+        <item quantity="one">%d monato</item>
+        <item quantity="other">%d monatoj</item>
+    </plurals>
+    <string name="pref_automatically_delete_messages">Aŭtomata forigo de mesaĝoj</string>
+    <string name="pref_automatically_delete_messages_description">Aŭtomate forigi mesaĝojn de ĉi tiu aparato, kiuj estas pli malnova ol specifa daŭro.</string>
+    <string name="encrypting_message">Ĉifrado de mesaĝo</string>
+    <string name="not_fetching_history_retention_period">Mesaĝoj ne elŝutataj pro loka periodo de konservado.</string>
+    <string name="transcoding_video">Densigado de videaĵo</string>
+    <string name="contact_blocked_past_tense">Kontakto blokita.</string>
+    <string name="pref_notifications_from_strangers">Sciigoj de nekonatuloj</string>
+    <string name="pref_notifications_from_strangers_summary">Sciigi pri mesaĝoj kaj vokoj, ricevitaj de nekonataj kontaktoj.</string>
+    <string name="received_message_from_stranger">Ricevita mesaĝo de nekonatulo</string>
+    <string name="block_stranger">Bloki nekonatulon</string>
+    <string name="block_entire_domain">Bloki la tutan domajnon</string>
+    <string name="online_right_now">nun enreta</string>
+    <string name="retry_decryption">Reprovi malĉifradon</string>
+    <string name="session_failure">Eraro de seanco</string>
+    <string name="sasl_downgrade">Mekanismo de SASL malplibonigita</string>
+    <string name="account_status_regis_web">Servilo bezonas registradon sur retejo</string>
+    <string name="open_website">Malfermi retejon</string>
+    <string name="application_found_to_open_website">Neniu aplikaĵo povas malfermi retejon</string>
+    <string name="today">Hodiaŭ</string>
+    <string name="yesterday">Hieraŭ</string>
+    <string name="pref_validate_hostname">Konfirmi nomon de servilo per DNSSEC</string>
+    <string name="pref_validate_hostname_summary">Servilaj atestiloj, kiuj enhavas konfirmitan servilan nomon, estas taskitaj kiel konfirmitaj</string>
+    <string name="certificate_does_not_contain_jid">Atestilo ne enhavas XMPP-adreson</string>
+    <string name="server_info_partial">parta</string>
+    <string name="copy_to_clipboard">Kopii</string>
+    <string name="message_copied_to_clipboard">Mesaĝo kopiita al tondujo</string>
+    <string name="message">Mesaĝo</string>
+    <string name="private_messages_are_disabled">Privataj mesaĝoj estas malebligitaj</string>
+    <string name="mtm_accept_cert">Ĉu akcepti nekonatan atestilon?</string>
+    <string name="mtm_trust_anchor">Ĉi tiu atestilo ne estas subskribita de iu konata atestila aŭtoritato.</string>
+    <string name="mtm_accept_servername">Ĉu akcepti nekongruan nomon de servilo?</string>
+    <string name="mtm_hostname_mismatch">La servilo ne povas aŭtentigi kiel \"%s\". La atestilo nur validas por:</string>
+    <string name="mtm_connect_anyway">Ĉu konekti malgraŭ?</string>
+    <string name="mtm_cert_details">Detaloj de atestilo:</string>
+    <string name="once">Unufoje</string>
+    <string name="qr_code_scanner_needs_access_to_camera">La skanilo de QR-kodoj bezonas atingon al la kamerao</string>
+    <string name="pref_scroll_to_bottom">Rulumi malsupren</string>
+    <string name="pref_scroll_to_bottom_summary">Rulumi malsupren post sendado de mesaĝo</string>
+    <string name="edit_status_message_title">Redakti statusan mesaĝon</string>
+    <string name="edit_status_message">Redakti statusan mesaĝon</string>
+    <string name="disable_encryption">Malŝalti ĉifradon</string>
+    <string name="error_trustkey_general">%1$s ne povas sendi ĉifritajn mesaĝojn al %2$s. Ebla kialo estas, ke via kontaktulo uzas malnovan servilon aŭ klienton, kiu ne subtenas protokolon OMEMO.</string>
+    <string name="error_trustkey_device_list">Ne povas ricevi liston de aparatoj</string>
+    <string name="error_trustkey_bundle">Ne povas ricevi ĉifradajn ŝlosilojn</string>
+    <string name="error_trustkey_hint_mutual">Konsilo: en iun okazoj, ĉi tiu problemo povas esti korektita, se ambaŭ kontaktoj aldonas unu la alian en la kontaktlisto.</string>
+    <string name="disable_encryption_message">Ĉu vi certas, ke vi volas malŝalti ĉifradon per OMEMO por ĉi tiu babilado?\nĈi tio ebligas al la administranto de la servilo legi viajn mesaĝojn, sed povas esti la solan metodon por kontakto al homoj, kiuj uzas malnovajn klientojn.</string>
+    <string name="disable_now">Malŝalti nun</string>
+    <string name="draft">Malneto:</string>
+    <string name="pref_omemo_setting">OMEMO-ĉifrado</string>
+    <string name="pref_omemo_setting_summary_always">OMEMO estos ĉiam uzata por personaj kaj privataj grupaj babiladoj.</string>
+    <string name="pref_omemo_setting_summary_default_on">OMEMO estos ĉiam uzata por novaj babiladoj.</string>
+    <string name="pref_omemo_setting_summary_default_off">OMEMO devos esti permane ŝaltita por novaj babiladoj.</string>
+    <string name="create_shortcut">Krei ŝparvojon</string>
+    <string name="default_on">Ŝaltita defaŭlte</string>
+    <string name="default_off">Malŝaltita defaŭlte</string>
+    <string name="pref_dynamic_colors">Dinamikaj koloroj</string>
+    <string name="pref_dynamic_colors_summary">Operaciumaj koloroj (Material You)</string>
+    <string name="not_encrypted_for_this_device">Mesaĝo ne estis ĉifrita por ĉi tiu aparato.</string>
+    <string name="omemo_decryption_failed">Malĉifrado de OMEMO-mesaĝo malsukcesis.</string>
+    <string name="undo">malfari</string>
+    <string name="location_disabled">Konigado de loko estas malŝaltita</string>
+    <string name="action_fix_to_location">Fiksi lokon</string>
+    <string name="action_unfix_from_location">Malfiksi lokon</string>
+    <string name="action_copy_location">Kopii lokon</string>
+    <string name="action_share_location">Konigi lokon</string>
+    <string name="title_activity_share_location">Konigi lokon</string>
+    <string name="action_directions">Direktoj</string>
+    <string name="title_activity_show_location">Montri lokon</string>
+    <string name="share">Konigi</string>
+    <string name="unable_to_start_recording">Ne povas komenci registradon</string>
+    <string name="please_wait">Atendu…</string>
+    <string name="no_microphone_permission">Permesu al %1$s atingo al mikrofono</string>
+    <string name="search_messages">Serĉi mesaĝojn</string>
+    <string name="view_conversation">Vidi babiladon</string>
+    <string name="pref_use_share_location_plugin">Etendaĵo por konigado de loko</string>
+    <string name="gif">GIF</string>
+    <string name="copy_geo_uri">Kopii geolokon</string>
+    <string name="copy_telephone_number">Kopii telefonnumeron</string>
+    <string name="copy_jabber_id">Kopii XMPP-adreson</string>
+    <string name="copy_email_address">Kopii retpoŝtadreson</string>
+    <string name="pref_start_search">Direkta serĉo</string>
+    <string name="pref_headsup_notifications">Sur-ekranaj sciigoj</string>
+    <string name="pref_headsup_notifications_summary">Montri sur-ekranajn sciigojn</string>
+    <string name="pref_use_share_location_plugin_summary">Uzi etendaĵon por konigado de loko anstataŭ integrita mapo</string>
+    <string name="copy_link">Kopii retejan adreson</string>
+    <string name="copy_URI">Kopii retadreson</string>
+    <string name="copied_email_address">Retpoŝta adreso kopiita al tondujo</string>
+    <string name="copied_phone_number">Telefonnumero kopiita al tondujo</string>
+    <string name="p1_s3_filetransfer">Konigado de dosieroj per HTTP por S3-memoro</string>
+    <string name="pref_start_search_summary">En la ekrano de nova babilado, malfermi klavaron kaj meti kursoron en la serĉokampon</string>
+    <string name="group_chat_avatar">Profilbildo de grupa babilado</string>
+    <string name="host_does_not_support_group_chat_avatars">Servilo ne subtenas profilbildojn de grupaj babiladoj</string>
+    <string name="contact_name">Nomo de kontakto</string>
+    <string name="nickname">Kromnomo</string>
+    <string name="group_chat_name">Nomo</string>
+    <string name="providing_a_name_is_optional">Provizado de nomo estas laŭvola</string>
+    <string name="create_dialog_group_chat_name">Nomo de grupa babilado</string>
+    <string name="conference_destroyed">Ĉi tiu grupa babilado estis detruita</string>
+    <string name="unable_to_save_recording">Ne povas konservi registraĵon</string>
+    <string name="foreground_service_channel_name">Procezo de aktiva rulado</string>
+    <string name="foreground_service_channel_description">Ĉi tiu kategorio estas uzata por montri konstantan sciigon, indikantan, ke %1$s funkcias.</string>
+    <string name="notification_group_status_information">Statusaj informoj</string>
+    <string name="error_channel_name">Problemoj de konekto</string>
+    <string name="error_channel_description">Ĉi tiu kategorio estas uzata por montri sciigon, se estas problemo dum konektado de konto.</string>
+    <string name="notification_group_messages">Mesaĝoj</string>
+    <string name="notification_group_calls">Vokoj</string>
+    <string name="messages_channel_name">Mesaĝoj</string>
+    <string name="incoming_calls_channel_name">Envenantaj vokoj</string>
+    <string name="ongoing_calls_channel_name">Alvenantaj vokoj</string>
+    <string name="missed_calls_channel_name">Preterpasitaj vokoj</string>
+    <string name="silent_messages_channel_name">Silentaj mesaĝoj</string>
+    <string name="silent_messages_channel_description">Ĉi tiu kategorio estas uzata por montri sensonajn sciigojn. Ekzemple, dum aktiveco sur alia aparato (preventado de troaj sciigoj).</string>
+    <string name="delivery_failed_channel_name">Fiaskaj liveradoj</string>
+    <string name="pref_message_notification_settings">Agordoj de sciigoj pri mesaĝoj</string>
+    <string name="pref_incoming_call_notification_settings">Agordoj de sciigoj pri envenaj vokoj</string>
+    <string name="pref_more_notification_settings_summary">Graveco, sono, vibrado</string>
+    <string name="video_compression_channel_name">Densigado de videaĵoj</string>
+    <string name="view_media">Vidi aŭdvidaĵon</string>
+    <string name="group_chat_members">Partoprenantoj</string>
+    <string name="media_browser">Foliumilo de aŭdvidaĵoj</string>
+    <string name="security_violation_not_attaching_file">Dosiero ignorita pro sekurecaj problemoj.</string>
+    <string name="pref_video_compression">Kvalito de videaĵo</string>
+    <string name="pref_video_compression_summary">Ju malalta kvalito, des malgrandaj dosieroj</string>
+    <string name="video_360p">Malalta (360p)</string>
+    <string name="video_480p">Meza (480p)</string>
+    <string name="video_720p">Alta (720p)</string>
+    <string name="video_1080p">Tre alta (1080p)</string>
+    <string name="cancelled">revokita</string>
+    <string name="already_drafting_message">Vi ankoraŭ skribas malnetan mesaĝon.</string>
+    <string name="feature_not_implemented">Funkcio ne realigita</string>
+    <string name="invalid_country_code">Nevalida landokodo</string>
+    <string name="choose_a_country">Elektu landon</string>
+    <string name="phone_number">telefonnumero</string>
+    <string name="verify_your_phone_number">Konfirmi vian telefonnumeron</string>
+    <string name="enable_account_to_delete">Ĉi tiu konto estas malŝaltita. Ŝaltu ĝin por forigi ĝin de la servilo.</string>
+    <string name="make_call">Alvoki</string>
+    <string name="rtp_state_incoming_call">Alvenanta voko</string>
+    <string name="rtp_state_incoming_video_call">Alvenanta videovoko</string>
+    <string name="rtp_state_content_add_video">Ĉu ŝanĝi al videovoko?</string>
+    <string name="rtp_state_content_add">Ĉu aldoni la pliajn aŭdiotrakojn?</string>
+    <string name="rtp_state_connecting">Konektado</string>
+    <string name="rtp_state_connected">Konektita</string>
+    <string name="rtp_state_reconnecting">Rekonektado</string>
+    <string name="rtp_state_accepting_call">Akceptado de voko</string>
+    <string name="rtp_state_ending_call">Finigado de voko</string>
+    <string name="answer_call">Respondi</string>
+    <string name="dismiss_call">Malakcepti</string>
+    <string name="rtp_state_finding_device">Malkovrado de aparatoj</string>
+    <string name="rtp_state_ringing">Sonorado</string>
+    <string name="rtp_state_declined_or_busy">Linio okupata</string>
+    <string name="rtp_state_contact_offline">Kontaktulo nedisponebla</string>
+    <string name="rtp_state_connectivity_error">Ne povis konekti vokon</string>
+    <string name="rtp_state_no_internet">Neniu interreta konekto</string>
+    <string name="rtp_state_connectivity_lost_error">Konekto perdita</string>
+    <string name="rtp_state_retracted">Voko revokita</string>
+    <string name="rtp_state_application_failure">Fiasko de aplikaĵo</string>
+    <string name="rtp_state_security_error">Problemo de konfirmado</string>
+    <string name="hang_up">Fini</string>
+    <string name="ongoing_call">Elvenanta voko</string>
+    <string name="ongoing_video_call">Aktiva videovoko</string>
+    <string name="reconnecting_call">Rekonektado al voko</string>
+    <string name="reconnecting_video_call">Rekonektado al videovoko</string>
+    <string name="disable_tor_to_make_call">Malŝalti uzadon de Tor por fari vokojn</string>
+    <string name="incoming_call">Alvenanta voko</string>
+    <string name="incoming_call_duration_timestamp">Alvenanta voko (%s) · %s</string>
+    <string name="outgoing_call_duration_timestamp">Elvenanta voko (%s) · %

[diff truncated — too large to display]

Built with passion for open-source software, XMPP, privacy, and security —
the belief that people deserve communication tools that serve them, not surveil them.

Dedicated to every brave man and woman who stands up against tyranny and oppression across the world.
And in memory of those who were silenced before they could speak freely.