From 64ef6565442bc2c2d93df119bc27540c16b12775 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Tue, 10 Mar 2026 22:57:38 +0100 Subject: [PATCH 01/27] setup spotless for code formatting --- .github/workflows/code-format.yml | 35 +++++++++++++++++++++++++++++++ CONTRIBUTING.md | 17 +++++++-------- build.gradle | 26 +++++++++++++++++++++++ settings.gradle | 1 + 4 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/code-format.yml diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml new file mode 100644 index 000000000..60980bae1 --- /dev/null +++ b/.github/workflows/code-format.yml @@ -0,0 +1,35 @@ +name: Check code formatting + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + spotless: + name: Check Java formatting (Spotless) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-java@v5 + with: + java-version: 17 + distribution: temurin + - uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + - name: Check formatting + run: ./gradlew spotlessCheck diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49414fe1b..6d33e5aba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,18 +73,15 @@ esp. before/after screenshots. ### Coding Conventions -Source files are partly derived from different other open source projects -and may follow different coding styles and conventions. - -If you do a PR fixing a bug or adding a feature, -please embrace the coding convention you see in the corresponding files, -so that the result fits well together. - -Do not refactor or rename things in the same PR -to make the diff small and the PR easy to review. - Project language is Java. +Code formatting is enforced via [Spotless](https://github.com/diffplug/spotless) Gradle plugin. +Auto-format all files by running `./gradlew spotlessApply` before opening a PR. +CI will fail if files are not formatted correctly so make sure to run the formatter before pushing. + +If you do a PR fixing a bug or adding a feature, do not refactor or rename things in the same PR +to make the diff small and the PR easy to review. + By using [Delta Chat Core](https://github.com/deltachat/deltachat-core-rust) there is already a strong separation between "UI" and "Model". Further separations and abstraction layers are often not helpful diff --git a/build.gradle b/build.gradle index 66116c4e1..0e0d19854 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' version '8.11.1' id 'com.google.gms.google-services' version '4.4.1' + id 'com.diffplug.spotless' version '8.3.0' } repositories { @@ -151,6 +152,31 @@ android { } +spotless { + java { + target 'src/*/java/**/*.java' + targetExclude 'src/main/java/chat/delta/**' // ignore auto-generated code + googleJavaFormat() // Google style = 2-space indent, matches Android Studio defaults + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } + format 'xml', { + target 'src/*/res/**/*.xml', 'src/*/AndroidManifest.xml' + eclipseWtp('xml') + leadingTabsToSpaces(2) + trimTrailingWhitespace() + endWithNewline() + } + groovy { + target '*.gradle' + greclipse() + leadingTabsToSpaces(2) + trimTrailingWhitespace() + endWithNewline() + } +} + dependencies { def media3_version = "1.8.0" // 1.9.0 need minSdkVersion 23 diff --git a/settings.gradle b/settings.gradle index 8aeca1ea1..492070dea 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ pluginManagement { repositories { + gradlePluginPortal() google() mavenCentral() } From 69e18a009b4dbf736eb56d257211601c7fee6952 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Tue, 10 Mar 2026 23:11:04 +0100 Subject: [PATCH 02/27] use 4 spaces for xml and .gradle files --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index 0e0d19854..1230ca232 100644 --- a/build.gradle +++ b/build.gradle @@ -164,14 +164,14 @@ spotless { format 'xml', { target 'src/*/res/**/*.xml', 'src/*/AndroidManifest.xml' eclipseWtp('xml') - leadingTabsToSpaces(2) + leadingTabsToSpaces(4) trimTrailingWhitespace() endWithNewline() } groovy { target '*.gradle' greclipse() - leadingTabsToSpaces(2) + leadingTabsToSpaces(4) trimTrailingWhitespace() endWithNewline() } From 16a02a5e4f9180d17dc35f401cdf3550d3aaf740 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 11 Mar 2026 15:58:42 +0100 Subject: [PATCH 03/27] add spotless/eclipse-wtp-xml.prefs --- build.gradle | 3 +-- spotless/eclipse-wtp-xml.prefs | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 spotless/eclipse-wtp-xml.prefs diff --git a/build.gradle b/build.gradle index 1230ca232..b6cd32858 100644 --- a/build.gradle +++ b/build.gradle @@ -163,8 +163,7 @@ spotless { } format 'xml', { target 'src/*/res/**/*.xml', 'src/*/AndroidManifest.xml' - eclipseWtp('xml') - leadingTabsToSpaces(4) + eclipseWtp('xml').configFile('spotless/eclipse-wtp-xml.prefs') trimTrailingWhitespace() endWithNewline() } diff --git a/spotless/eclipse-wtp-xml.prefs b/spotless/eclipse-wtp-xml.prefs new file mode 100644 index 000000000..c4d2608d0 --- /dev/null +++ b/spotless/eclipse-wtp-xml.prefs @@ -0,0 +1,6 @@ +eclipse.preferences.version=1 +lineWidth=100 +splitMultiAttrs=true +indentMultipleAttributes=true +indentationChar=space +indentationSize=4 From b878ed4df3e66b48b4607862073c0f77a313367b Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 11 Mar 2026 16:06:15 +0100 Subject: [PATCH 04/27] simplify workflows --- .github/workflows/code-format.yml | 35 ------------------------------- .github/workflows/preview-apk.yml | 19 +++++++++++------ 2 files changed, 13 insertions(+), 41 deletions(-) delete mode 100644 .github/workflows/code-format.yml diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml deleted file mode 100644 index 60980bae1..000000000 --- a/.github/workflows/code-format.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Check code formatting - -on: - push: - branches: [main] - pull_request: - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} - -jobs: - spotless: - name: Check Java formatting (Spotless) - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-java@v5 - with: - java-version: 17 - distribution: temurin - - uses: actions/cache@v4 - with: - path: | - ~/.gradle/caches - ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} - restore-keys: | - ${{ runner.os }}-gradle- - - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@v4 - - name: Check formatting - run: ./gradlew spotlessCheck diff --git a/.github/workflows/preview-apk.yml b/.github/workflows/preview-apk.yml index a5572ed81..d921eea0f 100644 --- a/.github/workflows/preview-apk.yml +++ b/.github/workflows/preview-apk.yml @@ -14,14 +14,12 @@ jobs: - uses: actions/checkout@v5 with: submodules: recursive - - uses: Swatinem/rust-cache@v2 - with: - working-directory: jni/deltachat-core-rust + - uses: actions/setup-java@v5 with: java-version: 17 distribution: 'temurin' - - uses: android-actions/setup-android@v3 + - uses: actions/cache@v4 with: path: | @@ -30,13 +28,22 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- + + - name: Validate Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Check code format + run: ./gradlew spotlessCheck + + - uses: android-actions/setup-android@v3 - uses: nttld/setup-ndk@v1 id: setup-ndk with: ndk-version: r27 - - name: Validate Gradle Wrapper - uses: gradle/actions/wrapper-validation@v4 + - uses: Swatinem/rust-cache@v2 + with: + working-directory: jni/deltachat-core-rust - name: Compile core env: From 08643d3389f9cded122e210fb4ea328f8587d9ce Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 11 Mar 2026 17:18:05 +0100 Subject: [PATCH 05/27] fix indentation --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index b6cd32858..0377119a8 100644 --- a/build.gradle +++ b/build.gradle @@ -167,7 +167,7 @@ spotless { trimTrailingWhitespace() endWithNewline() } - groovy { + groovy { target '*.gradle' greclipse() leadingTabsToSpaces(4) From abff1c0c55816d0d2ceb68e879e75fbcf2144456 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 11 Mar 2026 17:47:10 +0100 Subject: [PATCH 06/27] cache compiled core --- .github/workflows/preview-apk.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/preview-apk.yml b/.github/workflows/preview-apk.yml index d921eea0f..ba1090dc2 100644 --- a/.github/workflows/preview-apk.yml +++ b/.github/workflows/preview-apk.yml @@ -45,6 +45,19 @@ jobs: with: working-directory: jni/deltachat-core-rust + - name: Get deltachat-core-rust submodule hash + id: core-hash + run: echo "hash=$(git rev-parse HEAD:jni/deltachat-core-rust)" >> $GITHUB_OUTPUT + + - name: Cache compiled core + id: cache-core + uses: actions/cache@v4 + with: + path: | + jni/arm64-v8a + libs/arm64-v8a + key: core-arm64-v8a-${{ steps.core-hash.outputs.hash }}-${{ hashFiles('jni/Android.mk', 'jni/Application.mk', 'jni/dc_wrapper.c', 'scripts/ndk-make.sh') }}-ndk-r27 + - name: Compile core env: ANDROID_NDK_ROOT: ${{ steps.setup-ndk.outputs.ndk-path }} From 138dcd7bdf732d9cc658d39b381d58392b386fa3 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 11 Mar 2026 19:25:48 +0100 Subject: [PATCH 07/27] use groovyGradle --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 0377119a8..1279600ec 100644 --- a/build.gradle +++ b/build.gradle @@ -167,7 +167,7 @@ spotless { trimTrailingWhitespace() endWithNewline() } - groovy { + groovyGradle { target '*.gradle' greclipse() leadingTabsToSpaces(4) From c89f9ce875d1a0af183c7bc12065f060a314c900 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Wed, 11 Mar 2026 20:40:13 +0100 Subject: [PATCH 08/27] set spaceBeforeEmptyCloseTag=true explicitly --- spotless/eclipse-wtp-xml.prefs | 1 + 1 file changed, 1 insertion(+) diff --git a/spotless/eclipse-wtp-xml.prefs b/spotless/eclipse-wtp-xml.prefs index c4d2608d0..bc8e20f72 100644 --- a/spotless/eclipse-wtp-xml.prefs +++ b/spotless/eclipse-wtp-xml.prefs @@ -4,3 +4,4 @@ splitMultiAttrs=true indentMultipleAttributes=true indentationChar=space indentationSize=4 +spaceBeforeEmptyCloseTag=true \ No newline at end of file From dde82ccb2f305b578522075117a685330b1e7b57 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Fri, 13 Mar 2026 21:30:32 +0100 Subject: [PATCH 09/27] avoid race in processComposeControls during "reply privately" processComposeControls() spawns a background thread that uses the class field chatId, but by the time `setDraft(chatId, null)` is called to clear the draft, the chatId might be already the new selected chat for "reply privately" --- CHANGELOG.md | 1 + .../securesms/ConversationActivity.java | 39 +++++++------------ 2 files changed, 15 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 784c5bbb3..86ad2e59c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ * Fix: keep original sent timestamp for resent messages * Fix: make clicking on broadcast member-added messages work always * Fix: remove notification when a message is deleted by sender +* Fix: avoid "reply privately" not quoting the selected message sometimes * Update to core 2.44.0 ## v2.43.0 diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 7e72bd4b4..d81cec65f 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -1172,6 +1172,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity playbackViewModel.stopNonMessageAudioPlayback(); DcContext dcContext = DcHelper.getContext(context); + final int currentChatId = dcChat.getId(); Util.runOnAnyBackgroundThread(() -> { DcMsg msg = null; int recompress = 0; @@ -1181,9 +1182,9 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity if (action == ACTION_SEND_OUT) { dcContext.sendEditRequest(msgId, body); } else { - dcContext.setDraft(chatId, null); + dcContext.setDraft(currentChatId, null); } - future.set(chatId); + future.set(currentChatId); return; } @@ -1194,7 +1195,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity try { if (slideDeck.getWebxdctDraftId() != 0) { - msg = dcContext.getDraft(chatId); + msg = dcContext.getDraft(currentChatId); } else { List attachments = slideDeck.asAttachments(); for (Attachment attachment : attachments) { @@ -1239,7 +1240,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity // for WEBXDC, drafts are just sent out as is. // for preparations and other cases, cleanup draft soon. if (msg == null || msg.getType() != DcMsg.DC_MSG_WEBXDC) { - dcContext.setDraft(dcChat.getId(), null); + dcContext.setDraft(currentChatId, null); } if(msg!=null) { @@ -1255,7 +1256,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity false ); }); - doSend = VideoRecoder.prepareVideo(ConversationActivity.this, dcChat.getId(), msg); + doSend = VideoRecoder.prepareVideo(ConversationActivity.this, currentChatId, msg); Util.runOnMain(() -> { try { if (progressDialog != null) progressDialog.dismiss(); @@ -1266,48 +1267,36 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } if (doSend) { - if (dcContext.sendMsg(dcChat.getId(), msg) == 0) { + if (dcContext.sendMsg(currentChatId, msg) == 0) { String lastError = dcContext.getLastError(); if (!"".equals(lastError)) { Util.runOnMain(() -> Toast.makeText(ConversationActivity.this, lastError, Toast.LENGTH_LONG).show()); } - future.set(chatId); + future.set(currentChatId); return; } } - Util.runOnMain(() -> sendComplete(dcChat.getId())); + if (currentChatId == this.chatId) { + Util.runOnMain(() -> sendComplete()); + } } } else { - dcContext.setDraft(dcChat.getId(), msg); + dcContext.setDraft(currentChatId, msg); } - future.set(chatId); + future.set(currentChatId); }); return future; } - protected void sendComplete(int chatId) { - boolean refreshFragment = (chatId != this.chatId); - this.chatId = chatId; - + protected void sendComplete() { if (fragment == null || !fragment.isVisible() || isFinishing()) { return; } fragment.setLastSeen(-1); - - if (refreshFragment) { - fragment.reload(recipient, chatId); - try { - int accId = rpc.getSelectedAccountId(); - DcHelper.getNotificationCenter(this).updateVisibleChat(accId, chatId); - } catch (RpcException e) { - Log.e(TAG, "rpc.getSelectedAccountId() failed", e); - } - } - fragment.scrollToBottom(); attachmentManager.cleanup(); } From 41df4eb03db70353a58b7feec65de3ef9a27aa93 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 14 Mar 2026 14:39:32 +0100 Subject: [PATCH 10/27] update deltachat-core-rust to 'chore(release): prepare for 2.45.0' of 'v2.45.0' --- jni/deltachat-core-rust | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jni/deltachat-core-rust b/jni/deltachat-core-rust index ffce0dfc9..d9056fd18 160000 --- a/jni/deltachat-core-rust +++ b/jni/deltachat-core-rust @@ -1 +1 @@ -Subproject commit ffce0dfc9a1d5f7f8e9e140c784883255509a423 +Subproject commit d9056fd18708bf35dfb2f6fd1bd2b2f75cf550d8 From d64d094de3b65ea0e94382dfc864411370f37712 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 14 Mar 2026 15:12:23 +0100 Subject: [PATCH 11/27] update translations --- src/main/assets/help/cs/help.html | 41 +- src/main/assets/help/de/help.html | 76 +- src/main/assets/help/en/help.html | 41 +- src/main/assets/help/es/help.html | 41 +- src/main/assets/help/fr/help.html | 170 ++--- src/main/assets/help/id/help.html | 41 +- src/main/assets/help/it/help.html | 41 +- src/main/assets/help/nl/help.html | 41 +- src/main/assets/help/pl/help.html | 41 +- src/main/assets/help/pt/help.html | 41 +- src/main/assets/help/ru/help.html | 41 +- src/main/assets/help/sk/help.html | 41 +- src/main/assets/help/sq/help.html | 41 +- src/main/assets/help/uk/help.html | 41 +- src/main/assets/help/zh_CN/help.html | 952 +++++++++++++------------ src/main/res/values-ar/strings.xml | 4 - src/main/res/values-az/strings.xml | 4 - src/main/res/values-bg/strings.xml | 14 - src/main/res/values-bqi/strings.xml | 2 - src/main/res/values-ca/strings.xml | 91 +-- src/main/res/values-ckb/strings.xml | 4 - src/main/res/values-cs/strings.xml | 36 - src/main/res/values-da/strings.xml | 8 - src/main/res/values-de/strings.xml | 63 +- src/main/res/values-el/strings.xml | 12 - src/main/res/values-eo/strings.xml | 14 - src/main/res/values-es/strings.xml | 40 +- src/main/res/values-et/strings.xml | 61 +- src/main/res/values-eu/strings.xml | 38 +- src/main/res/values-fa/strings.xml | 38 +- src/main/res/values-fi/strings.xml | 14 - src/main/res/values-fr/strings.xml | 48 +- src/main/res/values-gl/strings.xml | 16 - src/main/res/values-hr/strings.xml | 2 - src/main/res/values-hu/strings.xml | 32 - src/main/res/values-in/strings.xml | 6 - src/main/res/values-it/strings.xml | 56 +- src/main/res/values-ja/strings.xml | 8 - src/main/res/values-kab/strings.xml | 38 +- src/main/res/values-ko/strings.xml | 12 - src/main/res/values-lt/strings.xml | 42 +- src/main/res/values-nb/strings.xml | 21 +- src/main/res/values-nl/strings.xml | 61 +- src/main/res/values-pl/strings.xml | 42 +- src/main/res/values-pt-rBR/strings.xml | 12 - src/main/res/values-pt/strings.xml | 4 - src/main/res/values-ro/strings.xml | 8 - src/main/res/values-ru/strings.xml | 61 +- src/main/res/values-sc/strings.xml | 4 - src/main/res/values-sk/strings.xml | 14 - src/main/res/values-sq/strings.xml | 63 +- src/main/res/values-sr/strings.xml | 12 - src/main/res/values-sv/strings.xml | 174 +++-- src/main/res/values-ta/strings.xml | 3 +- src/main/res/values-te/strings.xml | 3 +- src/main/res/values-tr/strings.xml | 67 +- src/main/res/values-uk/strings.xml | 42 +- src/main/res/values-vi/strings.xml | 14 - src/main/res/values-zh-rCN/strings.xml | 57 +- src/main/res/values-zh-rTW/strings.xml | 144 +++- 60 files changed, 1539 insertions(+), 1660 deletions(-) diff --git a/src/main/assets/help/cs/help.html b/src/main/assets/help/cs/help.html index 6791fb986..128035f15 100644 --- a/src/main/assets/help/cs/help.html +++ b/src/main/assets/help/cs/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • Podporuje Delta Chat obrázky, videa a jiné přílohy?
  • What are profiles? How can I switch between them?
  • Kdo uvidí můj profilový obrázek?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • What does the green dot mean?
  • What do the ticks shown beside outgoing messages mean?
  • Correct typos and delete messages after sending
  • +
  • How is media quality handled?
  • How do disappearing messages work?
  • What happens if I turn on “Delete Messages from Device”?
  • How can I delete my chat profile?
  • @@ -218,19 +218,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - Podporuje Delta Chat obrázky, videa a jiné přílohy? - - -

    - -

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    -

    @@ -412,6 +399,32 @@ Notifications are not sent and there is no time limit.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/de/help.html b/src/main/assets/help/de/help.html index f21e9c69e..0198f98fe 100644 --- a/src/main/assets/help/de/help.html +++ b/src/main/assets/help/de/help.html @@ -5,7 +5,6 @@
  • Wie finde ich Leute, mit denen ich chatten kann?
  • Warum ist ein Chat als “Anfrage” markiert?
  • Wie kann ich zwei meiner Freunde miteinander in Kontakt bringen?
  • -
  • Unterstützt Delta Chat Bilder, Videos und Dateianhänge?
  • Was sind Profile? Wie kann ich zwischen ihnen wechseln?
  • Wer sieht mein Profilbild?
  • Kann ich einen Status festlegen?
  • @@ -14,6 +13,7 @@
  • Was bedeutet der grüne Punkt?
  • Was bedeuten die Häkchen neben den ausgehenden Nachrichten?
  • Schreibfehler korrigieren und Nachrichten nach dem Senden löschen
  • +
  • Medienqualität und Datenverbrauch
  • Wie funktionieren “Verschwindende Nachrichten”?
  • Was passiert, wenn ich “Nachrichten vom Gerät löschen” aktiviere?
  • Wie kann ich mein Chat-Profil löschen?
  • @@ -109,7 +109,7 @@ @@ -177,7 +177,7 @@ wird eine Ende-zu-Ende-Verschlüsselung zwischen allen Mitgliedern eingerichtet.

    -

    Da Delta Chat ein privater Messenger ist, können dir zunächst nur Freunde und Familienmitglieder, denen du deinen QR-Code oder Einladungslink schickst, schreiben.

    +

    Da Delta Chat ein privater Messenger ist, können dir zunächst nur Freunde und Familienmitglieder schreiben, denen du deinen QR-Code oder Einladungslink schickst.

    Deine Freunde können deine Kontaktdaten dann mit anderen Freunden teilen. Dies wird als Anfrage angezeigt.

    @@ -186,12 +186,12 @@ wird eine Ende-zu-Ende-Verschlüsselung zwischen allen Mitgliedern eingerichtet.

    Du musst die Anfrage akzeptieren, bevor du antworten kannst.

  • -

    Du kannst sie auch “löschen”, wenn du vorerst nicht mit ihm chatten möchten.

    +

    Du kannst sie auch “löschen”, wenn du vorerst nicht mit dieser Person chatten möchtest.

  • -

    If you delete a request, future messages from that contact will still appear -as message request, so you can change your mind. If you really don’t want to -receive messages from this person, consider blocking them.

    +

    Wenn du eine Anfrage löschst, werden zukünftige Nachrichten von diesem Kontakt weiterhin +als Nachrichtenanfrage angezeigt, sodass du deine Meinung ändern kannst. Wenn du wirklich keine +Nachrichten von dieser Person erhalten möchtest, solltest du sie blockieren.

  • @@ -209,19 +209,6 @@ Du kannst auch eine kurze Nachricht hinzufügen.

    Der zweite Kontakt erhält dann die Kontaktdaten und kann darauf tippen, um mit dem ersten Kontakt zu chatten.

    -

    - - - Unterstützt Delta Chat Bilder, Videos und Dateianhänge? - - -

    - -

    Ja. Bilder, Videos, Dateien, Sprachnachrichten und mehr können über die Paperclip Anhang- - bzw. Microphone Sprachnachricht-Buttons hinzugefügt werden

    - -

    Um die Leistung zu verbessern, werden die Bilder standardmäßig optimiert und in einer kleineren Größe gesendet, aber du kannst sie auch als “Datei” senden, um das Original zu erhalten.

    -

    @@ -241,7 +228,7 @@ oder Profile zu wechseln.

    Du kannst separate Profile für politische, familiäre oder berufliche Aktivitäten verwenden.

    -

    Vielleicht möchtest due auch erfahren, wie du Profile auf mehreren Geräten verwenden kannst.

    +

    Vielleicht möchtest du auch erfahren, wie du Profile auf mehreren Geräten verwenden kannst.

    @@ -279,7 +266,7 @@ Sobald du eine Nachricht an einen Kontakt sendest, kann dieser deine Signatur in
    • -

      Angeheftete Chats bleiben immer ganz oben in der Chatliste. So kannst du schnell auf deine Lieblingschats zugreifen oder du verwendest vorübergehend angeheftete Chats um Dinge nicht zu vergessen.

      +

      Angeheftete Chats bleiben immer ganz oben in der Chatliste. So kannst du schnell auf deine Lieblingschats zugreifen oder du verwendest vorübergehend angeheftete Chats, um Dinge nicht zu vergessen.

    • Stummgeschaltete Chats erhalten keine Benachrichtigungen, bleiben ansonsten aber an ihrem Platz. Du kannst auch stummgeschaltete Chats anheften.

      @@ -292,7 +279,7 @@ Sobald du eine Nachricht an einen Kontakt sendest, kann dieser deine Signatur in
    -

    Um die Funktionen zu nutzen, lang auf einen Chat in der Chatliste tippen oder den Chat mit der rechten Maustaste anklicken.

    +

    Um die Funktionen zu nutzen, tippe lang auf einen Chat in der Chatliste oder klicke den Chat mit der rechten Maustaste an.

    @@ -302,11 +289,11 @@ Sobald du eine Nachricht an einen Kontakt sendest, kann dieser deine Signatur in

    -

    Gespeicherte Nachrichten ist ein Chat, den du verwenden kannst, um dir Nachrichten zu merken und wiederzufinden.

    +

    Gespeicherte Nachrichten ist ein Chat, den du verwenden kannst, um dir Nachrichten zu merken und sie wiederzufinden.

    @@ -533,7 +545,7 @@ Kein Problem, bitte einfach ein anderes Gruppenmitglied in einem normalen Chat, Wenn du der Gruppe später erneut beitreten möchtest, bitten ein anderes Gruppenmitglied, dich hinzuzufügen. -

    Alternativ kannst du eine Gruppe auch “stummschalten” - dies bedeutet, dass du weiterhin alle Nachrichten erhälst und neue schreiben kannst, aber nicht mehr über neue Nachrichten informiert wirst.

    +

    Alternativ kannst du eine Gruppe auch “stummschalten” - dies bedeutet, dass du weiterhin alle Nachrichten erhältst und neue schreiben kannst, aber nicht mehr über neue Nachrichten informiert wirst.

    diff --git a/src/main/assets/help/en/help.html b/src/main/assets/help/en/help.html index 5862d0288..f23be39ee 100644 --- a/src/main/assets/help/en/help.html +++ b/src/main/assets/help/en/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • Does Delta Chat support images, videos and other attachments?
  • What are profiles? How can I switch between them?
  • Who sees my profile picture?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • What does the green dot mean?
  • What do the ticks shown beside outgoing messages mean?
  • Correct typos and delete messages after sending
  • +
  • How is media quality handled?
  • How do disappearing messages work?
  • What happens if I turn on “Delete Messages from Device”?
  • How can I delete my chat profile?
  • @@ -218,19 +218,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - Does Delta Chat support images, videos and other attachments? - - -

    - -

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    -

    @@ -411,6 +398,32 @@ Notifications are not sent and there is no time limit.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/es/help.html b/src/main/assets/help/es/help.html index de3b5d668..84b8a9de8 100644 --- a/src/main/assets/help/es/help.html +++ b/src/main/assets/help/es/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • ¿Delta Chat soporta envío de imágenes, videos, documentos y otros archivos?
  • ¿Qué son los perfiles? ¿Cómo puedo cambiar entre ellos?
  • ¿Quién ve mi foto de perfil?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • ¿Qué significa el punto verde?
  • ¿Qué significan las marcas que se muestran junto a los mensajes salientes?
  • Corregir errores y borrar mensajes después de enviar
  • +
  • How is media quality handled?
  • ¿Cómo funciona la desaparición de mensajes?
  • ¿Qué pasa si activo “Borrar mensajes del dispositivo”?
  • How can I delete my chat profile?
  • @@ -216,19 +216,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - ¿Delta Chat soporta envío de imágenes, videos, documentos y otros archivos? - - -

    - -

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    Para mejorar el rendimiento, las imágenes se optimizan y se envían en un tamaño más pequeño de forma predeterminada, pero puedes enviarla como un “archivo” para conservar la original.

    -

    @@ -407,6 +394,32 @@ No se envían notificaciones y no hay límite de tiempo.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/fr/help.html b/src/main/assets/help/fr/help.html index 108831ad8..4d1b64c60 100644 --- a/src/main/assets/help/fr/help.html +++ b/src/main/assets/help/fr/help.html @@ -2,18 +2,18 @@

    -

    Delta Chat is a reliable, decentralized and secure instant messaging app, -available for mobile and desktop platforms.

    +

    Delta Chat est une application de messagerie fiable, décentralisée et sécurisée, disponible pour smartphones et ordinateurs de bureau.

    • -

      Instant creation of private chat profiles -with secure and interoperable chatmail relays -that offer instant message delivery, and Push Notifications for iOS and Android devices.

      +

      Création à la volée de profils de discussion privés grâce à des relais chatmail sécurisés et interopérables qui permettent des échanges instantanés et des notifications Push pour les appareils iOS et Android.

    • Pervasive multi-profile and @@ -120,75 +117,61 @@ that offer instant message delivery, and Push Notifications for iOS and Android and between different chatmail apps.

    • -

      Interactive in-chat apps for gaming and collaboration

      +

      Applications interactives permettant de jouer et collaborer au sein des discussions.

    • -

      Audited end-to-end encryption -safe against network and server attacks.

      +

      Chiffrement de bout-en-bout audité protégé contre les attaques réseaux et de serveurs.

    • -

      Free and Open Source software, both app and server side, -built on Internet Standards.

      +

      Logiciel libre et gratuit, autant côté serveur que côté application, développé autour des standards d’internet.

    - How can I find people to chat with? + Comment trouver des personnes avec qui discuter ?

    -

    First, note that Delta Chat is a private messenger. -There is no public discovery, you decide about your contacts.

    +

    Delta Chat est une application de messagerie privée. Il n’y a pas de mécanisme de découverte publique des utilisateurs et utilisatrices. Vous décidez avec qui vous échangez dans Delta Chat.

    • -

      If you are face to face with your friend or family, -tap the QR Code icon -on the main screen.
      -Ask your chat partner to scan the QR image -with their Delta Chat app.

      +

      En présence physique de vos amis ou de membres de votre famille, appuyez sur l’icône QR code sur l’écran principal. Demandez à votre contact de scanner le QR code avec leur application Delta Chat.

    • -

      For a remote contact setup, -from the same screen, -click “Copy” or “Share” and send the invite link -through another private chat.

      +

      Pour la mise en place d’un échange à distance, cliquez sur l’icône QR code sur l’écran principal, puis sur le bouton “Lien”, puis sur “Copier” ou “Partager” et envoyez le lien d’invitation via une autre messagerie privée.

    -

    Now wait while connection gets established.

    +

    Il suffit ensuite d’attendre que la connexion soit établie.

    • -

      If both sides are online, they will soon see a chat -and can start messaging securely.

      +

      Si les deux contacts sont en ligne, ils verront rapidement apparaître une nouvelle discussion et pourront tout de suite commencer une discussion sécurisée.

    • -

      If one side is offline or in bad network, -the ability to chat is delayed until connectivity is restored.

      +

      Si l’un des contacts n’est pas en ligne ou a des problèmes de réseau, la possibilité de démarrer la discussion aura lieu dès que la connectivité sera rétablie.

    -

    Congratulations! -You now will automatically use end-to-end encryption with this contact. -If you add each other to groups, end-to-end encryption will be established among all members.

    +

    Félicitations ! +Vous utiliserez désormais le chiffrement de bout-en-bout avec ce contact. +Si vous vous ajoutez à des discussions de groupe le chiffrement de bout-en-bout sera établi entre tous les membres de la discussion.

    -

    +

    - Why is a chat marked as “Request”? + Pourquoi une discussion est marquée comme “invitation” ?

    -

    As being a private messenger, -only friends and family you share your QR code or invite link with can write to you.

    +

    Delta Chat étant une messagerie privée, seules les personnes avec qui vous partagez votre QR code ou lien d’invitation peuvent vous écrire.

    -

    Your friends may share your contact with other friends, -this appears as Request

    +

    Vos contacts peuvent cependant partager votre profil avec d’autres personnes, ce qui apparaît alors comme une invitation.

    • @@ -204,53 +187,37 @@ recevoir de messages de cette personne, nous vous conseillons de la bloq
    -

    +

    - How can I put two of my friends in contact with each other? + Comment mettre deux personnes en relation entre elles ?

    -

    Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. -You can also add a little introduction message.

    +

    Joignez le premier contact à votre discussion avec le second en utilisant Paperclip Bouton pièce jointe → Contact. +Profitez-en pour ajouter un message de présentation.

    -

    The second contact will receive a card then -and can tap it to start chatting with the first contact.

    - -

    - - - Delta Chat prend-il en charge les images, vidéos et autres pièces jointes ? - - -

    - -

    Oui. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    Pour améliorer les performances, les images sont redimensionnées et envoyées en taille réduite par défaut ; mais vous pouvez les envoyer en tant que “fichier” pour en conserver la taille originale.

    +

    Le deuxième contact recevra une carte qu’il pourra cliquer pour commencer une discussion avec le premier.

    - What are profiles? How can I switch between them? + À quoi correspondent les profils ? Comment est-ce que je peux passer de l’un à l’autre ?

    -

    A profile is a name, a picture and some additional information for encrypting messages. -A profile lives on your device(s) only -and uses the server only to relay messages.

    +

    Un profil est un nom, une photo et quelques informations supplémentaires pour chiffrer des messages. +Un profil n’est présent que sur votre appareil/vos appareils et utilise le serveur seulement pour relayer les messages vers vos contacts.

    -

    On first installation of Delta Chat a first profile is created.

    +

    Lors de la première installation de Delta Chat un premier profil est créé.

    -

    Later, you can tap your profile image in the upper left corner to Add Profiles -or to Switch Profiles.

    +

    Par la suite vous pourrez cliquer sur votre image de profil en haut à gauche de l’écran principal pour Ajouter un profil ou Changer de profil.

    -

    You may want to use separate profiles for political, family or work related activities.

    +

    Vous souhaiterez peut être utiliser des profils différents pour vos activités familiales, professionnelles ou politiques.

    -

    You may also wish to learn how to use the same profile on multiple devices.

    +

    Vous souhaiterez peut être aussi apprendre comment utiliser le même profil sur plusieurs appareils.

    @@ -267,15 +234,13 @@ or to Switch Profiles.

    - Can I set a Bio/Status with Delta Chat? + Est-il possible de définir une Bio/un Statut avec Delta Chat ?

    -

    Yes, -you can do so under Settings → Profile → Bio. -Once you sent a message to a contact, -they will see it when they view your contact details.

    +

    Oui, vous pouvez le faire dans Paramètres → Profil → Signature. +À partir du moment ou vous échangez avec quelqu’un, cette personne pourra voir votre Signature en regardant les détails de votre profil.

    @@ -309,37 +274,33 @@ pour mettre une discussion en sourdine, ouvrez le menu de la conversation (Andro

    - How do “Saved Messages” work? + Comment fonctionnent les “Messages enregistrés” ?

    -

    Saved Messages is a chat that you can use to easily remember and find messages.

    +

    Messages enregistrés est une discussion que vous pouvez utiliser pour sauvegarder et retrouver facilement des messages.

    • -

      In any chat, long tap or right click a message and select Save

      +

      Dans n’importe quelle discussion, touchez Saved icon ou faites un clic droit sur un message et sélectionnez Enregistrer.

    • -

      Saved messages are marked by the symbol -Saved icon -next to the timestamp

      +

      Les messages enregistrés sont identifiés par le symbole Saved icon à côté de l’horodatage du message.

    • -

      Later, open the “Saved Messages” chat - and you will see the saved messages there. -By tapping Arrow-right icon, -you can go back to the original message in the original chat

      +

      Par la suite vous pourrez retrouver tous ces messages dans la discussion “Messages enregistrés”. +En touchant Arrow-right icon vous retrouverez le message dans sa discussion d’origine.

    • -

      Finally, you can also use “Saved Messages” to take personal notes - open the chat, type something, add a photo or a voice message etc.

      +

      Enfin vous pouvez aussi utiliser la discussion “Messages enregistrés” pour prendre des notes privées - ouvrez la discussion, écrivez votre message, ajoutez une photo, un message vocal, etc.

    • -

      As “Saved Message” are synced, they can become very handy for transferring data between devices

      +

      La discussion “Messages enregistrés” étant synchronisée elle peut être très pratique pour transférer des informations d’un appareil à un autre.

    -

    Messages stay saved even if they are edited or deleted - -may it be by sender, by device cleanup or by disappearing messages of other chats.

    +

    Les messages restent enregistrés même lorsqu’ils sont édités ou supprimés, que ça soit par l’expéditeur⋅rice, par suppression des anciens messages de l’appareil ou par disparition des messages éphémères au sein d’une discussion.

    @@ -349,10 +310,8 @@ may it be by sender, by device cleanup

    -

    You can sometimes see a green dot -next to the avatar of a contact. -It means they were recently seen by you in the last 10 minutes, -e.g. because they messaged you or sent a read receipt.

    +

    Vous pourrez parfois voir un point vert sur le profil d’un contact. +Cela veut dire que vous avez récemment vu le contact dans les 10 dernières minutes, par exemple parce que vous avez échangé avec ce contact, ou que vous avez reçu un accusé de lecture.

    So this is not a real time online status and others will as well not always see that you are “online”.

    @@ -408,6 +367,31 @@ Notifications are not sent and there is no time limit.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, vidéos, fichiers, messages vocaux etc. peuvent être envoyé avec les boutons Paperclip Pièce jointe ou Microphone Message vocal.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/id/help.html b/src/main/assets/help/id/help.html index 09f8ba2b8..7bf3be54e 100644 --- a/src/main/assets/help/id/help.html +++ b/src/main/assets/help/id/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • Apakah Delta Chat mendukung gambar, vidio dan lampiran lainnya?
  • What are profiles? How can I switch between them?
  • Siapa yang dapat melihat Foto Profil saya?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • What does the green dot mean?
  • What do the ticks shown beside outgoing messages mean?
  • Correct typos and delete messages after sending
  • +
  • How is media quality handled?
  • How do disappearing messages work?
  • What happens if I turn on “Delete Messages from Device”?
  • How can I delete my chat profile?
  • @@ -218,19 +218,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - Apakah Delta Chat mendukung gambar, vidio dan lampiran lainnya? - - -

    - -

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    -

    @@ -411,6 +398,32 @@ Notifications are not sent and there is no time limit.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/it/help.html b/src/main/assets/help/it/help.html index 8129e605f..cd3ce01ff 100644 --- a/src/main/assets/help/it/help.html +++ b/src/main/assets/help/it/help.html @@ -5,7 +5,6 @@
  • Come posso trovare persone con cui chattare?
  • Perché una chat è contrassegnata come “Richiesta”?
  • Come posso mettere in contatto due miei amici?
  • -
  • Delta Chat supporta immagini, video e altri allegati?
  • Cosa sono i profili? Come posso passare dall’uno all’altro?
  • Chi vede la mia immagine del profilo?
  • Posso impostare una Biografia/Stato con Delta Chat?
  • @@ -14,6 +13,7 @@
  • Cosa significa il punto verde?
  • Cosa significano i segni di spunta visualizzati accanto ai messaggi in uscita?
  • Correggi gli errori e cancella i messaggi dopo averli inviati
  • +
  • How is media quality handled?
  • Come funzionano i messaggi a scomparsa?
  • Cosa succede se attivo “Elimina Messaggi dal Dispositivo”?
  • Come posso eliminare il mio profilo chat?
  • @@ -217,19 +217,6 @@ Puoi anche aggiungere un breve messaggio di presentazione.

    Il secondo contatto riceverà una scheda e potrà toccarla per iniziare a chattare con il primo contatto.

    -

    - - - Delta Chat supporta immagini, video e altri allegati? - - -

    - -

    Sì. Immagini, video, files, messaggi vocali ecc. possono essere inviati utilizzando Paperclip Allegato- -o Microphone pulsanti Messaggio Vocale

    - -

    Per le prestazioni, le immagini sono ottimizzate e inviate in dimensioni inferiori per impostazione predefinita, ma è possibile inviarle come “file” per preservare l’originale.

    -

    @@ -409,6 +396,32 @@ Non vengono inviate notifiche e non c’è limite di tempo.

    Nota che il messaggio originale potrebbe essere ancora sui dispositivi dei membri della chat che avrebbero già potuto rispondere, inoltrare, salvare, scattare una schermata o copiare il messaggio in altri modi.

    +

    + + + How is media quality handled? + + +

    + +

    Immagini, video, files, messaggi vocali ecc. possono essere inviati utilizzando Paperclip Allegato- +o Microphone pulsanti Messaggio Vocale

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/nl/help.html b/src/main/assets/help/nl/help.html index 657fd4a65..cd788bb7e 100644 --- a/src/main/assets/help/nl/help.html +++ b/src/main/assets/help/nl/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • Ondersteunt Delta Chat afbeeldingen, video’s en ander soort bijlagen?
  • What are profiles? How can I switch between them?
  • Wie kan mijn profielfoto zien?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • Wat betekent die groene stip?
  • Wat betekenen de vinkjes naast verzonden berichten?
  • Correct typos and delete messages after sending
  • +
  • How is media quality handled?
  • How do disappearing messages work?
  • Wat gebeurt er als ik ‘Oude berichten van server verwijderen’ inschakel?
  • How can I delete my chat profile?
  • @@ -218,19 +218,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - Ondersteunt Delta Chat afbeeldingen, video’s en ander soort bijlagen? - - -

    - -

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    Om de prestaties te verhogen, worden afbeeldingen standaard geoptimaliseerd en verkleind verstuurd, maar je kunt ze als een bestand verzenden om het origineel te sturen.

    -

    @@ -410,6 +397,32 @@ Notifications are not sent and there is no time limit.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/pl/help.html b/src/main/assets/help/pl/help.html index 5a1e0c699..d6a55bda1 100644 --- a/src/main/assets/help/pl/help.html +++ b/src/main/assets/help/pl/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • Czy Delta Chat obsługuje obrazy, filmy i inne załączniki?
  • Czym są profile? Jak mogę przełączać się między nimi?
  • Kto widzi moje zdjęcie profilowe?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • Co oznacza zielona kropka?
  • Co oznaczają znaczniki wyświetlane obok wiadomości wychodzących?
  • Poprawianie literówek i usuwanie wiadomości po wysłaniu
  • +
  • How is media quality handled?
  • Jak działają znikające wiadomości?
  • Co się stanie, jeśli włączę opcję „Usuń wiadomości z urządzenia”?
  • How can I delete my chat profile?
  • @@ -215,19 +215,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - Czy Delta Chat obsługuje obrazy, filmy i inne załączniki? - - -

    - -

    Tak. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    Ze względu na wydajność obrazy są domyślnie optymalizowane i wysyłane w mniejszym rozmiarze, ale można je wysłać jako „plik”, aby zachować oryginał.

    -

    @@ -392,6 +379,32 @@ has Settings → Chats → Read Receipts enabled.

    Pamiętaj, że oryginalną wiadomość nadal mogą otrzymać członkowie czatu, którzy mogli już odpowiedzieć, przesłać dalej, zapisać, wykonać zrzut ekranu lub w inny sposób skopiować wiadomość.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/pt/help.html b/src/main/assets/help/pt/help.html index 5f71fab8b..751cb585e 100644 --- a/src/main/assets/help/pt/help.html +++ b/src/main/assets/help/pt/help.html @@ -5,7 +5,6 @@
  • Como posso encontrar pessoas para conversar?
  • Por que um chat está marcado como “Solicitação”?
  • Como posso colocar dois dos meus amigos em contato um com o outro?
  • -
  • Dá para mandar imagens, vídeos e outros anexos pelo Delta Chat?
  • O que são perfis? Como posso alternar entre eles?
  • Quem consegue ver a imagem do meu perfil?
  • Posso definir uma Bio/Assinatura com o Delta Chat?
  • @@ -14,6 +13,7 @@
  • O que significa o ponto verde?
  • O que significam os carrapatos mostrados ao lado das mensagens de saída?
  • Corrigir erros de digitação e excluir mensagens após o envio
  • +
  • How is media quality handled?
  • Como funcionam as mensagens efêmeras?
  • O que acontece se eu ativar a opção “Apagar mensagens do dispositivo”?
  • Como posso excluir minha conta?
  • @@ -218,19 +218,6 @@ Você também pode adicionar uma pequena mensagem de apresentação.

    O segundo contato receberá um cartão e poderá tocar nele para começar a conversar com o primeiro contato.

    -

    - - - Dá para mandar imagens, vídeos e outros anexos pelo Delta Chat? - - -

    - -

    Sim. Imagens, vídeos, arquivos, mensagens de voz etc. podem ser enviados usando os botões dePaperclip Anexo- -ou Microphone Mensagem de Voz

    - -

    Para fins de desempenho, as imagens são otimizadas e enviadas em um tamanho menor por padrão, mas você pode enviá-las como um “arquivo” para preservar o original.

    -

    @@ -409,6 +396,32 @@ Não são enviadas notificações e não há limite de tempo.

    Observe que a mensagem original ainda pode ser recebida pelos membros do conversa que podem ter respondido, encaminhado, salvo, capturado a tela ou copiado a mensagem.

    +

    + + + How is media quality handled? + + +

    + +

    Imagens, vídeos, arquivos, mensagens de voz etc. podem ser enviados usando os botões dePaperclip Anexo- +ou Microphone Mensagem de Voz

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/ru/help.html b/src/main/assets/help/ru/help.html index a38ca5877..82c1add75 100644 --- a/src/main/assets/help/ru/help.html +++ b/src/main/assets/help/ru/help.html @@ -5,7 +5,6 @@
  • Как найти людей для общения?
  • Почему чат помечен как “Запрос”?
  • Как я могу связать двух своих друзей друг с другом?
  • -
  • Поддерживает ли Delta Chat изображения, видео и другие вложения?
  • Что такое профили? Как я могу переключатся между ними?
  • Кто видит изображение моего профиля?
  • Могу ли я установить статус/подпись в Delta Chat?
  • @@ -14,6 +13,7 @@
  • Что означает зеленая точка?
  • Что означают галочки рядом с исходящими сообщениями?
  • Исправление опечаток и удаление сообщений после отправки
  • +
  • Как обеспечивается качество мультимедиа?
  • Как работают исчезающие сообщения?
  • Что произойдет, если я включу функцию “Удалять сообщения с устройства”?
  • Как удалить свой профиль в чате?
  • @@ -214,19 +214,6 @@

    Второй контакт получит карточку на которую можно нажать, чтобы начать общение с первым контактом.

    -

    - - - Поддерживает ли Delta Chat изображения, видео и другие вложения? - - -

    - -

    Да. Изображения, видео, файлы, голосовые сообщения и т.д. можно отправлять с помощью кнопок Paperclip Вложение -или Microphone Голосовое сообщение.

    - -

    Для повышения производительности, изображения оптимизируются и отправляются по умолчанию в уменьшенном размере, но вы можете отправить их как “файл”, чтобы сохранить оригинал.

    -

    @@ -406,6 +393,32 @@

    Обратите внимание, что исходное сообщение все еще может быть получено участниками чата которые могли уже ответить, переслать, сохранить, сделать скриншот или иным образом скопировать сообщение.

    +

    + + + Как обеспечивается качество мультимедиа? + + +

    + +

    Изображения, видео, файлы, голосовые сообщения и т.д. можно отправлять с помощью кнопок Paperclip Вложение +или Microphone Голосовое сообщение.

    + +
      +
    • +

      По умолчанию сжатие обеспечивает быструю и эффективную доставку сообщений, учитывая ограничения по объему данных и хранилищу у всех пользователей. +Это идеально подходит для повседневного общения.

      +
    • +
    • +

      В регионах с плохим качеством связи, +вы можете выбрать более высокую степень сжатия в меню Настройки → Чаты → Качество отправляемых медиафайлов.

      +
    • +
    • +

      Если вам необходимо отправить мультимедийный файл в исходном качестве, используйте значок Paperclip Прикрепить → Файл в чате. +Используйте этот метод с осторожностью, так как отправка файлов в исходном качестве значительно увеличит объем трафика как для вас, так и для всех участников чата.

      +
    • +
    +

    diff --git a/src/main/assets/help/sk/help.html b/src/main/assets/help/sk/help.html index 3fa0ae3c8..3fd397073 100644 --- a/src/main/assets/help/sk/help.html +++ b/src/main/assets/help/sk/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • Podporuje Delta Chat obrázky, videá a iné prílohy?
  • What are profiles? How can I switch between them?
  • Kto vidí moju profilovú fotku?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • What does the green dot mean?
  • Čo znamenajú zaškrtnutia zobrazené vedľa odchádzajúcich správ?
  • Correct typos and delete messages after sending
  • +
  • How is media quality handled?
  • How do disappearing messages work?
  • What happens if I turn on “Delete Messages from Device”?
  • How can I delete my chat profile?
  • @@ -218,19 +218,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - Podporuje Delta Chat obrázky, videá a iné prílohy? - - -

    - -

    Yes. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    For performance, images are optimized and sent at a smaller size by default, but you can send it as a “file” to preserve the original.

    -

    @@ -411,6 +398,32 @@ Notifications are not sent and there is no time limit.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/sq/help.html b/src/main/assets/help/sq/help.html index 4e21fcc71..f5cef8248 100644 --- a/src/main/assets/help/sq/help.html +++ b/src/main/assets/help/sq/help.html @@ -5,7 +5,6 @@
  • How can I find people to chat with?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • A mbulon Delta Chat-i figura, video dhe bashkëngjitje të tjera?
  • What are profiles? How can I switch between them?
  • Kush e sheh profilin tim?
  • Can I set a Bio/Status with Delta Chat?
  • @@ -14,6 +13,7 @@
  • Ç’do të thotë pika e gjelbër?
  • Ç’duan të thonë shenjat e shfaqura pas mesazheve që dërgohen?
  • Correct typos and delete messages after sending
  • +
  • How is media quality handled?
  • How do disappearing messages work?
  • Ç’ndodh, nëse aktivizoj “Fshi prej pajisjes mesazhe të vjetër”?
  • How can I delete my chat profile?
  • @@ -218,19 +218,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - A mbulon Delta Chat-i figura, video dhe bashkëngjitje të tjera? - - -

    - -

    Po Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    Si parazgjedhje, për funksionim më të mirë, figurat optimizohen dhe dërgohen në madhësi më të vogël, por mund ta dërgoni si një “kartelë”, që të ruhet origjinali.

    -

    @@ -410,6 +397,32 @@ Notifications are not sent and there is no time limit.

    Note, that the original message may still be received by chat members who could have already replied, forwarded, saved, screenshotted or otherwise copied the message.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/uk/help.html b/src/main/assets/help/uk/help.html index 532e62161..26bcc5b3c 100644 --- a/src/main/assets/help/uk/help.html +++ b/src/main/assets/help/uk/help.html @@ -5,7 +5,6 @@
  • Як мені знайти людей для спілкування?
  • Why is a chat marked as “Request”?
  • How can I put two of my friends in contact with each other?
  • -
  • Чи підтримує Delta Chat вкладення у вигляді фото, відео тощо?
  • Що таке профілі? Як я можу перемикатися між ними?
  • Хто бачить моє зображення профілю?
  • Чи можу я встановити біографію/статус у Delta Chat?
  • @@ -14,6 +13,7 @@
  • Що означає зелена точка?
  • Що означають галочки біля вихідних повідомлень?
  • Виправлення помилок та видалення повідомлень після надсилання
  • +
  • How is media quality handled?
  • Як працюють повідомлення, що зникають?
  • Що станеться, якщо я ввімкну «Видаляти старі повідомлення з пристрою»?
  • @@ -204,19 +204,6 @@ You can also add a little introduction message.

    The second contact will receive a card then and can tap it to start chatting with the first contact.

    -

    - - - Чи підтримує Delta Chat вкладення у вигляді фото, відео тощо? - - -

    - -

    Так. Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

    - -

    З міркувань продуктивності, зображення оптимізовані та надсилаються в меншому розмірі за замовчуванням, але ви можете надіслати їх як «файл», щоб зберегти оригінал.

    -

    @@ -381,6 +368,32 @@ has Settings → Chats → Read Receipts enabled.

    Зауважте, що початкове повідомлення все ще може бути отримане учасниками чату які могли вже відповісти, переслати, зберегти, зробити знімок екрану або іншим чином скопіювати повідомлення.

    +

    + + + How is media quality handled? + + +

    + +

    Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

    + +
      +
    • +

      By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

      +
    • +
    • +

      In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

      +
    • +
    • +

      If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

      +
    • +
    +

    diff --git a/src/main/assets/help/zh_CN/help.html b/src/main/assets/help/zh_CN/help.html index 759366567..3f24da989 100644 --- a/src/main/assets/help/zh_CN/help.html +++ b/src/main/assets/help/zh_CN/help.html @@ -2,38 +2,41 @@ -
  • Advanced +
  • 高级
  • +
  • 实验性功能
  • 加密和安全 @@ -105,90 +121,87 @@
  • -

    Delta Chat is a reliable, decentralized and secure instant messaging app, -available for mobile and desktop platforms.

    +

    Delta Chat 是一款可靠、去中心化和安全的消息应用程序、 +适用于移动和桌面平台。

    + +

    🥳 在聊天中体验互动网页应用,一起游戏和协作

    + +
      +
    • +

      经审计的端到端加密 +安全地抵御网络和服务器攻击。

    • -

      Interactive in-chat apps for gaming and collaboration

      -
    • -
    • -

      Audited end-to-end encryption -safe against network and server attacks.

      -
    • -
    • -

      Free and Open Source software, both app and server side, -built on Internet Standards.

      +

      免费开源软件,包括应用程序和服务器端、 +基于[互联网标准](https://github.com/chatmail/core/blob/main/standards.md#standards-used-in-delta-chat +)。

    - How can I find people to chat with? + 如何找到可以聊天的人?

    -

    First, note that Delta Chat is a private messenger. -There is no public discovery, you decide about your contacts.

    +

    首先,请注意Delta Chat 是一个私人信使。 +没有公开的发现功能,由您决定您的联系人。

    • -

      If you are face to face with your friend or family, -tap the QR Code icon -on the main screen.
      -Ask your chat partner to scan the QR image -with their Delta Chat app.

      +

      如果您与朋友或家人面对面、 +点击主屏幕上的 二维码 图标 +图标。
      +请您的聊天对象在 Delta Chat 上扫描 二维码 。

    • -

      For a remote contact setup, -from the same screen, -click “Copy” or “Share” and send the invite link -through another private chat.

      +

      对于远程联络设置、 +在同一屏幕上,点击 “复制 “或 “分享”,然后通过另一个私聊发送邀请链接

    -

    Now wait while connection gets established.

    +

    现在等待连接 建立。

    • -

      If both sides are online, they will soon see a chat -and can start messaging securely.

      +

      如果双方都在线,他们很快就会看到一个聊天 +并开始安全地发送信息。

    • -

      If one side is offline or in bad network, -the ability to chat is delayed until connectivity is restored.

      +

      如果一方离线或网络状况不佳、 +聊天功能会延迟,直到恢复连接。

    -

    Congratulations! -You now will automatically use end-to-end encryption with this contact. -If you add each other to groups, end-to-end encryption will be established among all members.

    +

    恭喜您! +现在您将自动与该联系人使用 端到端加密。 +如果你们将对方添加到 群组,所有成员之间将建立端对端加密 。

    -

    +

    - Why is a chat marked as “Request”? + 为什么聊天被标记为 “请求”?

    -

    As being a private messenger, -only friends and family you share your QR code or invite link with can write to you.

    +

    作为私人信使, +只有您[分享二维码或邀请链接](#howtoe2ee)的亲朋好友才能给您写信。

    -

    Your friends may share your contact with other friends, -this appears as Request

    +

    您的朋友可能会与其他朋友分享您的联系方式、 +这将显示为请求

    • @@ -204,32 +217,19 @@ this appears as +

      - How can I put two of my friends in contact with each other? + 如何让我的两个朋友相互联系?

      -

      Attach the first contact to the chat of the second using Paperclip Attachment Button → Contact. -You can also add a little introduction message.

      +

      使用Paperclip 附加按钮 → 联系人,将第一个联系人附加到第二个联系人的聊天中。 +您还可以添加一条小小的介绍信息。

      -

      The second contact will receive a card then -and can tap it to start chatting with the first contact.

      - -

      - - - Delta Chat 支持图像、视频和其他附件吗? - - -

      - -

      是的。 Images, videos, files, voice messages etc. can be sent using the Paperclip Attachment- -or Microphone Voice Message buttons

      - -

      为了提高性能,默认情况下会对图像进行优化并以较小的尺寸发送,但您也可以将其作为 “文件 “发送,以保留原始图像。

      +

      第二个联系人将收到一张, +然后可以点击它与第一个联系人开始聊天。

      @@ -239,16 +239,16 @@ or -

      A profile is a name, a picture and some additional information for encrypting messages. -A profile lives on your device(s) only -and uses the server only to relay messages.

      +

      一份账户资料包括姓名、照片和一些额外的加密信息。 +个人配置只存在于您的设备(们)中 +并使用聊天邮件或传统电子邮件服务器传输信息。

      首次安装Delta Chat 时,会创建第一个账户资料文件。

      之后,您可以点击左上角的个人资料图像,添加个人账户切换账户

      -

      You may want to use separate profiles for political, family or work related activities.

      +

      您可能需要为政治、家庭或工作相关活动使用单独的个人资料。

      您可能还想了解 如何在多台设备上使用同一账户资料

      @@ -267,15 +267,15 @@ and uses the server only to relay messages.

      - Can I set a Bio/Status with Delta Chat? + 我可以用Delta Chat 设置个人介绍/个人照片/状态吗?

      -

      Yes, -you can do so under Settings → Profile → Bio. -Once you sent a message to a contact, -they will see it when they view your contact details.

      +

      是的、 +您可以在“设置 > 个人资料 > 签名文本”下执行此操作。 +向联系人发送信息后、 +他们在查看您的联系人详情时就会看到。

      @@ -295,8 +295,9 @@ they will see it when they view your contact details.

      静音聊天,如果您不想再得到关于它们的通知。被静音的聊天会呆在原地,并且您可以固定被静音的聊天。

    • -

      如果您不想再在聊天列表中看到聊天记录,请归档聊天。 -已归档的聊天仍可在聊天列表上方或通过搜索访问。

      +

      如果不想再在聊天列表中看到聊天内容,归档聊天。 +已归档的聊天仍可在聊天列表上方或通过搜索访问。 +并标记为存档

    • 当被归档的聊天接收到一条新消息,除非其被静音,它会从归档中弹出并返回聊天列表。 @@ -349,13 +350,13 @@ they will see it when they view your contact details.

      -

      You can sometimes see a green dot -next to the avatar of a contact. -It means they were recently seen by you in the last 10 minutes, -e.g. because they messaged you or sent a read receipt.

      +

      有时您会在联系人头像旁边看到一个绿点 +在联系人的头像旁边。 +这意味着您在过去 10 分钟内**最近看到过他们、 +例如,因为他们给您发了信息或发送了阅读回执。

      -

      So this is not a real time online status -and others will as well not always see that you are “online”.

      +

      因此,这不是实时在线状态 +其他人也不会总是看到你 “在线”。

      @@ -367,19 +368,18 @@ and others will as well not always see that you are “online”.

      • -

        One tick -means that the message was sent successfully to the relay.

        +

        一勾** +表示信息已成功发送到 中继

      • -

        Two ticks -indicate your contact has read the message.

        +

        两勾** +表示您的联系人已阅读信息。

      -

      In groups the second tick means that at least one member has reported back having read the message.

      +

      群组 中,第二个”√”表示至少有一名成员报告已阅读该信息。

      -

      You will only get the second tick if both you and one of the recipients who read the message -has Settings → Chats → Read Receipts enabled.

      +

      只有您和其中一位阅读过信息的收信人都启用了设置 → 隐私 → 已读回执,您才会得到第二个勾。

      @@ -406,6 +406,32 @@ has Settings → Chats → Read Receipts enabled.

      需特别提示:若聊天成员已对消息进行过回复、转发、保存本地、截图留存或其他形式的复制操作, 即使您后续编辑了原始消息,对方设备上仍可能留存原内容。

      +

      + + + How is media quality handled? + + +

      + +

      Images, videos, files, voice messages etc. can be sent using the Paperclip Attach- +or Microphone Voice Message buttons.

      + +
        +
      • +

        By default, compression ensures fast, efficient delivery that respects everyone’s data limits and storage. +This is ideal for everyday communication.

        +
      • +
      • +

        In regions with worse connectivity, +you can choose higher compression at Settings → Chats → Outgoing Media Quality.

        +
      • +
      • +

        If you specifically need to send media in its original quality, use Paperclip Attach → File in the chat. +Please use this method sparingly, as sending original files will significantly increase data usage for you and all recipients in the chat.

        +
      • +
      +

      @@ -414,28 +440,25 @@ has Settings → Chats → Read Receipts enabled.

      -

      You can turn on “disappearing messages” -in the settings of a chat, -at the top right of the chat window, -by selecting a time span -between 5 minutes and 1 year.

      +

      您可以在聊天设置中开启 “ 消息定时销毁 “功能 +在聊天设置中、 +聊天窗口右上方、 +选择时间范围 +5 分钟到 1 年之间。

      -

      Until the setting is turned off again, -each chat member’s Delta Chat app takes care -of deleting the messages -after the selected time span. -The time span begins -when the receiver first sees the message in Delta Chat. -The messages are deleted both, -on the servers, -and in the apps itself.

      +

      在再次关闭该设置之前, +每个聊天成员的 Delta Chat 应用都会负责 +在选定的时间跨度后删除消息。 +时间跨度从 +接收者首次在 Delta Chat 中看到消息时开始。 +消息将在 +服务器上的每个电子邮件帐户中以及应用本身中删除。

      请注意,只有当您信任您的聊天伙伴时,您才可以依赖“消息定时销毁”; 不怀好意的人可能会拍照,或者在删除之前以其他方式保存、复制或转发消息。

      -

      Apart from that, -if one chat partner uninstalls Delta Chat, -the (anyway encrypted) messages may take longer to get deleted from their server.

      +

      但是,如果一个聊天伙伴卸载了Delta Chat 、 +(加密)信息可能需要更长的时间才能从他们的服务器上删除。

      @@ -452,39 +475,39 @@ the (anyway encrypted) messages may take longer to get deleted from their server

      - How can I delete my chat profile? + 如何删除我的账户?

      -

      If you are using more than one chat profile, -you can remove single ones in the top profile switcher menu (on Android and iOS), -or in the sidebar with a right click (in the Desktop app). -Chat profiles are only removed on the device where deletion was triggered. -Chat profiles on other devices will continue to fully function.

      +

      如果您使用多个聊天配置文件、 +你可以在顶部的配置文件切换器菜单中删除单个配置文件(在 Android 和 iOS 上)、 +或在侧边栏中右键单击(在桌面应用中)。 +聊天配置文件只会在本设备上删除。 +其他设备上的聊天配置文件将继续完整运行。

      -

      If you use a single default chat profile you can simply uninstall the app. -This will still automatically trigger deletion of all associated address data on the chatmail server. -For more info, please refer to nine.testrun.org address-deletion -or the respective page from your chosen 3rd party chatmail server.

      +

      如果您只使用一个默认聊天配置文件,可以直接卸载应用程序。 +这将自动触发删除 Chatmail 服务器上的所有关联帐户数据。 +有关更多信息,请参阅 nine.testrun.org address-deletion +或您选择的 第三方 Chatmail 服务器 的相关页面。

      - Groups + 群组

      -

      Groups let several people chat together privately with equal rights.

      +

      群组允许几个人私下一起聊天,他们享有相同的权力

      -

      Anyone can -change the group name or avatar, -add or remove members, -set disappearing messages, -and delete their own messages from all member’s devices.

      +

      任何人都可以 +更改群组名称或头像、 +[添加或删除成员](#addmembers)、 +设置[消息自动销毁](#ephemeralmsgs)、 +删除自己的信息

      -

      Because all members have the same rights, groups work best among trusted friends and family.

      +

      由于所有成员都拥有相同的权力,因此群组在信任的朋友和家人之间效果最佳。

      @@ -509,33 +532,31 @@ and delete their own messages from all member’s devices. - Add and remove members + 添加和删除成员

      -

      All group members have the same rights. -For this reason, everyone can delete any member or add new ones.

      +

      所有群组成员都拥有**相同的权限。 +因此,每个人都可以删除任何成员或添加新成员。

      • -

        To add or delete members, tap the group name in the chat and select the member to add or remove.

        +

        添加或删除成员,请点击聊天中的群组名称,然后选择要添加或删除的成员。

      • -

        If the member is not yet in your contact list, but face to face with you, -from the same screen, show a QR code.
        -Ask your chat partner to scan the QR image with their Delta Chat app by tapping - on the main screen.

        +

        如果会员还不在您的联系人列表中,但可以与您面对面、 +在同一屏幕上,显示二维码
        +在 Delta Chat 主屏幕上点击。

      • -

        For a remote member addition, -click “Copy” or “Share” and send the invite link -through another private chat to the new member.

        +

        如需添加远程成员、 +点击 “复制 “或 “分享”,然后通过另一个私聊向新会员发送邀请链接

      -

      QR code and invite link can be used to add several members. -However, since groups are meant for trusted people, avoid sharing them publicly.

      +

      二维码和邀请链接可用于添加多个成员。 +不过,由于群组是为信任的人准备的,因此应避免公开分享。

      @@ -561,122 +582,123 @@ However, since groups are meant for trusted people, avoid

      另外,您也可以“静音”群组——这样做意味着您会收到所有消息并且仍可以编写消息,但不会再收到任何新消息的通知。

      -

      +

      - Cloning a group + 克隆群组

      -

      You can duplicate a group to start a separate discussion -or to exclude members without them noticing.

      +

      您可以复制一个组以开始单独的讨论 +或在不被成员察觉的情况下排除成员。

      • -

        Open the group profile and tap Clone Chat (Android/iOS), -or right-click the group in the chat list (Desktop).

        +

        打开群组配置文件,然后点击 ** 克隆聊天** (Android/iOS)、 +或右键单击聊天列表中的群组(桌面)。

      • -

        Set a new name, choose an avatar, and adjust the member list if needed.

        +

        设置新名称、选择头像,并根据需要调整成员名单。

      -

      The new group is fully independent from the original, -which continues to work as before.

      +

      新群组与原群组完全独立、 +新群组将一如既往地工作。

      -

      +

      - How many members can participate in a single group? + 一个小组可以有多少成员? -

      + -

      There is no strict technical limit, -but more than 150 is not recommended.

      +

      没有严格的技术限制、 +但不建议超过 150 个。

      -

      As groups get larger, they can become socially unstable and may need a hierarchy - -where Delta Chat is a private messenger for chatting with equal rights. -See Dunbar’s number for more insights.

      +

      随着群体规模的扩大,他们的社会地位可能会变得不同,可能需要一个等级制度。 +其中Delta Chat 是与平等权利 聊天的私人信使。 +相关知识,请参阅邓巴数

      - In-chat apps + Webxdc 应用

      -

      You can send apps to a chat - games, editors, polls and other tools. -This makes Delta Chat a truly extensible messenger.

      +

      您可以向聊天发送应用程序–游戏、编辑器、投票和其他工具。 +这使得Delta Chat 成为一个真正可扩展的聊天工具。

      -

      +

      - Where can I get in-chat apps? + 我在哪里可以获得 Webxdc 应用?

      • -

        In a chat, using Paperclip Attachment Button → Apps

        +

        在聊天中,使用Paperclip 附件按钮 → 应用程序

      • -

        You can also create your own app and attach it using Paperclip Attachment Button → File

        +

        您还可以[创建自己的应用程序](#create-xdc),并使用Paperclip 附件按钮 → 文件附加它。

      -

      +

      - How private are in-chat apps? + Webxdc 应用的隐私性如何?

      • -

        In-chat apps can not send data to the Internet, or download anything.

        +

        聊天应用程序不能向互联网发送数据,也不能下载任何东西。

      • -

        An in-chat app can only exchange data within a chat, with its -copies on the devices of your chat partners. Other than that, it’s completely -isolated from the Internet.

        +

        聊天中的应用程序只能在对话中交换数据,其可以与 +聊天对象设备上的副本进行数据交换。除此之外,它完全 +与互联网隔离。

      • -

        The privacy an in-chat app offers is the privacy of your chat - as long as you -trust the people you chat with, you can trust the in-chat app as well.

        -
      • -
      • -

        This also means: Just like for web links, do not open apps from untrusted contacts.

        +

        聊天应用提供的隐私保护仅限于您的聊天隐私——只要您

      + +

      只要您信任与您聊天的人,您也可以信任聊天应用本身。

      + +
        +
      • 这也意味着:就像网页链接一样,不要打开来自不可信联系人的应用程序。
      • +
      -

      +

      - How can I create my own in-chat apps? + 如何创建自己的 WebXDC 应用?{#create-xdc}

      • -

        In-chat apps are zip files with .xdc extension containing html, css, and javascript code.

        +

        聊天应用程序是以.xdc为扩展名的压缩文件,包含 html、css 和 javascript 代码。

      • -

        You can extend the Hello World example app -to get started.

        +

        您可以扩展 Hello World 示例应用程序 +来快速入门。

      • -

        All else you need to know is written in the -Webxdc documentation.

        +

        您需要了解的所有其他信息都写在 +Webxdc 文档

      • -

        If you have question, you can ask others with experience -in the Delta Chat Forum.

        +

        如果您有问题,可以在 [Delta Chat 论坛] (https://support.delta.chat/c/webxdc/20)。

      @@ -734,8 +756,8 @@ in the Delta Chat Forum.

      -

      If a “Push Service” is available, Delta Chat enables Push Notifications -to achieve instant message delivery for all chatmail users.

      +

      如果推送服务可用,Delta Chat 会启用推送通知 +为所有聊天邮件用户发送即时信息。

      在 Delta Chat“通知”的“推送通知”设置中,您可以更改以下影响所有聊天配置文件的设置:

      @@ -769,9 +791,8 @@ to achieve instant message delivery for all chatmail users.

      -

      Delta Chat Push Notification support avoids leakage of private information. -It does not leak profile data, IP address or message content (not even encrypted) -to any system involved in the delivery of Push Notifications.

      +

      Delta Chat 推送通知支持可避免泄漏私人信息。 +它不会将个人资料数据、IP 地址或信息内容(即使是加密的)泄露给参与通知推送的任何系统。

      以下是 Delta Chat 应用如何执行推送通知传递:

      @@ -781,21 +802,21 @@ to any system involved in the delivery of Push Notifications.

      Chatmail 服务器上。

    • -

      When a chatmail server receives a message for a Delta Chat user -it forwards the encrypted device token to the central Delta Chat notification proxy.

      +

      chatmail 服务器收到Delta Chat 用户的信息时 +就会将加密设备令牌转发到Delta Chat 中央通知代理。

    • -

      The central Delta Chat notification proxy decrypts the device token -and forwards it to the respective Push service (Apple, Google, etc.), -without ever knowing the IP or profile data of Delta Chat users.

      +

      中央 Delta Chat 通知代理解密设备令牌 +并将其转发到相应的推送服务(Apple、Google 等), +而永远不知道 Delta Chat 用户的 IP 或电子邮件地址。

    • -

      The central Push Service (Apple, Google, etc.) -wakes up the Delta Chat app on your device -to check for new messages in the background. -It does not know about the profile data of the device it wakes up. -The central Apple/Google Push services never see any profile data (sender or receiver) -and also never see any message content (also not in encrypted forms).

      +

      中央推送服务(Apple、Google 等) +唤醒你设备上的 Delta Chat 应用 +,以便在后台检查新消息。 +它不知道它唤醒的设备的 Chatmail 或电子邮件地址。 +中央 Apple/Google 推送服务永远不会看到电子邮件地址(发件人或收件人), +也永远不会看到任何消息内容(也包括未加密的形式)。

    @@ -803,10 +824,10 @@ and also never see any message content (also not in encrypted forms).

    ,并在 Apple/Google 等处理设备令牌后立即忘记它们, 通常在几毫秒内。

    -

    Note that the device token is encrypted between apps and notification proxy -but it is not signed. -The notification proxy thus never sees profile data, IP-addresses or -any cryptographic identity information associated with a user’s device (token).

    +

    请注意,设备令牌在应用和通知代理之间加密, +但未签名。 +因此,通知代理永远不会看到与用户设备(令牌)关联的电子邮件地址、IP 地址或 +任何加密身份信息。

    由此产生的整体隐私设计,即使查封 Chatmail 服务器, 或完全查封中央 Delta Chat 通知代理 @@ -825,10 +846,10 @@ any cryptographic identity information associated with a user’s device (token) 就像他们从 Whatsapp、Signal 或 Telegram 应用体验到的那样, 而无需预先提出更适合专家用户或开发人员的问题。

    -

    Note that Delta Chat has a small and privacy-preserving Push Notification system -that achieves “instant delivery” of messages for all chatmail servers -including a potential one you might setup yourself without our permission. -Welcome to the power of the interoperable chatmail relay network :)

    +

    请注意,Delta Chat 具有小型且隐私保护的推送通知系统, +可为所有 Chatmail 服务器实现“即时消息传递”, +包括你可能在未经我们许可的情况下自行设置的服务器。 +欢迎来到可互操作且庞大的 Chatmail 和电子邮件系统的力量 :)

    @@ -962,167 +983,182 @@ Welcome to the power of the interoperable chatmail relay network :)

  • 如果是因为不能在工作的电脑上安装软件而需要一个 Web 客户端,您可以使用便携版的 Windows 桌面客户端,或者在 Linux 上使用 AppImage 版。您可以在 get.delta.chat 找到它们。
  • -

    +

    - Advanced + 高级

    -

    +

    - Experimental Features + 实验性功能 -

    + -

    At Settings → Advanced → Experimental Features -you can try out features we are working on.

    +

    设置 → 高级 → 实验功能 +您可以试用我们正在开发的功能。

    -

    The features may be unstable and may be changed or removed.

    +

    这些功能可能不稳定,可能会更改或删除

    -

    You can find more information -and give feedback in the Forum.

    +

    您可以在 论坛 提供反馈。

    - What are Relays? + 什么是中继服务器?

    -

    Relays are used to temporarily hold messages in case your device is offline. -Relays are cheap and dumb servers, -that do not store data as group states, your name or avatar - -all that exist only on your device. -Relays are operated by different groups and people.

    +

    中继服务器用于在您的设备离线时临时保存消息。

    -

    By default, after installation, a relay is automatically set up, -so you do not need to care about that. -However, if you want to, -you can configure relays at At Settings → Advanced → Relays:

    +

    中继服务器是廉价且功能简单的服务器,

    + +

    它们不会存储群组状态、您的姓名或头像等数据——

    + +

    这些数据仅存在于您的设备上。

    + +

    中继服务器由不同的团队和个人运营。

    + +

    默认情况下,安装完成后会自动设置中继服务器,

    + +

    因此您无需担心。

    + +

    但是,如果您需要,

    + +

    可以在“设置 → 高级 → 中继服务器”中配置:

    + +
      +
    • 您可以通过扫描二维码来添加中继;
    • +
    + +

    https://chatmail.at/relays 显示了一些已知的中继服务器。

    + +

    如果您添加了多个中继,您将在所有中继上收到消息。

    • -

      You can add a relay by scanning its QR code; -https://chatmail.at/relays shows some known ones. -If you have multiple relays, you will receive messages on all of them.

      +

      默认地址确定了聊天伙伴今后发送消息的目标地址。

    • -

      The default defines the one where your chat partners send future messages to.

      -
    • -
    • -

      If you remove a relay, -make sure another default relay was used for a sufficient amount of time. -Otherwise, messages from your chat partners won’t reach you. -If in doubt, remove later.

      +

      如果您移除某个中继服务器,

    -

    For more details and future possibilities of relays, -you can follow discussions in the Forum.

    +

    请确保已使用另一个默认中继服务器足够长的时间。

    + +

    否则,您将无法收到聊天伙伴的消息。

    + +

    如有问题,请稍后再移除。

    + +

    有关中继服务器的更多细节和未来可能性、 +您可以关注 论坛 中的讨论。

    -

    +

    - Can I use a classic email address with Delta Chat? + 我可以在Delta Chat 中使用传统电子邮件吗? -

    + -

    Yes, but only if the email address is used exclusively by chatmail clients.

    +

    可以,但前提是该电子邮件地址只能由 chatmail 客户端 使用。

    -

    It is not supported to share usage of an email address with non-chatmail apps or web-based mailers, -for the following reasons:

    +

    不支持与非聊天邮件应用程序或网络邮件程序共享电子邮件地址、 +原因如下:

    • -

      Non-chatmail apps are largely not accomplishing automatic end-to-end email encryption for their users, -while chatmail apps and relays pervasively enforce end-to-end encryption and security standards.

      +

      非聊天邮件应用程序在很大程度上无法为用户实现自动端到端电子邮件加密 、 +而 Chatmail 则普遍执行端到端加密 和安全标准。

    • -

      Non-chatmail apps use email servers as a long-term message archive -while chatmail clients use email servers for ephemeral instant message relay.

      +

      非聊天邮件应用程序使用电子邮件服务器作为长期信息存档 +而聊天邮件客户端使用电子邮件服务器作为短暂的即时信息中继。

    • -

      Supporting the full variety of classic email setups -would require considerable development and maintenance efforts, -and complicate making chatmail-based messaging more resilient, reliable and fast.

      +

      支持各种经典的电子邮件设置 +需要大量的开发和维护工作、 +这将使基于聊天邮件的信息传送变得更加灵活、可靠和快速。

    -

    +

    - How can I configure a chat profile with a classic email address as relay? + 如何使用经典电子邮件地址作为中继配置聊天配置文件? -

    + -

    First off, please do not use the same classic email address also from non-chatmail classic email apps -unless you are prepared to deal with encrypted messages in the inbox, -double notifications, accidentally deleted emails or similar annoyances.

    +

    首先,请不要在非 Chatmail 经典电子邮件应用程序中使用相同的经典电子邮件地址。 +除非你准备好处理收件箱中的加密邮件、 +双重通知、误删邮件或类似的烦恼。

    -

    You can configure a email address for chatting at New Profile → Use Other Server → Use Classic Mail as Relay. -Note that classic email providers will generally not support Push Notifications -and have other limitations, see Provider Overview. -Chatmail uses the default INBOX for relay; ensure the provider setup does too. -A chat profile using a classic email address allows to to send and receive unencrypted messages. -These messages, and the chats they appear in, are marked with an email icon +

    您可以在 ** 新配置文件 → 使用其他服务器 → 使用经典邮件作为中继** 配置用于聊天的电子邮件地址。 +请注意,经典电子邮件提供商一般不支持 推送通知 +并有其他限制,请参阅提供商概述。 +Chatmail 使用默认的 INBOX 进行中继;请确保提供商的设置也是如此。 +使用传统电子邮件地址的聊天配置文件允许收发未加密信息。 +这些信息及其在聊天中出现的信息都会用电子邮件图标标记 email.

    -

    +

    - I want to manage my own server for Delta Chat. What do you recommend? + 我想管理自己的服务器Delta Chat 。您有什么建议? -

    + -

    Any well behaving email server setup will do fine -except if your users’ devices require Google/Apple Push Notifications to work properly.

    +

    任何表现良好的电子邮件服务器设置都可以正常工作 +除非用户的设备需要 Google/Apple 推送通知 才能正常工作。

    -

    We generally recommend to set up a chatmail relay. -Chatmail is a community-driven project that encompasses both the setup of relays -and core Rust developments -that power chatmail clients of which Delta Chat is the most well known.

    +

    我们通常建议您设置一个聊天邮件中继服务器

    + +

    Chatmail是一个社区驱动的项目,涵盖了中继的设置

    + +

    以及核心Rust开发

    + +

    这些核心Rust开发为聊天邮件客户端提供支持,其中Delta Chat是最知名的客户端。

    -

    +

    - What is “Send statistics to Delta Chat’s developers”? + 什么是 “向Delta Chat’开发人员发送统计数据”? -

    + -

    We would like to improve Delta Chat with your help, -which is why Delta Chat for Android asks whether you want -to send anonymous usage statistics.

    +

    我们希望在您的帮助下改进Delta Chat 、 +这就是为什么Delta Chat for Android 会询问您是否希望 +发送匿名使用统计数据。

    -

    You can turn it on and off at -Settings → Advanced → Send statistics to Delta Chat’s developers.

    +

    您可以在以下位置打开或关闭它 +设置 → 高级 → 将统计数据发送给Delta Chat 的开发人员

    -

    When you turn it on, -weekly statistics will be automatically sent to a bot.

    +

    打开后 +每周统计数据将自动发送到机器人。

    -

    We are interested e.g. in statistics like:

    +

    我们感兴趣的统计数据包括

    • -

      How many contacts are introduced by personally scanning a QR code?

      +

      通过亲自扫描二维码,有多少人被介绍认识?

    • -

      Which versions of Delta Chat are being used?

      +

      使用的是Delta Chat 的哪些版本?

    • -

      What errors occur for users?

      +

      用户出现哪些错误?

    -

    We will not collect any personally identifiable information about you.

    +

    我们不会收集您的任何个人身份信息。

    @@ -1163,8 +1199,8 @@ weekly statistics will be automatically sent to a bot.

    用于在联系人和群聊的所有成员之间自动建立端到端加密。

  • -

    Autocrypt v2, scheduled for full implementation in 2026, -will bring post-quantum resistant encryption and forward secrecy.

    +

    Autocrypt v2,计划于 2026 年全面实施、 +将带来后量子抗加密 和前向保密。

  • 将联系人分享到聊天中 @@ -1175,22 +1211,22 @@ will bring post-quantum resistant encryption and forward secrecy.

    Delta Chat 不会查询、发布或与任何 OpenPGP 密钥服务器交互。

    -

    +

    - How can I know if messages are end-to-end encrypted? + 我如何知道信息是否经过端到端加密?{#whene2e} -

    +
  • Delta Chat 中的所有消息 默认都采用端到端加密。 自 Delta Chat 版本 2 发布系列(2025 年 7 月)起, 端到端加密消息上不再有锁或类似的标记。

    -

    +

    - Can I still receive or send messages without end-to-end encryption? + 没有端到端加密 ,我还能接收或发送信息吗?

    @@ -1198,29 +1234,29 @@ will bring post-quantum resistant encryption and forward secrecy.

    如果您使用默认的 chatmail 中继, 则不可能在没有端到端加密的情况下接收或发送消息。

    -

    If you instead use a classic email server, -you can send and receive messages with or without end-to-end encryption. -Messages lacking end-to-end encryption are marked with an email icon +

    如果您使用的是 classic 电子邮件服务器、 +您可以发送和接收带有或不带端到端加密 的邮件。 +没有端到端加密 的邮件会用电子邮件图标标出 email.

    - What does the green checkmark in a contact profile mean? + 联系人资料中的绿色复选标记是什么意思?

    -

    A contact profile might show a green checkmark -green checkmark -and an “Introduced by” line. -Every green-checkmarked contact either did a direct QR-scan with you -or was introduced by a another green-checkmarked contact. -Introductions happen automatically when adding members to groups. -Whoever adds a green-checkmarked contact to a group with only green-checkmarked members -becomes an introducer. -In a contact profile you can tap on the “Introduced by …” text repeatedly -until you get to the one with whom you directly did a QR-scan.

    +

    带有绿色复选标记的联系人配置文件 +绿色复选标记 +表示当前保证与联系人的消息传递是端到端加密的。 +每个带有绿色复选标记的联系人要么直接与你进行了 二维码扫描, +要么由另一个带有绿色复选标记的联系人介绍。 +当向群组添加成员时,介绍会自动发生。 +任何将联系人添加到带有绿色复选标记的群组的人都成为 +那些还不认识添加的联系人的成员的介绍人。 +在联系人配置文件中,你可以反复点击“由…介绍”文本 +,直到你到达直接与你进行 二维码扫描 的那个人。

    有关“保证的端到端加密”的更深入讨论, 请参阅 安全加入协议, @@ -1249,9 +1285,11 @@ until you get to the one with whom you directly did a QR-sc -

    Yes, Delta Chat uses a secure subset of OpenPGP -requiring the whole message to be properly encrypted and signed. -For example, “Detached signatures” are not treated as secure.

    +

    是的,Delta Chat 使用的是 OpenPGP 的一个安全子集,

    + +

    它要求整条消息都必须经过正确的加密和签名。

    + +

    例如,“分离式签名”不被视为安全。

    OpenPGP 加密标准本身不存在安全隐患。 目前公众讨论中涉及的 OpenPGP 安全问题, @@ -1267,18 +1305,18 @@ Delta Chat 实际使用的是 Rust 语言编写的 OpenPGP 实现库 新的 IETF OpenPGP Crypto-Refresh 来进一步提高安全特性,该标准已于 2023 年夏季获得通过,令人欣慰。

    -

    +

    - Did you consider using alternatives to OpenPGP for end-to-end-encryption? + 您是否考虑过使用 OpenPGP 的替代版端到端加密 ?{#openpgp-alternatives} -

    + -

    Yes, we are following efforts like MLS -but adopting them would mean breaking end-to-end encryption interoperability. -So it would not be a light decision to take -and there must be tangible improvements for users.

    +

    是的,我们正在关注 MLS等努力。 +但采用它们将意味着破坏端到端加密 的互操作性。 +因此,这不是一个轻而易举的决定。 +而且必须为用户带来切实的改进。

    Delta Chat 采用整体“可用安全性”方法, 并与广泛的活动家团体以及 @@ -1307,29 +1345,28 @@ and there must be tangible improvements for users.

    这些消息正好包含一个加密和签名的部分, 如 Autocrypt Level 1 规范所定义。

    -

    +

    - Are messages marked with the mail icon exposed on the Internet? + 带有邮件图标的邮件是否会在互联网上公开显示?{#tls}

    -

    If you are sending or receiving email messages without end-to-end encryption (using a classic email server), -they are still protected from cell or cable companies who can not read or modify your email messages. -But both your and your recipient’s email providers -may read, analyze or modify your messages, including any attachments.

    +

    如果您发送或接收电子邮件时没有端到端加密 (使用传统电子邮件服务器)、 +它们仍然受到手机或有线电视公司的保护,他们无法阅读或修改您的电子邮件。 +但您和收件人的电子邮件提供商 +可以阅读、分析或修改您的邮件,包括任何附件。

    -

    Delta Chat by default uses strict -TLS encryption -which secures connections between your device and your email provider. -All of Delta Chat’s TLS-handling has been independently security audited. -Moreover, the connection between your and the recipient’s email provider -will typically be transport-encrypted as well. -If the involved email servers support MTA-STS -then transport encryption will be enforced between email providers -in which case Delta Chat communications will never be exposed in cleartext to the Internet -even if the message was not end-to-end encrypted.

    +

    Delta Chat 默认使用严格的 +TLS 加密, +这可以保护你的设备和电子邮件提供商之间的连接安全。 +Delta Chat 的所有 TLS 处理都经过了独立的 安全审计。 +此外,你的和接收者的电子邮件提供商之间的连接 +通常也会进行传输加密。 +如果所涉及的电子邮件服务器支持 MTA-STS, +则将在电子邮件提供商之间强制执行传输加密, +在这种情况下,即使消息未进行端到端加密,Delta Chat 通信也永远不会以明文形式暴露给互联网。

    @@ -1339,18 +1376,18 @@ even if the message was not end-to-end encrypted.

    -

    Unlike most other messengers, -Delta Chat apps do not store any metadata about contacts or groups on servers, also not in encrypted form. -Instead, all group metadata is end-to-end encrypted and stored on end-user devices, only.

    +

    与其他大多数聊天工具不同、 +Delta Chat 应用程序不会在服务器上存储任何有关联系人或群组的元数据,也不会以加密形式存储。 +相反,所有群组元数据都经过端到端加密,仅存储在终端用户设备上。

    -

    Servers can therefore only see:

    +

    因此,服务器只能看到

      -
    • Sender and receiver addresses, randomly generated by default
    • -
    • Message size
    • +
    • 发件人和收件人地址,默认情况下随机生成
    • +
    • 信息大小
    -

    All other message, contact and group metadata resides in the end-to-end encrypted part of messages.

    +

    所有其他信息、联系人和群组元数据都保存在信息的端到端加密部分。

    @@ -1360,62 +1397,56 @@ Instead, all group metadata is end-to-end encrypted and stored on end-user devic

    -

    Both for protecting against metadata-collecting servers -as well as against the threat of device seizure -we recommend to use a chatmail relay -to create chat profiles using random addresses for transport. -Note that Delta Chat apps on all platforms support multiple profiles -so you can easily use situation-specific profiles next to your “main” profile -with the knowledge that all their data, along with all metadata, will be deleted. -Moreover, if a device is seized then chat contacts using short-lived profiles -can not be identified easily.

    +

    为了防止收集元数据的电子邮件服务器 +以及设备查封的威胁, +我们建议使用 Chatmail 服务器 +通过二维码扫描创建匿名临时配置文件。 +请注意,所有平台上的 Delta Chat 应用都支持多配置文件, +因此你可以轻松地在你“主要”配置文件旁边使用特定于情况的配置文件, +并且知道它们的所有数据以及所有元数据都将被删除。 +此外,如果设备被查封,则与使用临时配置文件的联系人 +相比,无法轻易识别,因为即时通讯应用会在聊天群组中显示 +电话号码,而电话号码通常与合法身份相关联。

    -

    +

    - Who sees my IP Address? + 谁能看到我的 IP 地址? -

    + -

    The used relay needs to know your IP Address, -as well as sometimes your contact’s devices if you have a call -or use apps together.

    +

    使用的 中继服务器 需要知道您的 IP 地址、 +有时还需要知道联系人的设备(如果你们有 通话),或一起使用 Webxdc应用程序

    -

    IP Addresses are needed for connectivity and efficiency. -They are neither persisted nor exposed. -Note that the IP Address -is not like a detailed address you give to a delivery service, -but much more coarse, often defining region or country only.

    +

    IP 地址是连接和提高效率所必需的。 +它们既不会持久存在,也不会暴露。 +请注意,IP 地址 +不像你给快递服务的详细地址、 +而是更粗略,通常只定义地区或国家。

    -

    As this is just how the internet and other messengers work by default, -we do not offer options here or ask upfront questions.

    +

    这只是互联网和其他信使的默认工作方式、 +我们在此不提供选项,也不预先提问。

    -

    If you see your IP Address as a security or privacy risk, -we recommend to use a VPN, in combination with system lockdown mode. -Hunting down options in all apps on your system will leave gaps. -For example, tapping a link exposes IP Addresses to unknown parties and is the by far larger risk here.

    - -

    - - - Does Delta Chat support “Sealed Sender”? - - -

    +

    如果你认为你的 IP 地址存在安全或隐私风险、 +我们建议使用 VPN 并结合系统锁定模式。 +在系统的所有应用程序中查找选项会留下漏洞。 +例如,点击链接会将 IP 地址暴露给未知方,这是目前最大的风险。

    -

    No, not yet.

    +

    ###Delta Chat 是否支持 “密封发件人”?{#sealedsender}

    -

    The Signal messenger introduced “Sealed Sender” in 2018 -to keep their server infrastructure ignorant of who is sending a message to a set of recipients. -It is particularly important because the Signal server knows the mobile number of each account, -which is usually associated with a passport identity.

    +

    不,还没有。

    -

    Even if chatmail relays -do not ask for any private data (including no phone numbers), -it might still be worthwhile to protect relational metadata between addresses. -We don’t foresee bigger problems in using random throw-away addresses for sealed sending -but an implementation has not been agreed as a priority yet.

    +

    Signal 信使在 2018 年推出“密封发件人” +以保持其服务器基础设施不知道谁在向一组收件人发送信息。 +这一点尤为重要,因为 Signal 服务器知道每个账户的手机号码、 +这通常与个人身份信息相关联。

    + +

    即使 chatmail relays +不要求提供任何私人数据(包括电话号码)、 +还是值得保护地址之间的关系元数据。 +我们认为使用随机地址进行密封发送不会有太大问题。 +但目前还没有达成优先实施方案。

    @@ -1425,44 +1456,44 @@ but an implementation has not been agreed as a priority yet.

    -

    Not yet, but it’s coming with Autocrypt v2.

    +

    目前还没有,但 Autocrypt v2 会提供。

    -

    Delta Chat today doesn’t support Perfect Forward Secrecy (PFS). -This means that if your private decryption key is leaked, -and someone has collected your prior in-transit messages, -they will be able to decrypt and read them using the leaked decryption key. -Note that Forward Secrecy only increases security if you delete messages. -Otherwise, someone obtaining your decryption keys -is typically also able to get all your non-deleted messages -and doesn’t even need to decrypt any previously collected messages.

    +

    Delta Chat 如今不支持完美前向保密(PFS)。 +这意味着,如果你的私人解密密钥被泄露、 +并且有人收集了你之前的传输中信息、 +他们就能使用泄漏的解密密钥解密并读取这些信息。 +请注意,只有在删除信息的情况下,前向保密才能提高安全性。 +否则,获得你的解密密钥的人 +通常也能获得所有未删除的信息 +甚至不需要对以前收集的任何信息进行解密。

    -

    Autocrypt v2, scheduled for full implementation in 2026, -will provide reliable deletion (forward secrecy) through automatic key rotation. -This approach is specified in the Autocrypt v2 OpenPGP Certificates draft.

    +

    Autocrypt v2计划于2026年全面实施,

    + +

    它将通过自动密钥轮换提供可靠的删除(前向保密)。

    + +

    该方法在Autocrypt v2 OpenPGP证书草案中进行了详细说明。

    + +

    ###Delta Chat 支持后量子加密吗?{#pqc}

    + +

    目前还没有,但 Autocrypt v2 会提供。

    + +

    Autocrypt v2计划于2026年全面实施,

    + +

    它将带来后量子加密技术,以抵御量子计算机攻击。

    + +

    Delta Chat使用Rust OpenPGP库rPGP

    + +

    该库支持最新的IETF后量子加密OpenPGP草案

    + +

    具体实现详见Autocrypt v2 OpenPGP证书草案。

    -

    +

    - Does Delta Chat support Post-Quantum-Cryptography? + 如何手动检查加密 信息? -

    - -

    Not yet, but it’s coming with Autocrypt v2.

    - -

    Autocrypt v2, scheduled for full implementation in 2026, -will bring post-quantum resistant encryption to protect against quantum computer attacks. -Delta Chat uses the Rust OpenPGP library rPGP -which supports the latest IETF Post-Quantum-Cryptography OpenPGP draft. -The implementation is specified in the Autocrypt v2 OpenPGP Certificates draft.

    - -

    - - - How can I manually check encryption information? - - -

    +

    你可以在“加密”对话框中手动检查端到端加密状态 (Android/iOS 上的用户配置文件或桌面上的用户聊天列表项上右键单击)。 @@ -1480,12 +1511,12 @@ Delta Chat 在此处显示两个指纹。

    不。

    -

    Delta Chat generates secure OpenPGP keys according to the Autocrypt specification 1.1. -We do not recommend or offer users to perform manual key management. -We want to ensure that security audits can focus on a few proven cryptographic algorithms -instead of the full breadth of possible algorithms allowed with OpenPGP. -If you want to extract your OpenPGP key, there only is an expert method: -you need to look it up in the “keypairs” SQLite table of a profile backup tar-file.

    +

    Delta Chat 根据 Autocrypt 规范 1.1 生成安全 OpenPGP 密钥。 +我们不建议或提供用户进行手动密钥管理。 +我们希望确保安全审计能专注于少数几种经过验证的加密算法 +而不是 OpenPGP 允许的所有可能算法。 +如果您想提取 OpenPGP 密钥,只有一种专业方法: +您需要在配置文件备份 tar 文件的 “keypairs “SQLite 表中查找它。

    @@ -1575,10 +1606,10 @@ Chat 的 PGP

    -

    Some features require certain permissions, -e.g. you need to grant camera permission if you want to scan an invite QR code.

    +

    某些功能需要特定权限、 +例如,如果您想[扫描邀请二维码](#howtoe2ee),则需要授予相机权限。

    -

    See Privacy Policy for a detailed overview.

    +

    详见 隐私政策

    @@ -1598,8 +1629,8 @@ e.g. you need to grant camera permission if you want to sca

    如果不可用,请使用 镜像 https://deltachat.github.io/deltachat-pages

  • -

    Open one of the following app stores and search for “Delta Chat”: -Google Play Store, F-Droid, Huawei App Gallery, iOS and macOS App Store, Microsoft Store

    +

    打开下列应用商店之一,搜索 “Delta Chat”: +谷歌应用商店、F-Droid、华为应用商店、iOS 和 macOS 应用商店、微软应用商店

  • 检查你的 Linux 发行版的 软件包管理器

    @@ -1633,8 +1664,13 @@ Google Play Store, F-Droid, Huawei App Gallery, iOS and macOS App Store, Microso 所有这些项目都已部分完成或将在 2025 年初完成。

  • -

    在 2021 年,我们从两项下一代互联网提案收到了欧盟的进一步资助,即 EPPD - 电子邮件提供商可移植性目录(约 9.7 万欧元)和 AEAP - 电子邮件地址移植(约 9 万欧元)。这带来了更好的多账户支持,改进的二维码联系人和群组设置,和所有平台上的多处网络改进。

    +

    2021年,我们获得了欧盟的进一步资助,用于两项下一代互联网提案

  • + + +

    分别是EPPD - 电子邮件提供商可移植性目录(约9.7万欧元)和AEAP - 电子邮件地址可移植性(约9万欧元)。这两项提案带来了更好的多配置文件支持、改进的二维码联系人和群组设置,以及所有平台上的许多网络改进。

    + +
    • NLnet 基金会 2019/2020 年拨款 4.6 万欧元,用于完成 Rust/Python 绑定并建立聊天机器人生态系统。

    • diff --git a/src/main/res/values-ar/strings.xml b/src/main/res/values-ar/strings.xml index ae3d4e94b..4a0386152 100644 --- a/src/main/res/values-ar/strings.xml +++ b/src/main/res/values-ar/strings.xml @@ -157,8 +157,6 @@ استيراد نسخة احتياطية حذف الحساب - استخدام التخصيص: %s - مستخدم كافتراضي: %s معلومات ملفك التعريفي صورة الملف التعريفي المراسلون المحبوسون @@ -174,8 +172,6 @@ لون ضوء التنبيه LED الصوت الخصوصية - - الدردشة والوسائط افتراضيات النظام فاتح diff --git a/src/main/res/values-az/strings.xml b/src/main/res/values-az/strings.xml index 73d3689ef..9b4f758be 100644 --- a/src/main/res/values-az/strings.xml +++ b/src/main/res/values-az/strings.xml @@ -271,8 +271,6 @@ - :Xüsusi istifadə %s - Varsayılan istifadə: %s Profil məlumatınız Profil şəkili Bloklanmış kontaktlar @@ -298,8 +296,6 @@ Səs Səssiz Gizlilik - - Çatlar və faillar Açıq Tünd diff --git a/src/main/res/values-bg/strings.xml b/src/main/res/values-bg/strings.xml index e8a42dc77..d494ed4ff 100644 --- a/src/main/res/values-bg/strings.xml +++ b/src/main/res/values-bg/strings.xml @@ -200,8 +200,6 @@ Нов чат Нова група Клониране на чат - - Добавяне на получатели Изпращане Включване/изключване на Emoji клавиатура Редактиране на група @@ -457,8 +455,6 @@ Входящи съобщения Изходящи съобщения - - Съхранение върху %1$s Свързаност Не сте свързани @@ -551,8 +547,6 @@ - Използва се дефинирано от Вас: %s - Използва се подразбиращо се: %s Информация от Вашия профил Изображение на профила Блокирани контакти @@ -582,8 +576,6 @@ Звук Без звук Поверителност - - Чатове и медийни файлове Подразбиращото се за системата Светъл @@ -726,10 +718,6 @@ Установихте таймера на изчезващите съобщения на %1$s Таймерът на изчезващите съобщение е установен на %1$sсек. от %2$s. - - Установихте таймера на изчезващите съобщения на 1 минута. - - Таймерът на изчезващите съобщения е установен на 1 минута от %1$s. Установихте таймера на изчезващите съобщения на 1 час. Таймерът на изчезващите съобщения е установен на 1 час от %1$s. @@ -815,8 +803,6 @@ Проверен от%1$s Представен от мен - - За да се гарантира криптирането от край до край, можете да добавяте в тази група само контакти, отбелязани със зелена отметка.\n\nБихте могли да се срещнете с лицата, с които желаете да контактувате, лично и да сканирате техните QR кодове, за да ги въведете. Изберете чат, към който да бъде пратено съобщението %1$s вече има съобщение чернова, искате ли да го замените? diff --git a/src/main/res/values-bqi/strings.xml b/src/main/res/values-bqi/strings.xml index 6114f5cff..3e928d7eb 100644 --- a/src/main/res/values-bqi/strings.xml +++ b/src/main/res/values-bqi/strings.xml @@ -378,8 +378,6 @@ نشووݩ داڌن وارسگر فشناڌن پیوم - - زفت کردن من %1$s هونی منپیز ابۊ... diff --git a/src/main/res/values-ca/strings.xml b/src/main/res/values-ca/strings.xml index 034089403..396e8728c 100644 --- a/src/main/res/values-ca/strings.xml +++ b/src/main/res/values-ca/strings.xml @@ -28,7 +28,7 @@ Uneix-te Torna a unir-te Esborra - Elimina per a mi + Esborra per a mi Esborra per a tothom Info Actualitza @@ -118,16 +118,13 @@ Vist per darrer cop: %1$s Vist per darrer cop: desconegut - - - %d segon - %d segons - - - - %d minut - %d minuts + + + %d durada d\\'un minut + %d minuts de durada + + Menys d\\'1 minut %d min @@ -259,10 +256,13 @@ Canals Nou Canal - - Afegeix destinataris Nom del Canal + + + %d vista + %d visualitzacions + Correu electrònic Nou correu electrònic @@ -384,25 +384,25 @@ Truca + Trucada de veu + Videotrucada Respon Rebutja - - Trucada sortint + Trucada de veu sortint + Videotrucada sortint - - Trucada entrant + Trucada de veu entrant + Videotrucada entrant Trucada rebutjada Trucada cancel·lada Trucada perduda - - %1$s, %2$s @@ -491,7 +491,7 @@ Comença... - Si us plau accepteu primer el requeriment del xat. + Si us plau accepteu primer la sol·licitud del xat. Envia \"%1$s\" a… @@ -588,8 +588,6 @@ Missatges entrants Missatges sortints - - Emmagatzematge a %1$s. Connectivitat Desconnectat @@ -638,7 +636,7 @@ Xat segur i descentralitzat Escaneja el Codi d\'Invitació Identifiqueu-vos - Aquest inici de sessió és per a usuaris avançats:\n\n• No feu servir una adreça que utilitzeu a una altra app.\n\n• Les adreces de correu tradicional admeten xats sense xifratge d\'extrem a extrem, indicades amb una icona de correu. + Aquest inici de sessió és per a usuaris avançats:\n\n• No feu servir una adreça que utilitzeu a una altra app.\n\n• Les adreces de correu tradicional admeten xats sense xifrar d\'extrem a extrem, indicades amb una icona de correu. Safata d\'entrada Nom d\'identificació IMAP Servidor IMAP @@ -665,6 +663,17 @@ Comparteix l\'enllaç Servidor intermediari activat Esteu fent servir un servidor intermediari. Si teniu problemes per a connectar-vos, proveu un servidor intermediari diferent. + + Relés + Afegir Relé + Elimina Relé + Edita Relé + + Afegeix aquest relé? + El codi QR escanejat no conté un relé vàlid. + + Eliminar relé \"%1$s\"?\n\nEls teus contactes només poden contactar amb tu si ja els has contactat prèviament a través d\'un altre relé.\n\nSi dubtes, elimina el relé més tard. + El certificat coincideix Si us plau poseu una adreça d\'email vàlida Si us plau poseu un servidor / adreça IP vàlida @@ -677,17 +686,17 @@ No es pot iniciar la sessió com a \"%1$s\". Comproveu que l\'adreça electrònica i la contrasenya són correctes. Accepta certificats no vàlids - Canvia el perfil - Afegeix un compte + Canvia de perfil + Afegeix un perfil - Etiqueta de Perfil + Etiqueta del Perfil p.ex. treball, família - Configureu una etiqueta visible només per a vosaltres; ajudant-vos a diferir entre els vostres perfils. + Configureu una etiqueta visible només per a vosaltres; ajudant-vos a diferenciar entre els vostres perfils. Desplaçat a l\'inici Esborra el perfil Esteu segur de voler esborrar les dades del vostre perfil? - S\'esborraran totes les dades del perfil «%s» en aquest dispositiu, incloent-hi la configuració de xifratge d\'extrem a extrem, contactes, xats, missatges i contingut multimèdia. Aquesta acció no es pot desfer. + S\'esborraran totes les dades del perfil «%s» en aquest dispositiu, incloent-hi la configuració de xifrat d\'extrem a extrem, contactes, xats, missatges i contingut multimèdia. Aquesta acció no es pot desfer. Perfil sense configurar Obriu el per perfil per a configurar-lo. Prova de connectar-hi ara @@ -707,16 +716,15 @@ - Ús personalitzat: %s - Ús per defecte: %s El vostre perfil Fotografia de perfil Contactes bloquejats Els contactes blocats apareixeran aquí. - La vostra imatge de perfil, nom i biografia s\'enviaran amb juntament els missatges que escriviu quant us comuniqueu amb altres usuaris. + La vostra imatge de perfil, nom i signatura s\'enviaran juntament amb els missatges que escriviu quant us comuniqueu amb altres usuaris. El vostre nom Signatura + Descripció La tecla «Retorn» envia Els missatges s\'enviaran prement la tecla «Retorn» @@ -740,8 +748,6 @@ So Silenci Privacitat - - Xats i multimèdia Per defecte del sistema Clar @@ -781,7 +787,7 @@ Mode multidispositiu Sincronitza els vostres missatges amb els vostres altres dispositius. Activat automàticament quan s\'afegeix un segon dispositiu. - El mode multidispositiu ha d\'estar habilitat quan feu servir el mateix compte des de diferents perfils. Només desactiveu aquesta opció si heu suprimit aquest perfil de tots els vostres altres dispositius.\n\nDesactivar aquesta opció mentre es fa servir en el mateix perfil en múltiples dispositius farà que es perdin missatges i hi hagi altres problemes. + El mode multidispositiu ha d\'estar habilitat quan feu servir el mateix perfil des de diferents dispositius. Desactiva aquesta opció només si has esborrat aquest perfil de tots els altres dispositius.\n\nDesactivar aquesta opció mentre es fa servir el mateix perfil en múltiples dispositius farà que es perdin missatges i hi hagi altres problemes. Moure automàticament a la carpeta de Delta Chat Agafa només de la carpeta de DeltaChat Mostra els correus clàssics @@ -806,8 +812,6 @@ %1$s missatge Baixa el màxim disponible fins a %1$s - - Trieu Perfil Trieu la imatge de perfil Trieu la imatge nova de perfil Esborra la imatge del perfil @@ -878,6 +882,9 @@ Heu canviat la imatge de grup. Imatge de grup canviada per %1$s. + Has modificat la descripció del xat. + + Descripció del xat modificada per %1$s. S\'ha afegit el membre %1$s. @@ -910,10 +917,6 @@ Heu definit el temporitzador dels missatges efímers a %1$s segons. %2$s ha definit el temporitzador dels missatges efímers a %1$s segons. - - Heu definit el temporitzador dels missatges fonedissos a 1 segon. - - %1$s ha definit el temporitzador dels missatges efímers a 1 minut. Heu definit el temporitzador dels missatges fonedissos a 1 hora. %1$s ha definit el temporitzador dels missatges efímers a 1 hora. @@ -973,7 +976,7 @@ Text del codi QR escanejat:\n\n%1$s URL del codi QR escanejat:\n\n%1$s Empremta - Altres persones que vulguin contactar amb vós poden escanejar aquest codi QR.\n\nPodeu reinicialitzar-lo, de manera que el codi QR o enllaç actual existent ja no servirà. + Altres persones que vulguin contactar amb vós poden escanejar aquest codi QR.\n\nPodeu reinicialitzar-lo, de manera que el codi QR o enllaç d\'invitació existent ja no servirà. Altres poden escanejar aquest codi QR per a unir-se al grup «%1$s».\n\nPodeu reinicialitzar-lo, de manera que el codi QR existent, o l\'enllaç d\'invitació, deixarà de funcionar. Altres poden escanejar aquest codi QR per a unir-se al canal «%1$s».\n\nPodeu reinicialitzar-lo, de manera que el codi QR existent, o l\'enllaç d\'invitació, deixarà de funcionar. Reinicialitza el codi QR @@ -981,7 +984,7 @@ Activa el codi QR Codi QR d\'invitació %1$s s\'uneix. - %1$s verificat. + %1$s afegit. %1$s s\'ha unit al grup. Codi QR d\'invitació @@ -1004,14 +1007,12 @@ %1$s ha respost, espereu per a ser afegit al grup... S\'està establint el xifratge d\'extrem a extrem garantit, espereu... - %1$s verificat. + %1$s afegit. Heu blocat el contacte Afegit per %1$s Afegit per mi Afegit - - Per a garantir el xifrat extrem a extrem, només podeu afegir a aquest grup contactes que tinguin la marca verda.\n\nPodeu trobar-vos en persona amb els contactes per a escanejar els seus codis QR i poder-los afegir. Trieu el xat on enviar el missatge %1$s ja té un missatge d\'esborrany, voleu substituir-lo? @@ -1175,5 +1176,5 @@ Ja heu autoritzat Delta Chat a rebre missatges en segon pla.\n\nSi encara no us arriben els missatge, reviseu els paràmetres de sistema. - Què hi ha de nou?\n\n💯 L’encriptació de punta a punta és fiable i permanent. Els candaus 🔒 han desaparegut!\n\n✉️ El correu electrònic clàssic sense encriptació de punta a punta es mostra amb el símbol d\'un sobre.\n\n😻 Nova pantalla de perfil millorada per a tots els teus contactes.\n\n🔲 Nou botó per accedir ràpidament a les aplicacions utilitzades als xats.\n\n❤️ Si us plau, fes una donació per ajudar-nos a continuar independents i seguir aportant millores: %1$s + Què hi ha de nou?\n\n💯 El xifrat d\'extrem a extrem és fiable i permanent. Els candaus 🔒 han desaparegut!\n\n✉️ El correu electrònic clàssic sense xifrar d\'extrem a extrem es mostra amb el símbol d\'un sobre.\n\n😻 Nova pantalla de perfil millorada per a tots els teus contactes.\n\n🔲 Nou botó per accedir ràpidament a les aplicacions utilitzades als xats.\n\n❤️ Si us plau, fes una donació per ajudar-nos a continuar independents i seguir aportant millores: %1$s diff --git a/src/main/res/values-ckb/strings.xml b/src/main/res/values-ckb/strings.xml index cc3adb838..e2a10ae85 100644 --- a/src/main/res/values-ckb/strings.xml +++ b/src/main/res/values-ckb/strings.xml @@ -339,8 +339,6 @@ هەناردن بۆ... ئەم دەقە دەنێریت بۆ %1$d وتووێژ؟n\n\"%2$s\" - بەکارهێنانی دڵخواز: %s - بەکارهێنانی بنەڕەتی: %s زانیارییەکانی پرۆفایلەکەت وێنەی لادیمەن هەژمارە ئاستەنگکراوەکان @@ -366,8 +364,6 @@ دەنگ بێدەنگ پاراستنی تاکەکەسی - - وتووێژ و ڕەنگاڵەکان بنەڕەتی سیستەم ڕووناک diff --git a/src/main/res/values-cs/strings.xml b/src/main/res/values-cs/strings.xml index 3546aac5b..f10ebd804 100644 --- a/src/main/res/values-cs/strings.xml +++ b/src/main/res/values-cs/strings.xml @@ -118,20 +118,6 @@ Naposledy viděni %1$s Naposledy viděno: Neznámý - - - %d druhý - %d sekundy - %d sekundy - %d vteřny - - - - %d minuta - %d minuty - %d minuty - %d minuty - %d min. @@ -281,8 +267,6 @@ Kanály Nový kanál - - Přidat příjemce Název kanálu E-mail @@ -412,15 +396,9 @@ Zvedni Pokles - - Odchozí hovor - - Příchozí hovor Odmítnutý hovor Zrušený hovor Zmeškaný hovor - - %1$s, %2$s @@ -616,8 +594,6 @@ Příchozí zprávy Odchozí zprávy - - Úložiště na %1$s Připojení Nepřipojeno @@ -746,8 +722,6 @@ - Nastaven vlastní: %s - Nastaven výchozí: %s Přehled účtu Profilový obrázek Blokované kontakty @@ -779,8 +753,6 @@ Zvuk Ticho Soukromí - - Chaty a multimédia Podle systému Světlý @@ -845,8 +817,6 @@ %1$s zpráva Stahovat dostupné maximum do %1$s - - Vyberte profil Vybrat profilový obrázek Vyberte si svůj nový profilový obrázek Smazat profilový obrázek @@ -943,10 +913,6 @@ Nastavili jste časovač mizejících zpráv na %1$s sekund. Uživatel %2$s nastavil časovač mizejících zpráv na %1$s sekund. - - Nastavili jste časovač mizejících zpráv na 1 minutu. - - Uživatel %1$s změnil časovač mizejících zpráv na 1 minutu. Nastavili jste časovač mizejících zpráv na 1 hodinu. Uživatel %1$s změnil časovač mizejících zpráv na 1 hodinu. @@ -1042,8 +1008,6 @@ Ověřeni uživatelem %1$s. Ověřeni mnou Představeno - - Aby mohlo být garantováno koncové šifrování, můžete do této skupiny přidávat pouze uživatele se zeleným zaškrtávátkem.\n\nSe svými kontakty se můžete setkat osobně a naskenovat jejich QR kód, abyste je ověřili. Vyberte chat, do kterého chcete zprávu poslat %1$s již obsahuje rozepsanou zprávu, přejete si ji přepsat? diff --git a/src/main/res/values-da/strings.xml b/src/main/res/values-da/strings.xml index 006ce486c..fe1526bab 100644 --- a/src/main/res/values-da/strings.xml +++ b/src/main/res/values-da/strings.xml @@ -393,8 +393,6 @@ - Bruger tilpasset: %s - Bruger standard: %s Profil information Profilbillede Blokerede kontakter @@ -424,8 +422,6 @@ Lyd Stille Privatliv - - Samtaler og medie Systemstandard Lys @@ -534,10 +530,6 @@ Tidsudløb for beskeder sat til %1$s s af mig. Tidsudløb for beskeder sat til %1$s s af %2$s. - - Tidsudløb for beskeder sat til 1 minut af mig. - - Tidsudløb for beskeder sat til 1 minut af %1$s. Tidsudløb for beskeder sat til 1 time af mig. Tidsudløb for beskeder sat til 1 time af %1$s. diff --git a/src/main/res/values-de/strings.xml b/src/main/res/values-de/strings.xml index 3f0471997..9e1b9da8c 100644 --- a/src/main/res/values-de/strings.xml +++ b/src/main/res/values-de/strings.xml @@ -118,16 +118,13 @@ Zuletzt gesehen %1$s Zuletzt gesehen: Unbekannt - - - %d Sekunde - %d Sekunden - - - - %d Minute - %d Minuten + + + Dauer: %d Minute + Dauer: %d Minuten + + Weniger als 1 Minute %d Min @@ -180,6 +177,9 @@ Zu Sticker-Sammlung hinzufügen Um Sticker hinzuzufügen, tippe auf \"Sticker-Ordner öffnen\", erstelle einen Unterordner für dein Sticker-Paket und ziehe Bild- und Sticker-Dateien dorthin. Sticker-Ordner öffnen + Sticker zur Sammlung hinzufügen? + Sticker löschen? + Sticker zum Hinzufügen antippen Bilder Audio @@ -213,7 +213,7 @@ Apps App-Picker-URL Wenn gesetzt, wird diese URL als App-Picker anstelle der Voreinstellung verwendet. - Zum Teilen auf \"Senden\" klicken + Zum Teilen auf \"Senden\" tippen Start Spiele Tools @@ -259,10 +259,13 @@ Kanäle Neuer Kanal - - Empfänger hinzufügen Name des Kanals + + + %d Aufruf + %d Aufrufe + E-Mail Neue E-Mail @@ -278,6 +281,7 @@ Dateien anhängen Gruppe verlassen Kanal verlassen + Verlassen & für mich löschen Chat löschen Chat leeren @@ -384,25 +388,29 @@ Anrufen + Sprachanruf + Videoanruf Annehmen Ablehnen - - Ausgehender Anruf + + Sprachanruf + + Videoanruf + Ausgehender Sprachanruf + Ausgehender Videoanruf - - Eingehender Anruf + Eingehender Sprachanruf + Eingehender Videoanruf Abgelehnter Anruf Abgebrochener Anruf Verpasster Anruf - - %1$s, %2$s @@ -588,8 +596,6 @@ Eingehende Nachrichten Ausgehende Nachrichten - - Speicher auf %1$s Verbindungsstatus Nicht verbunden @@ -718,8 +724,6 @@ - Benutzerdefiniert verwenden: %s - Standard verwenden: %s Mein Profil Profilbild Blockierte Kontakte @@ -733,6 +737,7 @@ Enter-Taste sendet Durch Drücken der Eingabetaste werden Textnachrichten gesendet. Medienqualität beim Senden + Um Originalqualität zu senden, hänge Medien als „Datei“ an. Dies verbraucht deutlich mehr Datenvolumen für dich und die Empfänger. Ausgewogen Schlechte Qualität, kleine Dateien Vibrieren @@ -743,6 +748,10 @@ Benachrichtigungen Erwähnungen In stummgeschalteten Gruppen, über an dich gerichtete Nachrichten, wie Antworten oder Reaktionen, benachrichtigen + + Anrufe + + Anrufbildschirm bei eingehenden Anrufen Anzeigen Priorität System-Benachrichtigungen bei neuen Nachrichten aktivieren @@ -752,8 +761,6 @@ Ton Leise Datenschutz - - Chats und Medien Systemstandard Hell @@ -818,8 +825,6 @@ %1$s Nachricht Download max. verfügbar bis %1$s - - Profil auswählen Profilbild auswählen Wählen dein Profilbild aus Profilbild löschen @@ -925,10 +930,6 @@ Du hast die Ablaufzeit verschwindender Nachrichten auf %1$s Sekunden gesetzt. Ablaufzeit verschwindender Nachrichten auf %1$s Sekunden gesetzt von %2$s. - - Du hast die Ablaufzeit verschwindender Nachrichten auf 1 Minute gesetzt. - - Ablaufzeit verschwindender Nachrichten auf 1 MInute gesetzt von %1$s. Du hast die Ablaufzeit verschwindender Nachrichten auf 1 Stunde gesetzt. Ablaufzeit verschwindender Nachrichten auf 1 Stunde gesetzt von %1$s. @@ -1025,8 +1026,6 @@ Eingeführt von %1$s Durch mich eingeführt Eingeführt - - Um Ende-zu-Ende-Verschlüsselung zu gewährleisten, können nur Kontakte mit grünem Häkchen zu dieser Gruppe hinzugefügt werden.\n\nDu kannst Kontakte persönlich treffen und ihren QR-Code scannen, um sie einzuführen. Wähle den Chat, an den die Nachricht gesendet werden soll %1$s hat bereits einen Entwurf. Möchtest du diesen ersetzen? diff --git a/src/main/res/values-el/strings.xml b/src/main/res/values-el/strings.xml index ee35c3bd3..39c78193e 100644 --- a/src/main/res/values-el/strings.xml +++ b/src/main/res/values-el/strings.xml @@ -182,8 +182,6 @@ Νέα Επαφή Νέα Συνομιλία Νέα ομάδα - - Προσθήκη Παραλήπτη Αποστολή Εναλλαγή πληκτρολογίου emoji Επεξεργασία ομάδας @@ -401,8 +399,6 @@ Εισερχόμενα Μηνύματα Εξερχόμενα Μηνύματα - - Αποθήκευση στο %1$s Συνδεσιμότητα Μη συνδεδεμένο @@ -467,8 +463,6 @@ - Χρήση προσαρμοσμένου: %s - Χρήση προεπιλογής: %s Πληροφορίες Προφίλ Εικόνα Προφίλ Αποκλεισμένες Επαφές @@ -498,8 +492,6 @@ Ήχος Σε σίγαση Ιδιωτικότητα - - Συνομιλίες και Πολυμέσα Προκαθορισμένο του συστήματος Φωτεινό @@ -624,10 +616,6 @@ Όρισες το χρόνο εξαφάνισης των μηνυμάτων σε %1$s δευτερόλεπτα. Ο/Η %2$s όρισε το χρόνο εξαφάνισης των μηνυμάτων σε %1$s δευτερόλεπτα. - - Όρισες το χρόνο εξαφάνισης των μηνυμάτων σε 1 λεπτό. - - Ο/Η %1$s όρισε το χρόνο εξαφάνισης των μηνυμάτων σε 1 λεπτό. Όρισες το χρόνο εξαφάνισης των μηνυμάτων σε 1 ώρα. Ο/Η %1$s όρισε το χρόνο εξαφάνισης των μηνυμάτων σε 1 ώρα. diff --git a/src/main/res/values-eo/strings.xml b/src/main/res/values-eo/strings.xml index 502cf2ee4..79d674a92 100644 --- a/src/main/res/values-eo/strings.xml +++ b/src/main/res/values-eo/strings.xml @@ -112,16 +112,6 @@ Laste vidita %1$s Laste vidita: nedisponebla informo - - - %d sekundo - %d sekundoj - - - - %d minuto - %d minutoj - %d min @@ -221,8 +211,6 @@ Nova kontakto Nova babilo Nova grupo - - Aldoni Ricevantojn Sendi Baskuligi emoĝian klavaron Redakti grupon @@ -416,8 +404,6 @@ Sono Silenta Privateco - - Interparoloj kaj aŭdvidaĵoj Sistema defaŭlto Hela diff --git a/src/main/res/values-es/strings.xml b/src/main/res/values-es/strings.xml index 7b6712358..51a2367db 100644 --- a/src/main/res/values-es/strings.xml +++ b/src/main/res/values-es/strings.xml @@ -118,18 +118,6 @@ Visto por última vez: %1$s Visto por última vez: Desconocido - - - %d segundo - %d segundos - %d segundos - - - - %d minuto - %d minutos - %d minutos - %d min @@ -270,8 +258,6 @@ Canales Nuevo canal - - Agregar destinatarios Nombre del canal Correo electrónico @@ -396,25 +382,25 @@ Llamar + Llamada + Videollamada Contestar Rechazar - - Llamada saliente + Llamada saliente + Videollamada saliente - - Llamada entrante + Llamada entrante + Videollamada entrante Llamada rechazada Llamada cancelada Llamada perdida - - %1$s, %2$s @@ -605,8 +591,6 @@ Mensajes entrantes Mensajes salientes - - Almacenamiento en %1$s Conectividad Desconectado @@ -735,8 +719,6 @@ - Usando valor personalizado: %s - Usando valor por defecto: %s Información de perfil Foto de perfil Contactos bloqueados @@ -768,8 +750,6 @@ Sonido Silencio Privacidad - - Chats y multimedia Predeterminado del sistema Claro @@ -835,8 +815,6 @@ Desactiva este ajuste solo si has eliminado este perfil de todos tus demás disp Mensaje de %1$s Descarga disponible hasta %1$s - - Seleccionar perfil Seleccionar imagen de perfil Seleccionar tu nueva imagen de perfil Borrar imagen de perfil @@ -939,10 +917,6 @@ Desactiva este ajuste solo si has eliminado este perfil de todos tus demás disp Estableciste el temporizador de la desaparición de mensajes en %1$s segundos Temporizador de la desaparición de mensajes establecido en %1$s segundos por %2$s. - - Estableciste el temporizador de la desaparición de mensajes en 1 minuto. - - Temporizador de la desaparición de mensajes establecido en 1 minuto por %1$s. Estableciste el temporizador de la desaparición de mensajes en 1 hora. Temporizador de la desaparición de mensajes establecido en 1 hora por %1$s. @@ -1039,8 +1013,6 @@ Desactiva este ajuste solo si has eliminado este perfil de todos tus demás disp Verificado por %1$s Verificado por ti Introducido - - Para garantizar el cifrado de extremo a extremo, solo puedes añadir contactos con una marca de verificación verde Seleccione el chat para enviar el mensaje %1$s ya tiene un mensaje borrador, ¿quieres reemplazarlo? diff --git a/src/main/res/values-et/strings.xml b/src/main/res/values-et/strings.xml index ab6ad01e4..d946d2524 100644 --- a/src/main/res/values-et/strings.xml +++ b/src/main/res/values-et/strings.xml @@ -118,16 +118,13 @@ Viimati nähtud: %1$s Viimati nähtud: pole teada - - - %d sekund - %d sekundit - - - - %d minut - %d minutit + + + kestusega %d minut + kestusega %d minutit + + alla ühe minuti %d min @@ -180,6 +177,9 @@ Lisa kleepsude kogumikku Kleepsude lisamiseks klõpsi valikut „Ava kleepsude kaust“, loo oma kleepsupaki jaoks sinna alamkaust ning lohista pildi- ja kleepsufailid sinna Ava kleepsude kaust + Kas lisad selle kleepsu oma kogumikku? + Kas kustutad selle kleepsu? + Klõpsa kleepsu lisamiseks siia. Pildid Heli @@ -259,10 +259,13 @@ Kanalid Uus kanal - - Lisa saajaid Kanali nimi + + + Nähtud %d kord + Nähtud %d korda + E-post Uus e-kiri @@ -278,6 +281,7 @@ Lisa manus Lahku grupist Lahku kanalist + Lahku ja kustuta minu jaoks Kustuta vestlus Eemalda vestluse sisu @@ -384,25 +388,29 @@ Helista + Häälkõne + Videokõne Vasta Keeldu - - Väljuv kõne + + Häälkõne + + Videokõne + Väljuv häälkõne + Väljuv videokõne - - Saabuv kõne + Saabuv häälkõne + Saabuv videokõne Keeldutud kõne katkestatud kõne Märkamata kõne - - %1$s, %2$s @@ -588,8 +596,6 @@ Saabuvad sõnumid Väljuvad sõnumid - - Andmeruum domeenis %1$s Ühenduvus Pole ühendatud @@ -718,8 +724,6 @@ - Kasutan kohandatud väärtust: %s - Kasutan vaikimisi väärtust: %s Sinu profiil Profiilipilt Blokeeritud kontaktid @@ -733,6 +737,7 @@ Sisestusklahv saadab sõnumi Sisestusklahvi vajutamisega saadad sõnumi ära Saadetava meediumi kvaliteet + Saatmaks algse kvaliteediga, palun vali manuse tüübiks „Fail“ ning arvesta, et nii sinu kui saaja puhul kasutatakse suuremat andmemahtu. Tasakaalustatud Kõige kehvem kvaliteet, aga väikseim maht Väristamine @@ -743,6 +748,10 @@ Teavitused Mainimised Summutatud gruppides teavita sinule suunatud sõnumitest, milleks võivad olla vastused või reageerimised + + Kõned + + Saabuvate kõnede puhul näita kõvaadet Näita Prioriteet Kasuta uute sõnumite puhul süsteemi teavitusi @@ -752,8 +761,6 @@ Heli Vaikne Privaatsus - - Vestlused ja meediumid Süsteemi vaikimisi valik Hele kujundus @@ -818,8 +825,6 @@ Sõnum suurusega %1$s Allalaadimise ülempiir on saadaval kuni %1$s - - Vali profiil Vali profiilipilt Vali oma uus profiilipilt Kustuta profiilipilt @@ -925,10 +930,6 @@ Sa määrasid isekustuvate sõnumite kehtivuseks %1$s sekundit. %2$s määras isekustuvate sõnumite kehtivuseks %1$s sekundit. - - Sa määrasid isekustuvate sõnumite kehtivuseks 1 minuti. - - %1$s määras isekustuvate sõnumite kehtivuseks 1 minuti. Sa määrasid isekustuvate sõnumite kehtivuseks 1 tunni. %1$s määras isekustuvate sõnumite kehtivuseks 1 tunni. @@ -1025,8 +1026,6 @@ %1$s lisas kontakti vestlusesse Mina lisasin kontakti vestlusesse Vestlusesse lisatud - - Selleks, et läbiv krüptimine toimiks garanteeritult, sa võid sellesse vestlusesse lisada vaid rohelise linnukesega kontakte.\n\nParim viis on omavahel kohtuda ning teise osapoole QR-koodi skaneerimisega lisada ta vestlusesse. Vali vestlus, kuhu soovid sõnumi saata Vestluses „%1$s“ juba on üks sõnumi kavand, kas sa soovid selle asendada? diff --git a/src/main/res/values-eu/strings.xml b/src/main/res/values-eu/strings.xml index 579dd33ee..78167475f 100644 --- a/src/main/res/values-eu/strings.xml +++ b/src/main/res/values-eu/strings.xml @@ -118,16 +118,6 @@ Azken aldiz begiratua %1$s Azken aldiz begiratzea: ezezaguna - - - segundo %d - %d segundo - - - - minutu %d - %d minutu - min %d @@ -259,8 +249,6 @@ Kanalak Kanal berria - - Gehitu harpidedunak Kanalaren izena Posta elektronikoa @@ -384,25 +372,25 @@ Deitu + Audio deia + Bideo deia Erantzun Ukatu - - Irteerako deia + Irteerako audio deia + Irteerako bideo deia - - Sarrerako deia + Sarrerako audio deia + Sarrerako bideo deia Ukatutako deia Bertan behera utzitako deia Galdutako deia - - %1$s, %2$s @@ -588,8 +576,6 @@ Sarrerako mezuak Irteerako mezuak - - %1$s(e)ko biltegiratzea Konexioa Konektatu gabe @@ -718,8 +704,6 @@ - Pertsonalizatua erabiltzen: %s - Lehenetsia erabiltzen: %s Zure profileko informazioa Profileko argazkia Blokeatutako kontaktuak @@ -752,8 +736,6 @@ Soinua Isila Pribatutasuna - - Txatak eta multimedia Sisteman lehenetsia Argia @@ -818,8 +800,6 @@ %1$s mezu Deskargatu eskuragarri dagoen gehienezkoa %1$s arte - - Aukeratu kontua Aukeratu abatarra Aukeratu zure abatar berria Ezabatu abatarra @@ -925,10 +905,6 @@ Mezuen desagerpen-denbora %1$s segundotan ezarri duzu. %2$s(e)k mezuen desagerpen-denbora %1$s segundotan ezarri du. - - Mezuen desagerpen-denbora 1 minutakoa dela ezarri duzu. - - %1$s(e)k mezuen desagerpen-denbora 1 minutukoa dela ezarri du. Mezuen desagerpen-denbora 1 ordukoa dela ezarri duzu. %1$s(e)k mezuen desagerpen-denbora 1 ordukoa dela ezarri du. @@ -1025,8 +1001,6 @@ %1$s(e)k egiaztatuta Nik egiaztatuta Egiaztatuta - - Muturretik muturrera zifratzea bermatzeko, marka berdea duten kontaktuak soilik gehitu ditzakezu talde honetara.\n\nKontaktuekin aurrez aurre elkartu zaitezke eta haien QR kodea eskaneatu dezakezu elkar egiaztatzeko. Aukeratu txat bat mezua bidaltzeko %1$s(e)k zirriborro bat du dagoeneko, ordezkatu nahi duzu? diff --git a/src/main/res/values-fa/strings.xml b/src/main/res/values-fa/strings.xml index 639a448a2..eb3c3ddfb 100644 --- a/src/main/res/values-fa/strings.xml +++ b/src/main/res/values-fa/strings.xml @@ -118,16 +118,6 @@ آخرین بار دیده‌شده در %1$s آخرین زمان مشاهده: نامشخص - - - %d ثانیه - %dثانیه - - - - %d دقیقه - %d دقیقه - %d دقیقه @@ -259,8 +249,6 @@ کانال‌ها کانال جدید - - افزودن دنبال‌کننده نام کانال رایانامه @@ -386,25 +374,25 @@ تماس + تماس صوتی + تماس تصویری جواب دادن رد کردن - - تماس خروجی + تماس صوتی خروجی + تماس تصویری خروجی - - تماس ورودی + تماس صوتی ورودی + تماس تصویری ورودی تماس رد شده تماس لغو شده تماس از دست رفته - - %1$s، %2$s @@ -593,8 +581,6 @@ پیام‌های ورودی پیام‌های خروجی - - ذخیره کردن در %1$s اتصال‌ها متصل نیست @@ -723,8 +709,6 @@ - استفاده از ترجیحی: %s - استفاده از پیش‌فرض: %s اطلاعات نمایهٔ شما تصویر نمایه مخاطبین مسدود شده @@ -756,8 +740,6 @@ صدا سکوت حریم خصوصی - - گپ‌ها و رسانه پیش‌فرض سیستم روشن @@ -822,8 +804,6 @@ پیام %1$s سقف دانلود تا %1$s فعال خواهد بود - - انتخاب نمایه انتخاب تصویر نمایه تصویر نمایهٔ جدیدتان را برگزینید حذف تصویر نمایه @@ -926,10 +906,6 @@ زمان‌سنج ناپدید شدن پیام‌ها را روی %1$s ثانیه تنظیم کردید زمان‌سنج ناپدید شدن پیام‌ها توسط %2$s روی %1$s ثانیه تنظیم شد. - - زمان‌سنج ناپدید شدن پیام‌ها را روی 1 دقیقه تنظیم کرده‌اید. - - زمان‌سنج ناپدید شدن پیام‌ها توسط %1$s روی 1 دقیقه تنظیم شد. زمان‌سنج ناپدید شدن پیام‌ها را روی 1 ساعت تنظیم کرده‌اید. زمان‌سنج ناپدید شدن پیام‌ها توسط %1$s روی 1 ساعت تنظیم شد. @@ -1030,8 +1006,6 @@ احراز هویت شده توسط %1$s تأیید شده توسط من معرفی شده - - برای تضمین رمزنگاری سراسری شما تنها می‌توانید مخاطب‌هایی که یک تیک سبز دارند به این گروه اضافه کنید.\n\nمی‌توانید با مخاطب‌هایتان حضوری ملاقات کنید و برای معرفی، کد QR آن‌ها را اسکن کنید. گپ را برای ارسال پیام انتخاب کنید %1$s هم‌اکنون هم یک پیام پیش‌نویس دارد؛ آیا می‌خواهید آن را جایگزین کنید؟ diff --git a/src/main/res/values-fi/strings.xml b/src/main/res/values-fi/strings.xml index cf8ad346b..09f3ec143 100644 --- a/src/main/res/values-fi/strings.xml +++ b/src/main/res/values-fi/strings.xml @@ -200,8 +200,6 @@ Uusi keskustelu Uusi ryhmä Kloonaa keskustelu - - Lisää vastaanottajia Lähetä Näytä emoji-näppäimistö Muokkaa ryhmää @@ -461,8 +459,6 @@ Saapuvat viestit Lähtevät viestit - - Tallennustila palvelimella %1$s Yhteys Ei yhdistetty @@ -555,8 +551,6 @@ - Käytetään mukautettua: %s - Käytetään oletusta: %s Profiilisi Profiilikuva Estetyt yhteystiedot @@ -586,8 +580,6 @@ Ääni Äänetön Yksityisyys - - Keskustelut ja media Järjestelmän oletus Vaalea @@ -732,10 +724,6 @@ Katoavien viestien ajastin asetettu %1$s sekuntiin minun toimestani. Katoavien viestien ajastin asetettu %1$s sekuntiin käyttäjän %2$s toimesta. - - Katoavien viestien ajastin asetettu 1 minuuttiin minun toimestani. - - Katoavien viestien ajastin asetettu 1 minuuttiin käyttäjän %1$s toimesta. Katoavien viestien ajastin asetettu 1 tuntiin minun toimestani. Katoavien viestien ajastin asetettu 1 tuntiin käyttäjän %1$s toimesta. @@ -821,8 +809,6 @@ %1$s lisäsi Lisäämäni - - Päästä-päähän -salauksen takaamiseksi, voit lisätä ryhmään ainoastaan yhteystietoja joilla on vihreä \"oikein\" -merkki.\n\nVoit tavata yhteystietoja kasvotusten ja skannata heidän QR -koodinsa niin saat lisättyä salauksen käyttöön Valitse keskustelu viestien lähettämiseen Keskustelussa %1$s on jo luonnos. Haluatko korvata sen? diff --git a/src/main/res/values-fr/strings.xml b/src/main/res/values-fr/strings.xml index 44c677e9c..d00d7dd59 100644 --- a/src/main/res/values-fr/strings.xml +++ b/src/main/res/values-fr/strings.xml @@ -118,18 +118,6 @@ Dernière activité %1$s Vu·e pour la dernière fois : inconnu - - - %d seconde - %d secondes - %d secondes - - - - %d minute - %d minutes - %d minutes - %d min @@ -270,10 +258,14 @@ Canaux Nouveau canal - - Ajouter des abonné⋅es Nom du canal + + + %d vue + %d vues + %d vues + Courriel Nouveau courriel @@ -396,25 +388,25 @@ Appeler + Appel audio + Appel vidéo Décrocher Refuser - - Appel sortant + Appel audio sortant + Appel vidéo sortant - - Appel entrant + Appel audio entrant + Appel vidéo entrant Appel refusé Appel annulé Appel manqué - - %1$s, %2$s @@ -467,7 +459,7 @@ Caméra indisponible. Impossible d\'enregistrer le son. - %dnouveaux messages + %d nouveau message %d nouveaux messages %d nouveaux messages @@ -605,8 +597,6 @@ Messages entrants Messages sortants - - Espace de stockage sur %1$s Connectivité Non connecté @@ -735,8 +725,6 @@ - Usage de la personnalisation : %s - Utilisation par défaut : %s Votre profil Photo de profil Contacts bloqués @@ -769,8 +757,6 @@ Son Silencieux Vie privée - - Discussions et fichiers multimédias Valeur par défaut du système Clair @@ -835,8 +821,6 @@ %1$s message Télécharger le plus possible jusqu\'à %1$s - - Sélectionner un profil Sélectionnez l\'image de profil Sélectionnez votre nouvelle image de profil Supprimer l\'image de profil @@ -942,10 +926,6 @@ Vous avez défini le délai avant disparition des messages éphémères à %1$s secondes. Délai avant disparition des messages éphémères défini à %1$s secondes par %2$s. - - Vous avez défini le délai avant disparition des messages éphémères à 1 minute. - - Délai avant disparition des messages éphémères défini à 1 minute par %1$s. Vous avez défini le délai avant disparition des messages éphémères à 1 heure. Délai avant disparition des messages éphémères défini à 1 heure par %1$s. @@ -1042,8 +1022,6 @@ Connu de %1$s Connu de vous Connu - - Pour garantir le chiffrement de bout en bout, vous pouvez ajouter à ce groupe uniquement les contacts avec un badge vert.\n\nVous pouvez rencontrer ces contacts en personne pour scanner leur code QR et les ajouter à votre liste de contacts connus. Sélectionnez une discussion où envoyer le message %1$sa déjà un message en brouillon, voulez-vous le remplacer? diff --git a/src/main/res/values-gl/strings.xml b/src/main/res/values-gl/strings.xml index e7d8b89e2..6884ebdb5 100644 --- a/src/main/res/values-gl/strings.xml +++ b/src/main/res/values-gl/strings.xml @@ -243,8 +243,6 @@ Canles Nova Canle - - Engadir correspondentes Nome da Canle Email @@ -546,8 +544,6 @@ Mensaxes entrantes Mensaxes saíntes - - Almacenar en %1$s Conectividade Non conectado @@ -654,8 +650,6 @@ - Utilizar personalizado: %s - Utilizando por omisión: %s Info do teu perfil Imaxe de perfil Contactos bloqueados @@ -687,8 +681,6 @@ Son Mudo Intimidade - - Conversas e medios Por omsión no sistema Claro @@ -746,8 +738,6 @@ %1$s mensaxe Descarga o máximo dispoñible ata %1$s - - Seleccionar Perfil Elixe imaxe de perfil Elixe a túa nova imaxe de perfil Elimina a imaxe de perfil @@ -828,10 +818,6 @@ Estableceches que as mensaxes desaparezan en %1$s s O temporizador de desaparición de mensaxes establecido en %1$s segundos por %2$s. - - Puxeches en 1 minuto o temporizador de desaparición de mensaxes. - - Temporizador de desaparición de mensaxes definido en 1 minuto por %1$s. Definiches o temporizador de desaparición de mensaxes en 1 hora. Temporizador de desaparición de mensaxes definido en 1 hora por %1$s. @@ -911,8 +897,6 @@ Introducido/a por %1$s Introducido/a por min Introducido/a - - Para garantir cifrado extremo-a-extremo, só podes adicionar contactos com un visto verde a este grupo.\n\nPodes encontrarche con contactos en persoa e escanear os seus códigos QR para os introducir. Elexir chat ao que enviar a mensaxe %1$s xa ten un borrador, queres susbstituílo? diff --git a/src/main/res/values-hr/strings.xml b/src/main/res/values-hr/strings.xml index aca5fb06a..b510c7840 100644 --- a/src/main/res/values-hr/strings.xml +++ b/src/main/res/values-hr/strings.xml @@ -190,8 +190,6 @@ Zvuk Tiho Privatnost - - Razgovori i media Sustavno zadano Svijetlo diff --git a/src/main/res/values-hu/strings.xml b/src/main/res/values-hu/strings.xml index f31ad63df..587478c7e 100644 --- a/src/main/res/values-hu/strings.xml +++ b/src/main/res/values-hu/strings.xml @@ -118,16 +118,6 @@ Utoljára látta: %1$s Utoljára látta: - (ismeretlen) - - - %d másodperc - %d másodperc - - - - %d perc - %d perc - %d perce @@ -259,8 +249,6 @@ Csatornák Új csatorna - - Címzettek hozzáadása Csatorna neve E-mail @@ -388,15 +376,9 @@ Fogadás Elutasítás - - Kimenő hívás - - Bejövő hívás Elutasított hívás Visszavont hívás Nem fogadott hívás - - %1$s, %2$s @@ -582,8 +564,6 @@ Beérkező üzenetek Kimenő üzenetek - - Tárhely mérete itt: %1$s Tárhely Nem kapcsolódott @@ -700,8 +680,6 @@ - Egyéni használata: %s - Alapértelmezett használata: %s Profiladatok Profilkép Letiltott névjegyek @@ -733,8 +711,6 @@ Hang Csendes Adatvédelem - - Csevegések és média Rendszer alapértelmezett Világos @@ -794,8 +770,6 @@ %1$s üzenet Letöltés maximum ennyi ideig visszamenőleg: %1$s - - Profil kiválasztása Profilkép kiválasztása Válassza ki az új profilképet Profilkép törlése @@ -890,10 +864,6 @@ Ön az eltűnő üzenetek időzítőjét %1$s másodpercre állította be. %2$s az eltűnő üzenetek időzítőjét %1$s másodpercre állította be. - - Ön az eltűnő üzenetek időzítőjét 1 percre állította be. - - %1$s az eltűnő üzenetek időzítőjét 1 percre állította be. Ön az eltűnő üzenetek időzítőjét 1 órára állította be. %1$s az eltűnő üzenetek időzítőjét 1 órára állította be. @@ -984,8 +954,6 @@ %1$s által bemutatva Ön által bemutatva Bemutatva - - A végpontok közötti titkosítás garantálása érdekében csak zöld jelöléssel rendelkező névjegyeket adhat hozzá ehhez a csoporthoz.\n\n Személyesen is találkozhat a partnereivel és beolvashatja a QR-kódjukat, hogy bemutassa őket. Válassza ki a csevegést az üzenet elküldéséhez Már létezik egy piszkozat a következő szöveggel: %1$s, biztosan le akarja cserélni? diff --git a/src/main/res/values-in/strings.xml b/src/main/res/values-in/strings.xml index 8cd3c8b98..6e930e8ee 100644 --- a/src/main/res/values-in/strings.xml +++ b/src/main/res/values-in/strings.xml @@ -181,8 +181,6 @@ Kontak baru Obrolan baru Grup baru - - Tambahkan Penerima Terkirim Tekan tombol emoji keyboard Edit grup @@ -395,8 +393,6 @@ - Menggunakan custom: %s - Menggunakan default: %s Info profil Anda Gambar profil Kontak yang diblokir @@ -423,8 +419,6 @@ Suara Sunyi Privasi - - Obrolan dan media Cahaya Gelap diff --git a/src/main/res/values-it/strings.xml b/src/main/res/values-it/strings.xml index 9e1ef9d30..3ea144ee0 100644 --- a/src/main/res/values-it/strings.xml +++ b/src/main/res/values-it/strings.xml @@ -118,18 +118,14 @@ Ultimo accesso %1$s Ultimo accesso: sconosciuto - - - %d secondo - %d secondi - %d secondi - - - - %d minuto - %d minuti - %d minuti + + + %d minuto di durata + %d minuti di durata + %d minuti di durata + + Meno di 1 minuto %d min @@ -270,10 +266,14 @@ Canali Nuovo Canale - - Aggiungi Destinatari Nome Canale + + + %d visualizzazione + %d visualizzazioni + %d visualizzazioni + E-Mail Nuova E-Mail @@ -396,25 +396,25 @@ Chiama + Chiamata Audio + Chiamata Video Rispondi Rifiuta - - Chiamata in uscita + Chiamata in uscita + Videochiamata in uscita - - Chiamata in arrivo + Chiamata in arrivo + Videochiamata in arrivo Chiamata rifiutata Chiamata annullata Chiamata persa - - %1$s, %2$s @@ -605,8 +605,6 @@ Messaggi in Arrivo Messaggi in Uscita - - Spazio su %1$s Connettività Non connesso @@ -735,8 +733,6 @@ - Personalizzazione in uso: %s - Usa predefinito: %s Il tuo Profilo Immagine Profilo Contatti Bloccati @@ -760,6 +756,10 @@ Notifiche Menzioni Nei gruppi silenziati notifica i messaggi indirizzati a te, come risposte o reazioni + + Chiamate + + Mostra la schermata delle chiamate in arrivo Mostra Priorità Abilita le notifiche di sistema per i nuovi messaggi @@ -769,8 +769,6 @@ Suono Silenzia Riservatezza - - Chat e Media Predefinito di sistema Chiaro @@ -835,8 +833,6 @@ %1$s messaggio Scaricamento disponibile massimo fino a %1$s - - Seleziona il Profilo Seleziona Immagine Profilo Seleziona la tua nuova immagine profilo Elimina Immagine Profilo @@ -942,10 +938,6 @@ Hai impostato il timer dei messaggi a scomparsa su %1$s secondi Timer messaggi a scomparsa impostato su %1$s secondi da %2$s. - - Hai impostato il timer dei messaggi a scomparsa su 1 minuto. - - Timer messaggi a scomparsa impostato su 1 minuto da %1$s. Hai impostato il timer dei messaggi a scomparsa su 1 ora. Timer messaggi a scomparsa impostato su 1 ora da %1$s. @@ -1042,8 +1034,6 @@ Introdotto da %1$s Introdotto da me Introdotto - - Per garantire la crittografia end-to-end, puoi aggiungere a questo gruppo solo i contatti con un segno di spunta verde.\n\nPuoi incontrare i contatti di persona e scansionare il loro Codice QR per verificarli. Seleziona la chat a cui inviare il messaggio %1$s ha già una bozza di messaggio, vuoi sostituirla? diff --git a/src/main/res/values-ja/strings.xml b/src/main/res/values-ja/strings.xml index da10bce98..90ad67db7 100644 --- a/src/main/res/values-ja/strings.xml +++ b/src/main/res/values-ja/strings.xml @@ -375,8 +375,6 @@ - カスタムを使う:%s - デフォルトを使う:%s ユーザープロファイル アイコン画像 ブロックされた人 @@ -406,8 +404,6 @@ 通知音 無音 機密保護 - - チャットと写真・動画・音楽 システム既定 @@ -511,10 +507,6 @@ 自分から消えるメッセージを%1$s秒にしました。 %2$sから消えるメッセージを%1$s秒にしました。 - - 自分から消えるメッセージを1分にしました。 - - %1$sから消えるメッセージを1分にしました。 自分から消えるメッセージを1時間にしました。 %1$sから消えるメッセージを1時間にしました。 diff --git a/src/main/res/values-kab/strings.xml b/src/main/res/values-kab/strings.xml index b90849e1f..f9c396bd5 100644 --- a/src/main/res/values-kab/strings.xml +++ b/src/main/res/values-kab/strings.xml @@ -118,16 +118,6 @@ yettwaẓer i tikkelt taneggarut %1$s yettwaẓer i tikkelt taneggarut: Arussin - - - %d n tasint - %d n tasinin - - - - %d n tesdat - %d n tesdatin - %d n tesdat @@ -259,8 +249,6 @@ Ibuda Abadu amaynut - - Rni imeltaɣen Isen n ubadu Imayl @@ -383,25 +371,25 @@ Siwel + Asiwel n umeslaw + Asiwel n tvidyut Err Agi - - Tiɣri tuffiɣt + Asiwel n umeslaw uffiɣ + Asiwel n uvidyu uffiɣ - - Tiɣri i d-ikecmen + Asiwel n umeslaw ukcim + Asiwel n uvidyu ukcim Tiɣri tettwagi Tiɣri tettwasefsex Tiɣri ittwazeglen - - %1$s, %2$s @@ -587,8 +575,6 @@ Iznan ukcimen Iznan uffiɣen - - Aḥraz deg %1$s Tuqqna Ur yeqqin ara @@ -717,8 +703,6 @@ - Aseqdec asagen: %s - Aseqdec amezwer: %s Amaɣnu-inek·inem Tugna n umaɣnu Inermisen yettusweḥlen @@ -751,8 +735,6 @@ Imesli Asusam Tabaḍnit - - Idiwenniyen d Umidya Anagraw amezwer Aceɛlal @@ -817,8 +799,6 @@ Izen %1$s Sader-d afellay i yellan alamma d %1$s - - Fren amaɣnu Fren tugna n umaɣnu Fren tugna-ik·im tamaynut n umaɣnu Kkes tugna n umaɣnu @@ -924,10 +904,6 @@ Tesbedḍeḍ amkud n yiznan ur nettdum ara ɣer %1$s n tesdatin Amkud n yiznan ur nettdum ara yettwasbed ar %1$s n tesdatin sɣur %2$s. - - Tesbeddeḍ amkud n yiznan ur nettdum ara ɣer 1 n tesdat. - - Amkud n yiznan ur nettdum ara yettwasbed ar 1 n tesdat sɣur %1$s. Tesbeddeḍ amkud n yiznan ue nettdum ara ɣer 1 n usrag. Amkud n yiznan ur nettdum ara yettwasbed ar 1 n usrag sɣur %1$s. @@ -1024,8 +1000,6 @@ Yettwasenked sɣur %1$s Yettwasenked sɣur nekk Yettwasenked - - Akken ad tḍemneḍ awgelhan n yixef ɣer yixef, tzemreḍ kan ad ternuḍ inermisen s tecreḍt n usenqed tadalt i ugraw-a.\n\nTzemreḍ ad temlileḍ inermisen s timmad-nsen u ad tesḍummeḍ tangalt-nsen QR akken ad ten-senkdeḍ. Fren adiwenni i tuzna n yizen ɣer %1$s ɣur-s yakkan izen n urewway, tebɣiḍ ad t-tbeddleḍ? diff --git a/src/main/res/values-ko/strings.xml b/src/main/res/values-ko/strings.xml index 659585208..6322005a4 100644 --- a/src/main/res/values-ko/strings.xml +++ b/src/main/res/values-ko/strings.xml @@ -174,8 +174,6 @@ 대화상대 추가 채팅 시작 새 그룹 - - 수신자 추가 보내기 이모티콘 키보드 전환 그룹 편집 @@ -384,8 +382,6 @@ 수신 메시지 발신 메시지 - - %1$s에 저장됨 연결 연결되지 않음 @@ -448,8 +444,6 @@ - 사용자 정의 사용: %s - 기본값 사용: %s 내 프로필 프로필 사진 차단 목록 @@ -478,8 +472,6 @@ 알림음 무음 개인정보 보호 - - 대화 및 미디어 시스템 기본값 밝게 @@ -592,10 +584,6 @@ 사라지는 메시지 타이머를 %1$s초로 설정함. %2$s가 사라지는 메시지 타이머를 %1$s초로 설정함. - - 사라지는 메시지 타이머를 1분으로 설정함. - - %1$s가 사라지는 메시지 타이머를 1분으로 설정함. 사라지는 메시지 타이머를 1시간으로 설정함. %1$s가 사라지는 메시지 타이머를 1시간으로 설정함. diff --git a/src/main/res/values-lt/strings.xml b/src/main/res/values-lt/strings.xml index dfae6c2f4..253f30bda 100644 --- a/src/main/res/values-lt/strings.xml +++ b/src/main/res/values-lt/strings.xml @@ -106,20 +106,6 @@ Adresatas paskutinį kartą matytas %1$s Adresatas paskutinį kartą matytas: Nežinoma - - - %d sekundė - %d sekundės - %d sekundžių - %d sekundė - - - - %d minutė - %d minutės - %d minučių - %d minutė - %d min. @@ -254,8 +240,6 @@ Kanalai Naujas kanalas - - Pridėti gavėjus Kanalo pavadinimas El. paštas @@ -368,23 +352,23 @@ Skambinti + Garso skambutis + Vaizdo skambutis Atsiliepti Atmesti - - Išsiunčiamasis skambutis + Išsiunčiamasis garso skambutis + Išsiunčiamasis vaizdo skambutis - - Gaunamasis skambutis + Gaunamasis garso skambutis + Gaunamasis vaizdo skambutis Praleistas skambutis - - %1$s, %2$s @@ -543,8 +527,6 @@ Gaunamosios žinutės Išsiunčiamosios žinutės - - Saugykla ties %1$s Jungiamumas Neprisijungta @@ -663,6 +645,7 @@ Bendrinimas nutrauktas dėl trūkstamų leidimų. + Jūsų profilis Profilio nuotrauka Užblokuoti adresatai @@ -694,8 +677,6 @@ Šviesos diodo spalva Garsas Privatumas - - Pokalbiai ir medija Sistemos numatytoji Šviesus @@ -811,6 +792,9 @@ Jūs pakeitėte grupės paveikslą. %1$s pakeitė grupės paveikslą. + Jūs pakeitėte pokalbio aprašą. + + %1$s pakeitė pokalbio aprašą. Dalyvis %1$s pašalintas. @@ -830,10 +814,6 @@ Jūs nustatėte išnykstančių žinučių laikmatį į %1$s sek. %2$s nustatė išnykstančių žinučių laikmatį į %1$s sek. - - Jūs nustatėte išnykstančių žinučių laikmatį į 1 minutę. - - %1$s nustatė išnykstančių žinučių laikmatį į 1 minutę. Jūs nustatėte išnykstančių žinučių laikmatį į 1 valandą. %1$s nustatė išnykstančių žinučių laikmatį į 1 valandą. @@ -945,6 +925,8 @@ Apversti Pasukti + + Tai yra oficiali Delta Chat programa, skirta darbalaukio aplinkai. Ši programinė įranga yra licencijuota pagal GNU GPL versiją 3, o pirminis kodas yra prieinamas „GitHub“ internetinėje svetainėje. Sveiki atvykę į Delta Chat Pasirinkti kalbą... diff --git a/src/main/res/values-nb/strings.xml b/src/main/res/values-nb/strings.xml index 6daa7457e..a990880c3 100644 --- a/src/main/res/values-nb/strings.xml +++ b/src/main/res/values-nb/strings.xml @@ -118,16 +118,6 @@ Sist sett %1$s Sist sett: uvisst - - - %d sekund - %d sekunder - - - - %d minutt - %d minutter - %d minutt @@ -259,8 +249,6 @@ Kanaler Ny kanal - - Legg til mottakere Kanalnavn E-post @@ -388,15 +376,9 @@ Svar Avvis - - Utgående anrop - - Innkommende anrop Avvist anrop Avbrutt anrop Ubesvart anrop - - %1$s, %2$s @@ -530,6 +512,7 @@ Videresend til… + Din profilinfo Profilbilde Blokkerte kontakter @@ -549,8 +532,6 @@ Lyd Stille Personvern - - Chatter og medier System standard Lys diff --git a/src/main/res/values-nl/strings.xml b/src/main/res/values-nl/strings.xml index 2974b9cc6..775801263 100644 --- a/src/main/res/values-nl/strings.xml +++ b/src/main/res/values-nl/strings.xml @@ -118,16 +118,13 @@ Laatst gezien: %1$s Laatst gezien: onbekend - - - %d seconde - %d seconden - - - - %d minuut - %d minuten + + + Duur: %d minuut + Duur: %d minuten + + Minder dan een minuut %d minuut @@ -180,6 +177,9 @@ Toevoegen aan stickerverzameling Stickers toevoegen kan als volgt: druk op ‘Stickermap openen’, maak een onderliggende map aan voor je pakket en plaats afbeeldings- en stickerbestanden in de map. Stickermap openen + Wil je deze sticker toevoegen aan je verzameling? + Wil je deze sticker verwijderen? + Druk op een sticker om die hier toe te voegen. Afbeeldingen Audio @@ -259,10 +259,13 @@ Kanalen Nieuw kanaal - - Ontvangers toevoegen Kanaalnaam + + + %d keer gelezen + %d keer gelezen + E-mail Nieuwe e-mail @@ -278,6 +281,7 @@ Bijlage toevoegen Groep verlaten Kanaal verlaten + Vertalen en verwijderen voor mij Gesprek verwijderen Ale berichten wissen @@ -384,25 +388,29 @@ Bellen + Audiogesprek + Videogesprek Aanvaarden Weigeren - - Uitgaande oproep + + Audiogesprek + + Videogesprek + Uitgaand audiogesprek + Uitgaand videogesprek - - Inkomende oproep + Inkomend audiogesprek + Inkomend videogesprek Geweigerde oproep Afgebroken oproep Gemiste oproep - - %1$s %2$s @@ -588,8 +596,6 @@ Inkomende berichten Uitgaande berichten - - Opslag op %1$s Verbindingen Niet verbonden @@ -718,8 +724,6 @@ - Aangepast wordt gebruikt: %s - Standaard wordt gebruikt: %s Mijn profiel Profielfoto Geblokkeerde contactpersonen @@ -733,6 +737,7 @@ Versturen met enter Druk op de entertoets om tekstberichten te versturen Kwaliteit van verstuurde media + Als je de oorspronkelijke kwaliteit wilt behouden, verstuur het dan als bestand. De huidige methode verbruikt meer data bij zowel jou als de ontvangers. Normaal Lagere kwaliteit, kleinere bestanden Trillen @@ -743,6 +748,10 @@ Meldingen Vermeldingen Toon berichten gericht aan jou in genegeerde groepen + + Oproepen + + Belscherm tonen bij inkomende oproepen Tonen Prioriteit Systeemmeldingen tonen bij nieuwe berichten @@ -752,8 +761,6 @@ Geluid Stil Privacy - - Gesprekken en media Systeemstandaard Licht @@ -818,8 +825,6 @@ %1$s bericht Maximaal downloaden tot en met een aantal van %1$s - - Kies een profiel Profielfoto kiezen Kies een nieuwe profielfoto Profielfoto verwijderen @@ -925,10 +930,6 @@ Je hebt de tijdklok van verdwijnende berichten ingesteld op %1$s sec. De tijdklok van verdwijnende berichten is door %2$s ingesteld op %1$s sec. - - Je hebt de tijdklok van verdwijnende berichten ingesteld op 1 minuut. - - De tijdklok van verdwijnende berichten is door %1$s ingesteld op 1 minuut. Je hebt de tijdklok van verdwijnende berichten ingesteld op 1 uur. De tijdklok van verdwijnende berichten is door %1$s ingesteld op 1 uur. @@ -1025,8 +1026,6 @@ Geïntroduceerd door %1$s Geïntroduceerd door mij Geïntroduceerd - - Om de eind-tot-eindversleuteling te garanderen, kun je alleen contactpersonen met een groen vinkje toevoegen aan deze groep.\n\nVan niet-goedgekeurde contactpersonen kun je de QR-code scannen om ze goed te keuren. Kies in welk gesprek je dit bericht wilt delen %1$s bevat al een concept. Wil je dit vervangen? diff --git a/src/main/res/values-pl/strings.xml b/src/main/res/values-pl/strings.xml index ed4a2ece2..800a4732f 100644 --- a/src/main/res/values-pl/strings.xml +++ b/src/main/res/values-pl/strings.xml @@ -118,20 +118,6 @@ Ostatnio widziano %1$s Ostatnio widziano: nie wiadomo - - - %d sekunda - %d sekundy - %d sekund - %d sekundy - - - - %d minuta - %d minuty - %d minut - %d minuty - %d min @@ -281,8 +267,6 @@ Kanały Nowy kanał - - Dodaj subskrybentów Nazwa kanału E-mail @@ -408,25 +392,25 @@ Zadzwoń + Połączenie audio + Połączenie wideo Odbierz Odrzuć - - Połączenie wychodzące + Wychodzące połączenie audio + Wychodzące połączenie wideo - - Połączenie przychodzące + Przychodzące połączenie audio + Przychodzące połączenie wideo Połączenie odrzucone Połączenie anulowane Połączenie anulowane - - %1$s, %2$s @@ -622,8 +606,6 @@ Wiadomości przychodzące Wiadomości wychodzące - - Zajętość skrzynki %1$s Łączność Nie połączony @@ -752,8 +734,6 @@ - Używanie niestandardowe: %s - Użyj domyślne: %s Twój profil Zdjęcie profilowe Zablokowane kontakty @@ -785,8 +765,6 @@ Dźwięk Cichy Prywatność - - Czaty i media Domyślny systemu Jasny @@ -851,8 +829,6 @@ Wiadomość %1$s Maksymalna ilość do pobrania dostępna do %1$s - - Wybierz profil Wybierz zdjęcie profilowe Wybierz nowe zdjęcie profilowe Usuń zdjęcie profilowe @@ -955,10 +931,6 @@ Ustawiono zegar znikających wiadomości na %1$s s. Użytkownik %2$s ustawił zegar znikających wiadomości na %1$s s. - - Ustawiono zegar znikających wiadomości na 1 minutę. - - Użytkownik %1$s ustawił zegar znikających wiadomości na 1 minutę. Ustawiono zegar znikających wiadomości na 1 godzinę. Użytkownik %1$s ustawił zegar znikających wiadomości na 1 godzinę. @@ -1055,8 +1027,6 @@ Kontakt zweryfikowany przez %1$s Kontakt zweryfikowany przez ciebie Zweryfikowano - - Aby zagwarantować szyfrowanie end-to-end, do tej grupy możesz dodawać wyłącznie kontakty oznaczone zielonym znacznikiem wyboru.\n\nMożesz spotkać się z kontaktami osobiście i zeskanować ich kod QR, aby je zweryfikować. Wybierz czat, do którego chcesz wysłać wiadomość Na czacie %1$s jest już wersja robocza wiadomości, czy chcesz ją zastąpić? diff --git a/src/main/res/values-pt-rBR/strings.xml b/src/main/res/values-pt-rBR/strings.xml index eb1e91afe..dcb1ff413 100644 --- a/src/main/res/values-pt-rBR/strings.xml +++ b/src/main/res/values-pt-rBR/strings.xml @@ -205,8 +205,6 @@ Novo contato Nova conversa Novo grupo - - Adicionar Destinatários Enviar Ativar teclado de emojis Modificar grupo @@ -459,8 +457,6 @@ Mensagens Recebidas Mensagens Enviadas - - Armazenamento em %1$s Conectividade Não conectado @@ -525,8 +521,6 @@ - Usando personalização: %s - Usando padrão: %s Informações do seu perfil Foto do perfil Contatos bloqueados @@ -556,8 +550,6 @@ Som Silêncio Privacidade - - Conversas e mídia Padrão do sistema Claro @@ -684,10 +676,6 @@ Você definiu o temporizador de desaparecimento de mensagens para %1$s segundos Temporizador de desaparecimento de mensagens definido a %1$s s por %2$s. - - Você definiu o temporizador de desaparecimento de mensagens para 1 minuto. - - Temporizador de desaparecimento de mensagens definido para 1 minuto por %1$s. Você definiu o temporizador de desaparecimento de mensagens para 1 hora. Temporizador de desaparecimento de mensagens definido para 1 hora por %1$s. diff --git a/src/main/res/values-pt/strings.xml b/src/main/res/values-pt/strings.xml index aba9e92c5..a72488ebe 100644 --- a/src/main/res/values-pt/strings.xml +++ b/src/main/res/values-pt/strings.xml @@ -345,8 +345,6 @@ Reencaminhar ... Enviar %1$d arquivo(s) para %2$d conversas? - Utilizando personalizado %s - Utilizando padrão: %s O seu perfil Foto do perfil Bloquear contactos @@ -369,8 +367,6 @@ Som Silêncio Privacidade - - chats e mídia Configuração por omissão Luz diff --git a/src/main/res/values-ro/strings.xml b/src/main/res/values-ro/strings.xml index 096f97eb4..1b6cab6de 100644 --- a/src/main/res/values-ro/strings.xml +++ b/src/main/res/values-ro/strings.xml @@ -196,8 +196,6 @@ Contact Nou Chat Nou Grup Nou - - Adăugați Destinatari Trimiteți Comutați Tastatura Emoji Editați grupul @@ -444,8 +442,6 @@ Mesaje primite Mesaje de ieșire - - Depozitarea pe %1$s Conectivitate Nu este conectat @@ -510,8 +506,6 @@ - Folosind o comandă personalizată: %s - Utilizarea implicită: %s Informații despre profilul dumneavoastră Imagine de profil Contacte blocate @@ -541,8 +535,6 @@ Sunet Silențios Confidențialitate - - Discuții și mass-media Sistem implicit Lumină diff --git a/src/main/res/values-ru/strings.xml b/src/main/res/values-ru/strings.xml index 3ef5d70c9..529ab6e83 100644 --- a/src/main/res/values-ru/strings.xml +++ b/src/main/res/values-ru/strings.xml @@ -118,20 +118,15 @@ Последнее посещение: %1$s Последнее посещение: Неизвестно - - - %d секунда - %d секунды - %d секунд - %d секунд - - - - %d минута - %d минуты - %d минут - %d минут + + + Длительность: %d минута + Длительность: %d минуты + Длительность: %d минут + Длительность: %d минут + + Менее 1 минуты %d минуту @@ -281,10 +276,15 @@ Каналы Новый канал - - Добавить получателей Название канала + + + %d просмотр + %d просмотра + %d просмотров + %d просмотров + Электронная почта Новое письмо @@ -408,25 +408,25 @@ Позвонить + Аудиозвонок + Видеозвонок Ответить Отклонить - - Исходящий звонок + Исходящий аудиозвонок + Исходящий видеозвонок - - Входящий звонок + Входящий аудиозвонок + Входящий видеозвонок Отклонённый звонок Отменённый звонок Пропущенный звонок - - %1$s, %2$s @@ -622,8 +622,6 @@ Входящие сообщения Исходящие сообщения - - Хранилище на %1$s Соединение Нет соединения @@ -752,8 +750,6 @@ - Использовать персональное: %s - Используется по умолчанию: %s Ваш профиль Изображение профиля Заблокированные контакты @@ -767,6 +763,7 @@ Отправка по Enter Нажатие клавиши Enter отправит текстовые сообщения Качество отправляемых медиафайлов + Чтобы отправить файлы в оригинальном качестве, прикрепляйте их как \"Файл\". Это потребует больше трафика для вас и ваших получателей. Сбалансированное Низкое качество, малый размер Вибрация @@ -777,6 +774,10 @@ Уведомления Упоминания В группах с отключенными уведомлениями, оповещать о сообщениях, адресованных вам, например об ответах или реакциях + + Звонки + + Показывать экран входящего вызова Показывать Приоритет Включить системные уведомления для новых сообщений @@ -786,8 +787,6 @@ Звук Беззвучный Конфиденциальность - - Чаты и медиафайлы По умолчанию Светлая @@ -852,8 +851,6 @@ %1$s сообщение Максимальная загрузка доступна до %1$s - - Выбрать профиль Выбрать изображение профиля Выбрать новое изображение профиля Удалить изображение профиля @@ -959,10 +956,6 @@ Вы включили автоудаление сообщений через %1$s сек %2$s включает автоудаление сообщений через %1$s сек - - Вы включили автоудаление сообщений через 1 минуту. - - %1$s включает автоудаление сообщений через 1 минуту. Вы включили автоудаление сообщений через 1 час. %1$s включает автоудаление сообщений через 1 час. @@ -1059,8 +1052,6 @@ Подтверждён: %1$s Подтверждён: Лично Подтверждён - - Чтобы обеспечить сквозное шифрование, вы можете добавлять в эту группу только контакты с зеленой галочкой.\n\nВы можете встретиться с контактами лично и отсканировать их QR-код, чтобы подтвердить их личность. Выберите чат, чтобы отправить сообщение %1$s уже имеет черновик, вы хотите его заменить? diff --git a/src/main/res/values-sc/strings.xml b/src/main/res/values-sc/strings.xml index 49571ee26..41ffd37c9 100644 --- a/src/main/res/values-sc/strings.xml +++ b/src/main/res/values-sc/strings.xml @@ -329,8 +329,6 @@ - Personalizatziones impreadas: %s - Predefinidos impreados: %s Sas informatziones de profilu tuas Fotografia de profilu Cuntatos blocados @@ -359,8 +357,6 @@ Sonu Istuda su sonu Riservadesa - - Tzarradas e mèdios Predefinidu de sistema Craru diff --git a/src/main/res/values-sk/strings.xml b/src/main/res/values-sk/strings.xml index 4f6b91d7b..200fa7831 100644 --- a/src/main/res/values-sk/strings.xml +++ b/src/main/res/values-sk/strings.xml @@ -251,8 +251,6 @@ Kanály Nový kanál - - Pridať Príjemcov Názov kanálu E-mail @@ -548,8 +546,6 @@ Ak chcete pokračovať, aktualizujte toto zariadenie na najnovšiu verziu Delta Prichádzajúce Správy Odchádzajúce Správy - - Dáta o %1$s Pripojenie Nepripojené @@ -647,8 +643,6 @@ Ak chcete pokračovať, aktualizujte toto zariadenie na najnovšiu verziu Delta - Používa sa vlastné: %s - Používa sa predvolené nastavenie: %s Informácie o vašom účte Profilový obrázok Blokované kontakty @@ -678,8 +672,6 @@ Ak chcete pokračovať, aktualizujte toto zariadenie na najnovšiu verziu Delta Zvuk Ticho Ochrana osobných údajov - - Konverzácie a médiá Predvolené nastavenie Svetlo @@ -738,8 +730,6 @@ Ak chcete pokračovať, aktualizujte toto zariadenie na najnovšiu verziu Delta %1$s správa Maximálny počet stiahnutí k dispozícii do %1$s - - Vybrať účet Vyberte profilový obrázok Vyberte svoj nový profilový obrázok Odstrániť profilový obrázok @@ -823,10 +813,6 @@ Ak chcete pokračovať, aktualizujte toto zariadenie na najnovšiu verziu Delta Časovač zmiznutia správ nastavený na %1$s s mnou. Časovač zmiznutia správ nastavený na %1$s s od %2$s. - - Časovač zmiznutia správ bol nastavený na 1 minútu mnou. - - Časovač zmiznutia správ bol nastavený na 1 minútu od %1$s. Časovač zmiznutia správ bol nastavený na 1 hodinu mnou. Časovač zmiznutia správ bol nastavený na 1 hodinu od %1$s. diff --git a/src/main/res/values-sq/strings.xml b/src/main/res/values-sq/strings.xml index 8ab510998..f2504eed6 100644 --- a/src/main/res/values-sq/strings.xml +++ b/src/main/res/values-sq/strings.xml @@ -117,16 +117,13 @@ Parë së fundi %1$s Parë së fundi më: S’dihet - - - %d sekondë - %d sekonda - - - - %d minutë - %d minuta + + + Kohëzgjatja %d minutë + Kohëzgjatja %d minuta + + Më pak se 1 minutë %d min @@ -179,6 +176,9 @@ Shtoje te Koleksion Ngjitësish Që të shtoni ngjitës, prekni “Hap Dosje Ngjitësish”, krijoni një nëndosje për paketën e ngjitësit tuaj dhe tërhiqni në të figurë dhe kartela ngjitësi Hap Dosje Ngjitësish + Të shtohet ky ngjitës te koleksioni juaj? + Të fshihet ky ngjitës? + Prekni një ngjitës që të shtohet këtu. Figura Audio @@ -215,6 +215,8 @@ Lojëra Mjete Madhësi + + Botuar më Shtoje te Fjalosje Shfletoni @@ -254,10 +256,13 @@ Kanale Kanal i Ri - - Shtoni Marrës Emër Kanali + + + %d parje + %d parje + Email Email i Ri @@ -273,6 +278,7 @@ Shtoni Bashkëngjitje Braktiseni Grupin Braktise Kanalin + Dil & Fshiji për Mua Fshije Fjalosjen Spastroje Fjalosjen @@ -379,25 +385,29 @@ Thirreni + Thirrje Me Zë + Thirrje Me Video Përgjigjuni Hidhe poshtë - - Thirrje e dërguar + + Thirrje audio + + Thirrje video + Thirrje audio e dërguar + Thirrje video e dërguar - - Thirrje e marrë + Thirrje audio e marrë + Thirrje video e marrë Thirrje e hedhur poshtë Thirrje e anuluar Thirrje e humbur - - %1$s, %2$s @@ -582,8 +592,6 @@ Mesazhe të Marrë Mesazhe të Dërguar - - Depozitim në %1$s Aftësi lidhjeje I palidhur @@ -712,8 +720,6 @@ - Po përdoret vetjak: %s - Po përdoret parazgjedhje: %s Profili Juaj Foto Profili Kontakte të Bllokuar @@ -727,6 +733,7 @@ Tasti Enter Dërgon Shtypja e tastit Enter do të dërgojë mesazhe tekst Cilësi Mediash Në Dalje + Që të dërgohet në cilësinë origjinale, bashkëngjiteni median si një “Kartelë”. Kjo përdor në mënyrë domethënëse më tepër të dhëna për ju dhe marrësit tuaj. E baraspeshuar Cilësia më e ulët, madhësi e vogël Dridhu @@ -737,6 +744,10 @@ Njoftime Përmendje Në grupe të heshtuar, njofto për mesazhe drejtuar jush, si përgjigjet, apo reagimet + + Thirrje + + Shfaq skenë thirrjeje për thirrje ardhëse Shfaqi Përparësi Aktivizoni njoftime sistemi për mesazhe të reja @@ -746,8 +757,6 @@ Tingull Heshtazi Privatësi - - Fjalosje dhe Media Parazgjedhje sistemi E çelët @@ -811,8 +820,6 @@ Mesazh %1$s Shkarko maksimumin e mundshëm deri më %1$s - - Përzgjidhni Profil Përzgjidhni Figurë Profili Përzgjidhni figurën tuaj të re të profilit Fshini Figurën e Profilit @@ -918,10 +925,6 @@ E vutë kohëmatësin e zhdukjes së mesazheve në %1$s sekonda Kohëmatësi i zhdukjes së mesazheve u vu në %1$s sekonda nga %2$s. - - E vutë kohëmatësin e zhdukjes së mesazheve në 1 minutë. - - Kohëmatësi i zhdukjes së mesazheve u vu në 1 minutë nga %1$s. E vutë kohëmatësin e zhdukjes së mesazheve në 1 orë. Kohëmatësi i zhdukjes së mesazheve u vu në 1 orë nga %1$s. @@ -1018,8 +1021,6 @@ Sjellë nga %1$s Sjellë nga unë Sjellë nga - - Që të garantohet fshehtëzim skaj-më-skaj, mund të shtoni në këtë grup vetëm kontakte me shenjën e gjelbër të verifikimit.\n\nMund të takoheni drejtpërsëdrejti me kontakte të paverifikuar dhe të skanoni kodin e tyre QR për t’i verifikuar ata. Përzgjidhni fjalosje ku të dërgohet mesazhi %1$s përmban tashmë një skicë mesazhi, doni të zëvendësohet? diff --git a/src/main/res/values-sr/strings.xml b/src/main/res/values-sr/strings.xml index ceea30866..77b3620d9 100644 --- a/src/main/res/values-sr/strings.xml +++ b/src/main/res/values-sr/strings.xml @@ -217,8 +217,6 @@ Нови контакт Ново ћаскање Нова група - - Додај примаоце Пошаљи Емоџи тастатура Уреди групу @@ -457,8 +455,6 @@ Долазеће поруке Одлазеће поруке - - Складиштење на %1$s Повезаност Нисте повезани @@ -523,8 +519,6 @@ - Користи поручен: %s - Користи уобичајено: %s Подаци вашег профила Слика профила Блокирани контакти @@ -554,8 +548,6 @@ Звук Без звука Приватност - - Ћаскања и медији Подразумеване поставке оперативног система Светла @@ -682,10 +674,6 @@ Ви сте подесили хронометар за нестајуће поруке на %1$s секунде %2$s је подесио хронометар за нестајуће поруке на %1$s секунде. - - Ви сте подесили хронометар за нестајуће поруке на 1 минут. - - %1$s је подесио хронометар за нестајуће поруке на 1 минут. Ви сте подесили хронометар за нестајуће поруке на 1 сат. %1$sје подесио хронометар за нестајуће поруке на 1 сат. diff --git a/src/main/res/values-sv/strings.xml b/src/main/res/values-sv/strings.xml index a66d94797..91385a04d 100644 --- a/src/main/res/values-sv/strings.xml +++ b/src/main/res/values-sv/strings.xml @@ -78,7 +78,7 @@ Aktivera Läs in fjärrbilder - Fjärrbilder kan användas för att spåra dig.\n\nInställningen att fonter och annat innehåll laddas in. Även om inställningen är inaktiverad kan eventuellt inbäddade eller cachade fjärrbilder visas.\n\nLadda in fjärrbilder? + Fjärrbilder kan användas för att spåra dig.\n\nInställningen att fonter och annat innehåll laddas in. Även om inställningen är inaktiverad kan eventuellt inbäddade eller cachade fjärrbilder visas.\n\nVill du läsa in fjärrbilder? Alltid Läs alltid in fjärrbilder en gång @@ -118,16 +118,13 @@ Sågs senast %1$s Sågs senast: Okänt - - - %d sekund - %d sekunder - - - - %d minut - %d minuter + + + %d minuts fördröjning + %d minuters fördröjning + + Mindre än 1 minut %d min @@ -180,6 +177,9 @@ Lägg till i dekalsamling För att lägga till dekaler trycker du på \"Öppna dekalmapp\", skapar en undermapp för ditt dekalpaket och drar bild- och dekalfiler dit. Öppna dekalmapp + Vill du lägga till denna dekal till din samling? + Vill du ta bort denna dekal? + Tryck på en dekal för att lägga till den här. Bilder Ljud @@ -259,10 +259,13 @@ Kanaler Ny kanal - - Lägg till mottagare Kanalnamn + + + %d visning + %d visningar + E-post Ny e-post @@ -278,11 +281,12 @@ Lägg till bilaga Lämna grupp Lämna kanalen + Lämna & ta bort för mig Ta bort chatt Rensa chatt Ta bort kontakt - Ta bort denna plats? + Vill du ta bort denna plats? Meddelandedetaljer Kopiera till urklipp Dela inbjudningslänk @@ -378,25 +382,35 @@ Skicka följande fil till %s? - Skicka de följande filerna %d till %s ? + Vill du skicka följande %d filer till %s ? Filen är sparad i \"%1$s\". Ring upp + + Ljudsamtal + + Videosamtal Svara Avböj - - Utgående samtal - - Inkommande samtal + + Ljudsamtal + + Videosamtal + + Utgående ljudsamtal + + Utgående videosamtal + + Inkommande ljudsamtal + + Inkommande videosamtal Nekade samtal Avbrutna samtal Missade samtal - - %1$s, %2$s @@ -411,16 +425,16 @@ Vill du ta bort %d meddelande? Vill du ta bort %d meddelanden? - Vidarebefordra meddelanden till %1$s? + Vill du vidarebefordra meddelanden till %1$s? Vill du vidarebefordra meddelanden till %1$d chattar? - Exportera bilagor? Exporterade bilagor tillåter andra appar på din enhet att komma åt dem.\n\nVill du fortsätta? - Blockera den här kontakten? Du kommer inte att få fler meddelanden från den här kontakten. - Avblockera den här kontakten? Du kommer återigen att kunna ta emot meddelanden från den här kontakten. - Ta bort kontakter? Detta kommer att ta bort de valda kontakterna permanent.\n\nKontakter med pågående chattar och kontakter från systemets adressbok kan inte tas bort permanent. - Ta bort kontakten %1$s?\n\nKontakter med pågående chattar och kontakter från systemets adressbok kan inte tas bort permanent. - Chatta med %1$s ? + Vill du exportera bilagor? Exporterade bilagor tillåter andra appar på din enhet att komma åt dem.\n\nVill du fortsätta? + Vill du blockera den här kontakten? Du kommer inte att få fler meddelanden från den här kontakten. + Vill du avblockera den här kontakten? Du kommer återigen att kunna ta emot meddelanden från den här kontakten. + Vill du ta bort kontakter? Detta kommer att ta bort de valda kontakterna permanent.\n\nKontakter med pågående chattar och kontakter från systemets adressbok kan inte tas bort permanent. + Vill du ta bort kontakten %1$s?\n\nKontakter med pågående chattar och kontakter från systemets adressbok kan inte tas bort permanent. + Vill du chatta med %1$s? - Ta bort %1$s från gruppen? + Vill du ta bort %1$s från gruppen? Vill du ta bort %1$s från kanalen? Vill du öppna denna länk? @@ -582,8 +596,6 @@ Inkommande meddelanden Utgående meddelanden - - Lagring på %1$s Anslutning Inte ansluten @@ -656,6 +668,20 @@ Vill du verkligen ta bort \"%1$s\"? Vill du använda proxyn \"%1$s\"? Dina vänner kan lägga till denna proxy genom att skanna QR-koden. + Dela länk + Proxy aktiverad + Du använder en proxy. Prova med en annan proxy om du har problem att ansluta. + + Reläer + Lägg till relä + Ta bort relä + Redigera relä + + Vill du lägga till detta relä? + Den skannade QR-koden innehåller inget giltigt relä. + + Vill du ta bort relä \"%1$s\"?\n\nDina kontakter kan endast nå dig om du tidigare har kontaktat dem via annat relä.\n\nTa bort reläet senare, om du är osäker. + Certifikatkontroller Ange en giltig e-postadress Ange en giltig server/IP-adress @@ -670,6 +696,12 @@ Acceptera ogiltiga certifikat Byt konto Lägg till konto + + Profiletikett + T.ex. Arbete, Familj + Ange en etikett som bara är synlig för dig och som hjälper dig att skilja mellan dina profiler. + + Flytta högst upp Ta bort konto Är du säker på att du vill ta bort din kontodata? Alla profildata för \"%s\" på den här enheten kommer att raderas, inklusive dina inställningar för totalsträckskryptering, kontakter, chattar, meddelanden och media. Denna åtgärd kan inte ångras. @@ -683,14 +715,15 @@ Vidarebefordra till... + + Vill du skicka %1$d filer till \"%2$s\"? Vill du skicka %1$d fil(er) till %2$d chattar? + (Om du skickar flera videor skickas de i befintligt skick, vilket kan vara stort. Om du vill göra dem mindre och spara data kan du skicka varje video i ett eget meddelande) Vill du skicka denna text till %1$d chattar?\n\n\"%2$s\" Delningen avbröts på grund av saknad behörighet. - Använder anpassat: %s - Använder standard: %s Din profilinformation Profilbild Blockerade kontakter @@ -699,10 +732,12 @@ Ditt namn Signaturtext + Beskrivning Returtangenten skickar Tryck på returtangenten för att skicka textmeddelanden Utgående ljudkvalitet + För att skicka originalkvalitet skall du bifoga media som en \"fil\". Detta använder betydligt mer data för dig och dina mottagare. Balanserad Sämre kvalitet, mindre storlek Vibrera @@ -711,6 +746,12 @@ Blockera skärmklipp i tidigare-listan samt inuti programmet Starta om programmet för att tillämpa skärmsäkerhetsinställningarna Avisering + Omnämnanden + Avisera meddelanden som riktas till dig, i tystade grupper, såsom svar eller reaktioner + + Samtal + + Visa samtalsskärm vid inkommande samtal Visa Prioritet Aktivera systemavisering för nya meddelanden @@ -720,8 +761,6 @@ Ljud Tyst Integritet - - Chattar och media Systemets standardinställning Ljust @@ -729,6 +768,7 @@ Utseende Tema Språk + Använd systemets teckensnitt Inkognito-tangentbord Begär att tangentbordet stänger av personlig inlärning @@ -747,6 +787,7 @@ Säkerhetskopiering Säkerhetskopiera chattar till extern lagring En säkerhetskopia hjälper dig att ställa in en ny installation på denna eller någon annan enhet.\n\nSäkerhetskopian innehåller alla meddelanden, kontakter och chattar, liksom din autokrypterade slutpunkt-till-slutpunkt-inställning. Förvara säkerhetskopian på ett säkert ställe eller ta bort den så fort som möjligt. + Exportera denna profil Exportera all %1$d profiler Starta säkerhetskopiering @@ -756,6 +797,10 @@ Använd standardbild Välj från galleriet Om du inaktiverar det här alternativet, tillse att servern och dina andra klienter är konfigurerade i enlighet med detta.\n\nAnnars kanske saker och ting inte fungerar alls. + + Flerenhetsläge + Synkronisera dina meddelanden med dina andra enheter. Aktiveras automatiskt när du lägger till en andra enhet + Flerenhetsläge måste vara aktiverat när du använder samma profil på flera enheter. Inaktivera endast den här inställningen om du har tagit bort profilen från alla andra enheter.\n\nInaktiverar du flerenhetsläget när du använder profilen på flera enheter kommer du att missa meddelanden och få andra problem. Flyttas automatiskt till DeltaChat-mappen Hämta endast från DeltaChat-mappen Visa vanliga e-postmeddelanden @@ -763,6 +808,7 @@ För godkända kontakter Alla Experimentella funktioner + Dessa funktioner kan vara instabila och kan ändras eller tas bort På-begäran platsströmning Standardbakgrund Standardfärg @@ -787,6 +833,14 @@ Avaktivera IMAP IDLE Använd inte IMAP IDLE-tillägget även om servern stöder det. Om du aktiverar det här alternativet kommer hämtningen av meddelanden att fördröjas, aktivera det endast för testning. Skicka statistik till Delta Chats utvecklare + Vill du hjälpa till att förbättra Delta Chat och stödja efterforskning genom att skicka anonym användningsstatistik varje vecka? \n\n👉 Tryck här ... 👈 + Vill du hjälpa till att förbättra Delta Chat och stödja efterforskning genom att skicka anonym användningsstatistik varje vecka? + Tack så mycket! Du kan alltid inaktivera sändningen under "Inställningar -> Avancerat".\n\nHar du dessutom 5 minuter på dig att delta i en vetenskaplig studie om säkerheten i Delta Chat? + Skickande av statistik är redan aktiverat.\n\nVill du inaktivera det? + Avaktivera + Fortsätt skicka + Bilagan innehåller anonym användningsstatistik, som hjälper oss att förbättra Delta Chat. Se https://delta.chat/help#statssending för mer information. Tack! + Sökresultat Inga emoji hittades @@ -841,6 +895,9 @@ Gruppbilden ändrades av mig. Gruppbilden ändrades av %1$s. + Du har ändrat chattbeskrivningen. + + Chattbeskrivningen ändrades av %1$s. Medlemmen %1$s lades till. @@ -853,6 +910,11 @@ Medlemmen %1$s togs bort av %2$s. Medlemmen %1$s togs bort. + + Du lämnade gruppen. + + Du lämnade kanalen. + Du anslöt till kanalen. Gruppen lämnades av %1$s. Gruppbilden togs bort av mig. @@ -868,10 +930,6 @@ Tiduret för meddelandeborttagning är satt till %1$s s av mig. Tiduret för meddelandeborttagning är satt till %1$s s av %2$s. - - Tiduret för meddelandeborttagning är satt till 1 minut av mig. - - Tiduret för meddelandeborttagning är satt till 1 minut av %1$s. Tiduret för meddelandeborttagning är satt till 1 timma av mig. Tiduret för meddelandeborttagning är satt till 1 timma av %1$s. @@ -881,6 +939,9 @@ Du satte tiduret för meddelandeborttagning till 1 vecka. Tiduret för meddelandeborttagning är satt till 1 vecka av %1$s. + Du satte tiduret för meddelandeborttagning till 1 år. + + Tiduret för meddelandeborttagning är satt till 1 år av %1$s. Du satte tiduret för meddelandeborttagning till %1$s minuter. @@ -897,6 +958,7 @@ Du satte tiduret för meddelandeborttagning till %1$s veckor. Tiduret för meddelandeborttagning är satt till %1$s veckor av %2$s. + Meddelanden i denna chatt använder klassisk e-post och är inte totalsträckskrypterade. Meddelanden är garanterat totalsträckskrypterade från och med nu. Tryck för att få veta mer. Det är nu garanterat att alla meddelanden i den här chatten är totalsträckskrypterade.\n\nTotalsträckskryptering håller meddelanden privata mellan dig och dina chattpartners. Inte ens din e-postleverantör kan läsa dem. ⚠️ %1$s kräver totalsträckskryptering som inte är kofigurerad för den här chatten ännu. Tryck för att lära dig mer. @@ -921,6 +983,7 @@ Flytta QR-koden till kameran QR-koden kunde inte avkodas Vill du ansluta till gruppen \"%1$s\"? + Vill du ansluta till kanalen \"%1$s\"? Det skannade fingeravtrycket stämmer inte med senast sedda för %1$s. QR-koden innehåller ett fingeravtryck men ingen e-postadress.\n\nUpprätta först en krypterad anslutning till mottagaren, för en utanför-bandet-verifiering. Skannad QR-kod-text:\n\n%1$s @@ -928,6 +991,7 @@ Fingeravtryck Denna QR-kod kan skannas av andra för att kontakta dig.\n\nDu kan avaktivera QR-koden här och återaktivera den genom att skanna den igen. Denna QR-kod kan skannas av andra för att gå med i gruppen \"%1$s\".\n\nDu kan avaktivera QR-koden här och återaktivera den genom att skanna den igen. + Denna QR-kod kan skannas av andra för att ansluta till kanalen \"%1$s\".\n\nDu kan återställa den, så att den befintliga QR-koden eller inbjudningslänken inte längre fungerar. Avaktivera QR-kod Den här QR-koden kan skannas av andra för att kontakta dig.\n\nQR-koden är inte aktiv på den här enheten. Aktivera QR-kod @@ -938,11 +1002,12 @@ QR-kod för inbjudan Skanna det här för att ansluta till gruppen \"%1$s\". + Skanna det här för att ansluta till kanalen \"%1$s\". QR-kod för inbjudan Skanna det här för att få kontakt med %1$s. Ingen internetanslutning, kan inte utföra QR-kod-inställning. - Skapa ny e-postadress på \"%1$s\" och logga in där? + Vill du skapa ny profil på \"%1$s\" och logga in där? Ange ett namn som dina kontakter kommer att känna igen. Du kan också ange en profilbild. Ange ett namn. Den skannade QR-koden kan inte användas för att konfigurera ett nytt konto. @@ -950,18 +1015,20 @@ Vill du logga in på \"%1$s\"? %1$s bjöd in dig att gå med i den här gruppen.\n\nVäntar på att enheten hos %2$s ska svara... + + %1$s bjöd in dig att ansluta till den här kanalen.\n\nVäntar på att enheten hos %2$s ska svara... %1$s svarade och väntar på att bli tillagd i gruppen... Upprättar garanterad totalsträckskryptering, vänligen vänta... %1$s verifierad. + Du blockerade kontakten Introducerad av %1$s Introducerad av mig - - För att garantera totalsträckskryptering kan du bara lägga till kontakter med en grön bock i den här gruppen.\n\nDu kan träffa kontakter personligen och skanna deras QR-kod för att introducera dem. + Introducerad Välj chatt för att skicka meddelandet till - %1$s redan har ett utkast till meddelande, vill du ersätta det? + %1$s har redan ett utkast till meddelande, vill du ersätta det? mailto-länken kunde inte avkodas: %1$s @@ -974,6 +1041,7 @@ Endast namn Inget namn eller meddelande Avisering avaktiverad + Aktivera \"Tvinga bakgrundsanslutning\" för tillförlitliga meddelanden Nya meddelanden Du har nya meddelanden @@ -1018,6 +1086,8 @@ Utvecklare Utvecklingsverktyg Hjälp + Delta Chats webbplats + Bidra Rapportera ett ärende Om Delta Chat Öppna Delta Chat @@ -1030,6 +1100,8 @@ MMM D Ta bort Save + + Spara inte Namn Välj en gruppbild Exportera säkerhetskopia @@ -1083,6 +1155,18 @@ I genomsnitt var %1$d minut I genomsnitt var %1$d:e timma Kontrollerad %1$s + Systeminställningar + + Redigera typ, märken, förhandsgranskning och mer + + + Genvägar + + Använd Delta Chats \"Lägg till i widget\" för att lägga till objekt + + Ta bort från widget + + Lägg till i widget Delta Chat använder din kamera för att ta och skicka foton och filmer, och för att skanna QR-koder. Delta Chat behöver tillstånd för att dela din plats under den tidsperiod som du har aktiverat platsdelning. @@ -1090,6 +1174,8 @@ Delta Chat använder din mikrofon för att spela in och skicka röstmeddelanden, och filmer med ljud. Delta Chat låter dig välja vilka bilder från ditt galleri som du vill skicka. Delta Chat vill spara bilder i ditt fotogalleri. + Delta Chat kan använda Face ID för att skydda din lokala profil, skapa säkerhetskopior och konfigurera en andra enhet. + Omedelbar leverans Använd bakgrundsanslutning @@ -1102,4 +1188,6 @@ Tryck här för att ta emot meddelanden medan Delta Chat är i bakgrunden. Du har redan gett Delta Chatt tillstånd att ta emot meddelanden i bakgrunden.\n\nKontrollera dina systeminställningar också, om det fortfarande inte kommer några meddelanden i bakgrunden. - + + Vad är nytt?\n\n💯 Totalsträckskryptering är pålitlig och för alltid nu. Hänglås 🔒 är borta!\n\n✉️ Klassisk e-post utan Totalsträckskryptering är markerad med en bokstavssymbol\n\n😻 Ny förbättrad profilskärm för alla dina kontakter\n\n🔲 Ny knapp för snabb åtkomst till appar som används i en chatt\n\n❤️ Vänligen donera för att hjälpa oss att förbli oberoende och fortsätta att göra förbättringar: %1$s + diff --git a/src/main/res/values-ta/strings.xml b/src/main/res/values-ta/strings.xml index fa169cc34..025c405a5 100644 --- a/src/main/res/values-ta/strings.xml +++ b/src/main/res/values-ta/strings.xml @@ -177,6 +177,7 @@ SMTP க்கான கடவுச்சொல் SMTP க்கான சேவகன் SMTP போர்ட் + உங்கள் சுயவிவரத் தகவல் தடுக்கபட்ட தொடர்புகள் எனது பெயர் @@ -193,8 +194,6 @@ ஓசை அமைதி தனியுரிமை - - அரட்டைகள் மற்றும் ஊடகங்கள் கருவி இயல்புநிலை வெளிச்சமிக்க diff --git a/src/main/res/values-te/strings.xml b/src/main/res/values-te/strings.xml index 3e63a6f1d..b6f9425f2 100644 --- a/src/main/res/values-te/strings.xml +++ b/src/main/res/values-te/strings.xml @@ -146,6 +146,7 @@ సమూహము గ్యాలరీ మీడియా పూర్వప్రదర్శన + మీ రూప సమాచారం రూపచిత్రం నిరోధించిన పరిచయాలు @@ -163,8 +164,6 @@ శబ్ధం మౌనం గోప్యత - - ముచ్చట్లు మరియు ప్రసార మాధ్యమం లైటు గుప్తమైన diff --git a/src/main/res/values-tr/strings.xml b/src/main/res/values-tr/strings.xml index f8dba8363..d4471e880 100644 --- a/src/main/res/values-tr/strings.xml +++ b/src/main/res/values-tr/strings.xml @@ -118,16 +118,13 @@ Son görülme %1$s Son görülme: Bilinmeyen - - - %d saniye - %d saniye - - - - %d dakika - %d dakika + + + %d dakika süre + %d dakika süre + + 1 dakikadan az %d dk @@ -180,6 +177,9 @@ Çıkartma Toplamasına Ekle Çıkartmalar eklemek için “Çıkartma Klasörünü Aç”a dokunun, çıkartma paketiniz için bir alt klasör oluşturun ve görseli ve çıkartma dosyalarını oraya sürükleyin Çıkartma Klasörünü Aç + Bu çıkartma, koleksiyonunuza eklensin mi? + Bu çıkartma silinsin mi? + Buraya eklemek için bir çıkartmaya dokunun. Görseller Ses @@ -259,10 +259,13 @@ Kanallar Yeni Kanal - - Aboneleri Ekle Kanal Adı + + + %d görüntülenme + %d görüntülenme + E-Posta Yeni E-Posta @@ -278,6 +281,7 @@ İlişik Ekle Gruptan Ayrıl Kanaldan Ayrıl + Ayrıl ve Benim için Sil Sohbeti Sil Sohbeti Temizle @@ -384,25 +388,29 @@ Ara + Sesli Arama + Görüntülü Arama Yanıtla Reddet - - Giden arama + + Sesli arama + + Görüntülü arama + Giden sesli arama + Giden görüntülü arama - - Gelen arama + Gelen sesli arama + Gelen görüntülü arama Reddedilen arama İptal edilen arama Yanıtlanmayan arama - - %1$s, %2$s @@ -588,8 +596,6 @@ Gelen İletiler Giden İletiler - - %1$s Üzerinde Depolama Bağlanabilirlik Bağlı değil @@ -712,14 +718,12 @@ %1$d dosya “%2$s” sohbetine gönderilsin mi? %1$d dosya %2$d sohbete gönderilsin mi? - (Videolar özgün, büyük dosyalar olarak gönderilir. Videoları daha küçük dosyalar olarak göndermek için onları ayrı ayrı iliştirin) + (Çoklu video göndermek, onları olduğu gibi gönderecektir ve bu da büyük boyutlara yol açabilir. Boyutlarını küçültmek ve veri tasarrufu sağlamak için her videoyu kendi iletisinde gönderebilirsiniz.) Bu metin %1$d sohbete gönderilsin mi?\n\n“%2$s” Eksik izinlerden dolayı paylaşımdan vazgeçildi. - Özel kullanılıyor: %s - Varsayılan kullanılıyor: %s Profiliniz Profil Görseli Engellenen Kişiler @@ -728,10 +732,12 @@ Adınız Biyografi + Açıklama Enter Tuşu Göndersin Enter tuşuna basma, metin iletileri gönderecek Giden Ortam Kalitesi + Özgün kalitede göndermek için ortamı “Dosya” olarak iliştirin. Bu, siz ve alıcılarınız için önemli ölçüde daha fazla veri kullanır. Dengeli Daha kötü kalite, küçük boyut Titret @@ -742,6 +748,10 @@ Bildirimler Anmalar Sessize alınan gruplarda, yanıtlar ya da tepkiler gibi size yönlendirilen iletileri bildirin + + Aramalar + + Gelen aramalarda arama ekranını göster Göster Öncelik Yeni iletiler için sistem bildirimlerini etkinleştir @@ -751,8 +761,6 @@ Ses Sessiz Gizlilik - - Sohbetler ve Ortamlar Sistem varsayılanı Açık @@ -817,8 +825,6 @@ %1$s ileti İndirme en fazla %1$s zamanına kadar kullanılabilir - - Profil Seç Profil Görseli Seç Yeni profil görselinizi seçin Profil Görselini Sil @@ -889,6 +895,9 @@ Grup görselini değiştirdiniz. %1$s tarafından grup görseli değiştirildi. + Sohbet açıklamasını değiştirdiniz. + + %1$s tarafından sohbet açıklaması değiştirildi. %1$s üyesi eklendi. @@ -921,10 +930,6 @@ Kaybolan iletiler zamanlayıcısını %1$s saniye olarak ayarladınız. %2$s tarafından kaybolan iletiler zamanlayıcısı %1$s saniye olarak ayarlandı. - - Kaybolan iletiler zamanlayıcısını 1 dakika olarak ayarladınız. - - %1$s tarafından kaybolan iletiler zamanlayıcısı 1 dakika olarak ayarlandı. Kaybolan iletiler zamanlayıcısını 1 saat olarak ayarladınız. %1$s tarafından kaybolan iletiler zamanlayıcısı 1 saat olarak ayarlandı. @@ -1021,8 +1026,6 @@ %1$s tarafından tanıtıldı Benim tarafımdan tanıtıldı Tanıtıldı - - Uçtan uca şifrelemeyi güvencelemek için bu gruba yalnızca yeşil bir onay imi olan kişileri ekleyebilirsiniz.\n\nDoğrulanmayan kişilerle kişisel olarak tanışabilir ve onları tanıtmak için onların QR kodunu tarayabilirsiniz. İletiyi göndermek için sohbet seçin %1$s zaten bir taslak ileti; onu değiştirmek istiyor musunuz? diff --git a/src/main/res/values-uk/strings.xml b/src/main/res/values-uk/strings.xml index f929ec3f8..a17a8064e 100644 --- a/src/main/res/values-uk/strings.xml +++ b/src/main/res/values-uk/strings.xml @@ -118,20 +118,6 @@ Останній раз бачили %1$s Останній раз бачили: Невідомо. - - - %d секунда - %d секунди - %d секунд - %d секунд - - - - %d хвилина - %d хвилини - %d хвилин - %d хвилин - %d хвилину @@ -281,8 +267,6 @@ Канали Новий Канал - - Додати одержувачів Назва Каналу E-Mail @@ -408,25 +392,25 @@ Дзвонити + Аудіовиклик + Відеовиклик Відповісти Відхилити - - Вихідний дзвінок + Вихідний аудіовиклик + Вихідний відеовиклик - - Вхідний дзвінок + Вхідний аудіовиклик + Вхідний відеовиклик Відхилений дзвінок Скасований дзвінок Пропущений дзвінок - - %1$s, %2$s @@ -622,8 +606,6 @@ Вхідні повідомлення Вихідні повідомлення - - Сховище на %1$s Підключення Не з\'єднано @@ -752,8 +734,6 @@ - Використовувати персоналізовано: %s - Використовувати за замовчуванням: %s Ваш профіль Зображення профілю Заблоковані контакти @@ -786,8 +766,6 @@ Звук Беззвучний Конфіденційність - - Чати й медіа За замовчуванням системи Світла @@ -852,8 +830,6 @@ Повідомлення розміром %1$s\' Доступний для завантаження максимум до %1$s - - Вибрати Профіль Вибрати зображення профілю Вибрати ваше нове зображення профілю Видалити зображення профілю @@ -959,10 +935,6 @@ Ви встановили таймер зникнення повідомлень на %1$s сек. %2$s встановив (-ла) таймер повідомлень, що зникають на %1$s сек. - - Ви встановили таймер зникнення повідомлень на 1 хвилину. - - %1$s встановив (-ла) таймер зникнення повідомлень на 1 хвилину. Ви встановили таймер зникнення повідомлень на 1 годину. %1$s встановив (-ла) таймер зникнення повідомлень на 1 годину. @@ -1059,8 +1031,6 @@ Представни·ця: %1$s Представлено мною Представлено - - Аби наскрізне шифрування лишалося ґарантованим, у цю групу можна додавати лише контактних осіб із зеленою галочкою.\n\nМожна зустрітись із контактною особою та відсканувати баркод із її пристрою, щоби представити її. Виберіть чат, куди потрібно надіслати повідомлення %1$s уже містить чернетку повідомлення, бажаєте її замінити? diff --git a/src/main/res/values-vi/strings.xml b/src/main/res/values-vi/strings.xml index cdc108b27..4962bfe63 100644 --- a/src/main/res/values-vi/strings.xml +++ b/src/main/res/values-vi/strings.xml @@ -190,8 +190,6 @@ Trò chuyện mới Nhóm mới Sao chép cuộc trò chuyện - - Thêm người nhận Gửi Chuyển đổi Bàn phím Emoji Chỉnh sửa nhóm @@ -445,8 +443,6 @@ Tin nhắn đến Tin nhắn đi - - Lưu trữ trên %1$s Kết nối Không được kết nối @@ -511,8 +507,6 @@ - Sử dụng tùy chỉnh: %s - Sử dụng mặc định: %s Thông tin hồ sơ của bạn Ảnh hồ sơ Liên hệ bị chặn @@ -542,8 +536,6 @@ Âm thanh Im lặng Quyền riêng tư - - Trò chuyện và Phương tiện Mặc định hệ thống Ánh sáng @@ -676,10 +668,6 @@ Bạn đặt bộ hẹn giờ cho tin nhắn biến mất thành %1$s giây Bộ hẹn giờ tin nhắn biến mất được %2$s đặt thành %1$s giây. - - Bạn đặt bộ hẹn giờ cho tin nhắn biến mất thành 1 phút. - - Bộ hẹn giờ cho tin nhắn biến mất được %1$s đặt thành 1 phút. Bạn đặt bộ hẹn giờ cho tin nhắn biến mất thành 1 giờ. Bộ hẹn giờ cho tin nhắn biến mất được %1$s đặt thành 1 giờ. @@ -759,8 +747,6 @@ Được giới thiệu bởi %1$s Được tôi giới thiệu - - Để đảm bảo mã hóa hai đầu, bạn chỉ có thể thêm những người liên hệ có dấu kiểm màu xanh lục vào nhóm này.\n\nBạn có thể gặp trực tiếp những người liên hệ và quét Mã QR của họ để giới thiệu họ. Chọn trò chuyện để gửi tin nhắn tới %1$s đã có tin nhắn nháp, bạn có muốn thay thế nó không? diff --git a/src/main/res/values-zh-rCN/strings.xml b/src/main/res/values-zh-rCN/strings.xml index 45057dce5..e85648e71 100644 --- a/src/main/res/values-zh-rCN/strings.xml +++ b/src/main/res/values-zh-rCN/strings.xml @@ -118,14 +118,12 @@ 最后上线 %1$s 最后上线:未知 - - - %d 秒 - - - - %d 分钟 + + + 时长:%d 分钟 + + 不到 1 分钟 %d 分钟 @@ -169,6 +167,9 @@ 添加到贴纸收藏集 要添加贴纸,请点按“打开贴纸文件夹”,为贴纸包创建一个子文件夹,然后将图片和贴纸文件拖动到那里 打开贴纸文件夹 + 将此贴纸添加到您的收藏集? + 删除此贴纸? + 点按贴纸即可将其添加到此处。 图片 音频 @@ -248,10 +249,12 @@ 频道 新建频道 - - 添加订阅者 频道名称 + + + %d 次查看 + 邮件 新建邮件 @@ -267,6 +270,7 @@ 添加附件 离开群组 离开频道 + 离开并为我删除 删除聊天 清空聊天记录 @@ -372,25 +376,29 @@ 呼叫 + 音频通话 + 视频通话 接听 拒接 - - 去电 + + 音频通话 + + 视频通话 + 音频通话去电 + 视频通话去电 - - 来电 + 音频通话来电 + 视频通话来电 已拒接的来电 已取消的通话 未接来电 - - %1$s %2$s @@ -571,8 +579,6 @@ 消息接收 消息发送 - - %1$s 上的存储空间 连接 未连接 @@ -701,8 +707,6 @@ - 使用自定义值: %s - 使用默认值: %s 您的个人资料 个人资料图片 已屏蔽的联系人 @@ -716,6 +720,7 @@ Enter 键发送 按 Enter 键发送文本消息 发出媒体质量 + 如需发送原始质量的媒体,请将媒体以“文件”形式附加。此方式会消耗您和接收者更多的数据使用量。 平衡 体积小,但质量更差 振动 @@ -726,6 +731,10 @@ 通知 提及 在静音群中,通知直接与你相关的消息,比如回复或回应 + + 通话 + + 来电时显示通话屏幕 显示 优先级 为新消息启用系统通知 @@ -735,8 +744,6 @@ 声音 无声 隐私 - - 聊天与媒体 系统默认 明亮 @@ -801,8 +808,6 @@ %1$s 消息 下载最晚于 %1$s 前可用 - - 选择账号 选择个人资料图片 选择新的个人资料图片 删除个人资料图片 @@ -908,10 +913,6 @@ 你将消息定时销毁计时器设置为 %1$s 秒 %2$s 将消息定时销毁计时器设为 %1$s 秒 - - 你将消息定时销毁计时器设为 1 分钟 - - %1$s 将消息定时销毁计时器设为 1 分钟 你将消息定时销毁计时器设为 1 小时 %1$s 将消息定时销毁计时器设为 1 小时 @@ -1008,8 +1009,6 @@ 验证人 %1$s 由您验证 已验证 - - 要确保端到端加密,你只能将带有绿色验证标识的联系人到此群中。\n\n你可以亲自和未验证联系人线下碰面并扫描联系人二维码来验证。 选择要发送消息的聊天 %1$s 已经有一条草稿消息,是否要替换? diff --git a/src/main/res/values-zh-rTW/strings.xml b/src/main/res/values-zh-rTW/strings.xml index bb9f5c71c..7dfcbaec1 100644 --- a/src/main/res/values-zh-rTW/strings.xml +++ b/src/main/res/values-zh-rTW/strings.xml @@ -118,6 +118,12 @@ 最後上線時間:%1$s 最後上線時間:未知 + + + 持續時間%d分鐘 + + + 少於 1 分鐘 %d分鐘前 @@ -161,6 +167,9 @@ 新增至貼圖集 要添加貼圖,請點擊「打開貼圖資料夾」 ,為您的貼圖包創建一個子資料夾,然後將圖像和貼圖檔拖拽到那裡 開啟貼圖資料夾 + 將這個貼圖加入你的收藏嗎? + 移除這個貼圖? + 點擊貼圖以將其添加到這裡。 圖片 音訊 @@ -234,8 +243,23 @@ 與……聊天 克隆聊天 - - 新增對象 + + 頻道 + + 頻道 + + 新頻道 + + 頻道名稱 + + + %d次觀看 + + 郵件 + + 新郵件 + + 主題 送出 切換表情符號鍵盤 編輯群組 @@ -245,6 +269,8 @@ 對話解除封存 新增附件 退出群組 + 離開頻道 + 離開并爲我刪除 刪除對話 清除對話 @@ -329,6 +355,8 @@ 新增至主畫面 捐贈 + ❤️ 您似乎很喜歡 Delta Chat!\n\n請考慮捐款以幫助Delta Chat確保對每個人保持免費。/n/n雖然 Delta Chat 可以免費使用並且是開源的,但開發需要資金。幫助我們保持 Delta Chat 的獨立性,並在未來讓它更棒。\n\nhttps://delta.chat/donate + 靜音1小時 靜音8小時 靜音1天 @@ -346,6 +374,35 @@ 檔案已儲存至「%1$s」。 + + 通話 + + 音訊通話 + + 視訊通話 + + 接聽 + + 拒接 + + 音訊通話 + + 視訊通話 + + 撥出音訊通話 + + 撥出視訊通話 + + 撥入音訊通話 + + 撥出視訊通話 + 已拒接來電 + 已取消通話 + 未接來電 + + + + 確定要離開? 刪除您所有裝置上的%d個聊天記錄? @@ -364,6 +421,8 @@ 要和%1$s開始對話嗎? 要把%1$s移出群組嗎? + + 將%1$s從頻道中移除? 你想開啟此連結嗎? @@ -388,6 +447,8 @@ 向 %1$s 傳送訊息。 + + 頻道是一種用於一對多廣播訊息的工具。 只有在你傳送第一條訊息後,其他人才會看到此群組。 滑動以取消 按住錄音,放開後傳送 @@ -518,8 +579,6 @@ 傳入訊息 傳出訊息 - - %1$s上的存儲空間 連接狀態 未連接 @@ -568,6 +627,7 @@ 安全的去中心化聊天 掃描邀請碼 登入 + 此登入適用於進階使用者:\n\n• 不要使用你在其他應用程式中使用的位址。\n\n• 傳統的電子郵件伺服器允許未使用端對端加密的聊天,並以郵件圖示標示。 收件匣 IMAP 帳號 IMAP 伺服器 @@ -582,7 +642,7 @@ 代理伺服器 使用代理伺服器 - 添加代理伺服器 + 新增代理伺服器 支援的代理伺服器類型:HTTP(S)、SOCKS5 和 Shadowsocks。 在此處輸入代理伺服器連結 代理伺服器無效或不受支援 @@ -592,6 +652,19 @@ 是否要使用代理 「%1$s」? 您的朋友可以通過掃描QR碼來添加此代理。 分享連結 + 已啓用代理伺服器連接 + 你正在使用代理伺服器連接。如果你在連線時遇到問題,請嘗試其他代理伺服器。 + + 中繼伺服器 + 新增中繼伺服器 + 移除中繼伺服器 + 編輯中繼伺服器 + + 新增這個中繼伺服器? + 掃描的 QR 碼不包含有效的中繼伺服器。 + + 是否移除中繼伺服器“%1$s”?\n\n移除后您的聯絡人只有之前通過其他中繼伺服器聯絡過您,他們才可以繼續聯絡您。\n\n如有疑問,請稍後移除中繼伺服器。 + 憑證檢查 請輸入有效的電子郵件位址 請輸入有效的伺服器/IP 位址 @@ -634,8 +707,6 @@ - 使用自訂值: %s - 使用預設值: %s 你的個人資料 大頭照 已封鎖的聯絡人 @@ -644,10 +715,12 @@ 你的名字 簽名檔 + 描述 換行鍵送出訊息 按下換行鍵就送出訊息 傳送的媒體品質 + 若要傳送原始品質的媒體,請將媒體作為「檔案」附加。這會消耗你和收件人大量的數據使用量。 普通 低品質 振動 @@ -658,6 +731,10 @@ 通知設定 提到 在勿擾的群組中,通知傳送給您的訊息,例如回復或回應 + + 通話 + + 來電時顯示通話畫面 顯示通知 通知優先權 為新訊息啟用系統通知 @@ -667,8 +744,6 @@ 音效 靜音 隱私權設定 - - 對話和媒體設定 系統預設 亮色 @@ -695,6 +770,7 @@ 備份 將備份另存至外部儲存媒體 備份可以讓你把設定及對話紀錄轉移到這台或其它裝置上。\n\n備份內容包括所有的訊息、聯絡人、對話、端到端AutoCrypt設定。請務必將備份檔存放在安全的地方,並在轉移後儘快刪除。 + 匯出此賬戶 匯出全部%1$d個賬戶 開始備份 @@ -704,6 +780,10 @@ 使用預設圖案 從相簿中挑選 如果關閉本選項的話,請務必確定你的伺服器和其它客戶端應用程式都使用相同的設定,否則很可能不能運作。 + + 多裝置模式 + 將您的訊息與其他裝置同步。新增第二個裝置時自動啟用 + 在多個裝置上使用同一的帳戶時,必須啟用多裝置模式。只有在您已從所有其他設備移除此帳戶後,才可以禁用此設定。\n\n在多個裝置上使用該帳戶時禁用多裝置模式將導致漏接訊息及其他問題。 自動搬移到 DeltaChat 資料夾 僅從 DeltaChat 資料夾中獲取 顯示傳統email @@ -711,6 +791,7 @@ 顯示已接受的聯絡人 所有郵件 實驗性功能 + 這些功能可能不穩定,並且可能會被更改或移除 按需要位置串流 預設圖片 預設顏色 @@ -727,8 +808,6 @@ %1$s 訊息 下載有效期至%1$s - - 選擇賬戶 選擇大頭貼 選擇新的大頭貼 刪除大頭貼 @@ -737,6 +816,14 @@ 禁用 IMAP IDLE 即使伺服器支援 IMAP IDLE 擴展,也不要使用它。啟用此選項會延遲訊息檢索,僅對測試啟用此選項。 將統計數據發送給 Delta Chat 的開發人員 + 你想透過每週傳送匿名使用統計數據來幫助改進 Delta Chat 並支持研究嗎?\n\n👉 點擊這裡… 👈 + 你想透過每週寄送匿名使用統計資料來幫助改善 Delta Chat 並支持研究嗎? + 謝謝你!你可以隨時在「設定 -> 進階」中關閉傳送。\n\n是否愿意用 5 分鐘的時間參加Delta Chat 的安全性科學研究嗎? + 統計數據的傳送已啟用。\n\n你想要停用它嗎? + 禁用 + 繼續傳送 + 附件包含匿名使用統計數據,這有助於我們改進 Delta Chat。更多資訊請參見 https://delta.chat/help#statssending 。謝謝! + 搜尋結果 找不到表情符號 @@ -791,6 +878,9 @@ 我已更改群組圖片。 %1$s已更改群組圖片。 + 你更改了聊天描述。 + + 聊天描述已被%1$s更改。 已新增成員 %1$s。 @@ -803,6 +893,11 @@ %2$s已移除成員 %1$s 已移除成員 %1$s + + 你已離開該群組。 + + 你已離開該頻道。 + 你已加入該頻道。 %1$s 退出了群組。 我已刪除群組圖片。 @@ -818,10 +913,6 @@ 您將自動刪除訊息計時器設置為%1$s秒 %2$s設置自動刪除訊息計時器為%1$s秒。 - - 您將自動刪除訊息計時器設置為 1 分鐘。 - - %1$s將自動刪除訊息計時器設置為 1 分鐘。 您將自動刪除訊息計時器設置為 1 小時。 %1$s將自動刪除訊息計時器設置為 1 分鐘。 @@ -831,6 +922,9 @@ 您將自動刪除訊息計時器設置為 1週。 %1$s將自動刪除訊息計時器設置為 1週。 + 您將自動刪除訊息計時器設置為 1年。 + + %1$s將自動刪除訊息計時器設置為 1年。 您將自動刪除訊息計時器設置為%1$s分鐘。 @@ -847,6 +941,7 @@ 您將自動刪除訊息計時器設置為%1$s週。 %2$s將自動刪除訊息計時器設置為%1$s週。 + 此聊天中的訊息使用傳統電子郵件,並未進行端對端加密。 從現在開始,保證消息進行端到端加密。輕觸以瞭解更多資訊。 現在可以保證此聊天中的所有消息都是端到端加密的。\n\n端到端加密使您和您的聊天夥伴之間的消息保持私密。甚至您的電子郵件供應商也無法讀取它們。 ⚠️ %1$s需要端到端加密,但尚未為此聊天設置。輕觸以瞭解更多資訊。 @@ -871,6 +966,7 @@ 將 QR 碼移動到相機 QR 碼無法識別 你要參加群組 「%1$s」嗎? + 是否加入頻道“%1$s”? 掃描的指紋與的最後一次看到%1$s的指紋不匹配。 此 QR 碼包含指紋,但不包含電子郵件位址。\n\n要進行帶外驗證,請先與收件者建立加密連接。 掃描的 QR 碼文本:\n\n%1$s @@ -878,6 +974,7 @@ 指紋 其他人可以掃描此QR碼與您聯絡。您可以在此處停用QR碼,然後再次掃描以重新啟動它。 其他人可以掃描此 QR 碼以加入群組「%1$s」。\n\n您可以在此處停用 QR 碼,然後再次掃描以重新啟動它。 + 其他人可以掃描這個 QR 碼以加入此頻道“%1$s”。\n\n您可以重置它,這樣現有的 QR 碼或邀請連結將不再有效。 停用QR碼 此 QR 碼可以被其他人掃描以與您聯絡。\n\n此裝置上的 QR 碼未啟用。 啟用QR碼 @@ -888,6 +985,7 @@ 邀請QR碼 掃描加入群組「%1$s」 + 掃描以加入頻道“%1$s” QR 圖碼邀請碼 掃描 QRCode 以新增 %1$s @@ -900,15 +998,17 @@ 登入「%1$s」? %1$s邀請您加入此群組。\n\n正在等待%2$s裝置的回覆... + + %1$s邀請你加入這個頻道。\n\n正在等待%2$s的裝置的回應… %1$s已回覆,等待被添加到群組中... 正在建立有保證的端到端加密,請稍候... %1$s已介紹。 + 你已封鎖該聯絡人 由%1$s介紹 由我介紹 - - 為了保證端到端加密,您只能將帶有綠色驗證標識的聯絡人添加到此群組。\n\n您可以當面掃描聯絡人的QR碼以介紹他們。 + 已驗證 選擇要將消息發送到的聊天 %1$s已有草稿消息,是否要替換它? @@ -924,6 +1024,7 @@ 僅名稱 沒有名稱或訊息 通知已停用 + 啟用「強制背景連線」以確保完成通知。 新訊息 你有新訊息 @@ -1044,11 +1145,11 @@ 捷徑 - 使用 Delta Chat 的“添加到小工具”添加項目 + 使用 Delta Chat 的“新增到小工具”添加項目 從小工具中移除 - 添加到小工具 + 新增到小工具 Delta Chat 使用您的相機拍攝和傳送照片和影片,以及掃描QR碼。 Delta Chat 需要存取權才能在您啟用位置共用的時間範圍內共用您的位置。 @@ -1070,4 +1171,7 @@ 點擊此處可在 Delta Chat 處於後台時接收訊息。 您已經允許 Delta Chat 在後台接收訊息。\n\n如果消息仍未在後台到達,請同時檢查您的系統設定。 - + + 新版變化? +💯 端對端加密現在可靠且永久有效。鎖頭圖標🔒已經消失!\n\n✉️ 沒有端對端加密的傳統電子郵件會以信件符號✉️標示\n\n😻 為你的所有聯絡人提供全新增強的個人資料頁面\n\n🔲 新按鈕可快速存取聊天中使用的應用程式\n\n❤️ 請捐款以幫助我們保持獨立並持續提供改進:%1$s + From 71a473d3e3a70a6029158880a04171ab817b79b2 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 14 Mar 2026 15:12:33 +0100 Subject: [PATCH 12/27] update changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 784c5bbb3..86f36a808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,13 @@ * Explain at "Settings / Chats / Outgoing Media Quality" how to send original quality * Add a basic sticker picker * Leave groups and channels before deletion +* Further minimize metadata in messages and while getting in contact. +* Increase resilience of multi-relay usage: if on relay goes down, messages are still received in the others. * Fix: keep original sent timestamp for resent messages * Fix: make clicking on broadcast member-added messages work always * Fix: remove notification when a message is deleted by sender -* Update to core 2.44.0 +* Some more bug fixes and updated translations +* Update to core 2.45.0 ## v2.43.0 2026-02 From 738f5b2cc87b2b871dc8a7d86d4e0fdfca177105 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 14 Mar 2026 15:33:41 +0100 Subject: [PATCH 13/27] update RPC bindings --- src/main/java/chat/delta/rpc/Rpc.java | 9 +++++++++ src/main/java/chat/delta/rpc/types/Qr.java | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/src/main/java/chat/delta/rpc/Rpc.java b/src/main/java/chat/delta/rpc/Rpc.java index 72761b583..142631e00 100644 --- a/src/main/java/chat/delta/rpc/Rpc.java +++ b/src/main/java/chat/delta/rpc/Rpc.java @@ -509,6 +509,8 @@ public class Rpc { * if `checkQr()` returns `askVerifyContact` or `askVerifyGroup` * an out-of-band-verification can be joined using `secure_join()` *

      + * @deprecated as of 2026-03; use create_qr_svg(get_chat_securejoin_qr_code()) instead. + *

      * chat_id: If set to a group-chat-id, * the Verified-Group-Invite protocol is offered in the QR code; * works for protected groups as well as for normal groups. @@ -1211,12 +1213,19 @@ public class Rpc { * even if there is no concurrent call to [`CommandApi::provide_backup`], * but will fail after 60 seconds to avoid deadlocks. *

      + * @deprecated as of 2026-03; use `create_qr_svg(get_backup_qr())` instead. + *

      * Returns the QR code rendered as an SVG image. */ public String getBackupQrSvg(Integer accountId) throws RpcException { return transport.callForResult(new TypeReference(){}, "get_backup_qr_svg", mapper.valueToTree(accountId)); } + /** Renders the given text as a QR code SVG image. */ + public String createQrSvg(String text) throws RpcException { + return transport.callForResult(new TypeReference(){}, "create_qr_svg", mapper.valueToTree(text)); + } + /** * Gets a backup from a remote provider. *

      diff --git a/src/main/java/chat/delta/rpc/types/Qr.java b/src/main/java/chat/delta/rpc/types/Qr.java index 9ad484729..f790f0693 100644 --- a/src/main/java/chat/delta/rpc/types/Qr.java +++ b/src/main/java/chat/delta/rpc/types/Qr.java @@ -25,6 +25,8 @@ public abstract class Qr { public String fingerprint; /** Invite number. */ public String invitenumber; + /** Whether the inviter supports the new Securejoin v3 protocol */ + public Boolean is_v3; } /** Ask the user whether to join the group. */ @@ -41,6 +43,8 @@ public abstract class Qr { public String grpname; /** Invite number. */ public String invitenumber; + /** Whether the inviter supports the new Securejoin v3 protocol */ + public Boolean is_v3; } /** Ask the user whether to join the broadcast channel. */ @@ -55,6 +59,8 @@ public abstract class Qr { public String grpid; /** Invite number. */ public String invitenumber; + /** Whether the inviter supports the new Securejoin v3 protocol */ + public Boolean is_v3; /** The user-visible name of this broadcast channel */ public String name; } From 93ba86a77926bb2ee18e9b7fac6e3042e20a09c3 Mon Sep 17 00:00:00 2001 From: adbenitez Date: Sat, 14 Mar 2026 16:49:22 +0100 Subject: [PATCH 14/27] update src/main/res/values-ca/strings.xml --- src/main/res/values-ca/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/res/values-ca/strings.xml b/src/main/res/values-ca/strings.xml index 396e8728c..d1505c357 100644 --- a/src/main/res/values-ca/strings.xml +++ b/src/main/res/values-ca/strings.xml @@ -120,11 +120,11 @@ Vist per darrer cop: desconegut - %d durada d\\'un minut + %d durada d\'un minut %d minuts de durada - Menys d\\'1 minut + Menys d\'1 minut %d min From ed72a60f241c57ccc4862f2b6b72fbe99000849e Mon Sep 17 00:00:00 2001 From: wch423 Date: Sat, 14 Mar 2026 21:45:22 +0100 Subject: [PATCH 15/27] Combined changes for adding call managements, native WebRTC support, CallStyle notification and other implemenations --- build.gradle | 7 +- proguard-rules.pro | 7 +- src/main/AndroidManifest.xml | 30 +- .../securesms/ApplicationContext.java | 13 + .../securesms/ConversationActivity.java | 9 +- .../securesms/ConversationItem.java | 16 +- .../org/thoughtcrime/securesms/EglUtils.java | 21 + .../calls/AudioDevicePickerDialog.java | 129 ++ .../securesms/calls/CallActivity.java | 917 +++++++++-- .../securesms/calls/CallCoordinator.java | 1456 +++++++++++++++++ .../securesms/calls/CallService.java | 527 ++++++ .../securesms/calls/CallUtil.java | 137 +- .../securesms/calls/CallViewModel.java | 448 +++++ .../securesms/calls/MediaStreamManager.java | 153 ++ .../securesms/connect/DcEventCenter.java | 9 - .../notifications/DeclineCallReceiver.java | 41 - .../notifications/NotificationCenter.java | 113 +- .../securesms/webrtc/WebRTCClient.java | 1011 ++++++++++++ src/main/res/drawable/bottom_sheet_handle.xml | 6 + src/main/res/drawable/ic_bluetooth_audio.xml | 10 + src/main/res/drawable/ic_call.xml | 10 + src/main/res/drawable/ic_call_end.xml | 10 + src/main/res/drawable/ic_cast.xml | 10 + src/main/res/drawable/ic_check.xml | 10 + src/main/res/drawable/ic_headset.xml | 10 + src/main/res/drawable/ic_mic_off.xml | 10 + src/main/res/drawable/ic_mic_on.xml | 10 + src/main/res/drawable/ic_person.xml | 10 + src/main/res/drawable/ic_phone_in_talk.xml | 10 + src/main/res/drawable/ic_switch_camera.xml | 10 + src/main/res/drawable/ic_videocam_off.xml | 10 + src/main/res/drawable/ic_videocam_on.xml | 10 + src/main/res/drawable/ic_volume_up.xml | 11 + src/main/res/layout/activity_call.xml | 364 +++++ .../res/layout/dialog_audio_device_picker.xml | 22 + src/main/res/layout/item_audio_device.xml | 35 + src/main/res/values/colors.xml | 12 + src/main/res/values/themes.xml | 7 + 38 files changed, 5253 insertions(+), 378 deletions(-) create mode 100644 src/main/java/org/thoughtcrime/securesms/EglUtils.java create mode 100644 src/main/java/org/thoughtcrime/securesms/calls/AudioDevicePickerDialog.java create mode 100644 src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java create mode 100644 src/main/java/org/thoughtcrime/securesms/calls/CallService.java create mode 100644 src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java create mode 100644 src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java delete mode 100644 src/main/java/org/thoughtcrime/securesms/notifications/DeclineCallReceiver.java create mode 100644 src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java create mode 100644 src/main/res/drawable/bottom_sheet_handle.xml create mode 100644 src/main/res/drawable/ic_bluetooth_audio.xml create mode 100644 src/main/res/drawable/ic_call.xml create mode 100644 src/main/res/drawable/ic_call_end.xml create mode 100644 src/main/res/drawable/ic_cast.xml create mode 100644 src/main/res/drawable/ic_check.xml create mode 100644 src/main/res/drawable/ic_headset.xml create mode 100644 src/main/res/drawable/ic_mic_off.xml create mode 100644 src/main/res/drawable/ic_mic_on.xml create mode 100644 src/main/res/drawable/ic_person.xml create mode 100644 src/main/res/drawable/ic_phone_in_talk.xml create mode 100644 src/main/res/drawable/ic_switch_camera.xml create mode 100644 src/main/res/drawable/ic_videocam_off.xml create mode 100644 src/main/res/drawable/ic_videocam_on.xml create mode 100644 src/main/res/drawable/ic_volume_up.xml create mode 100644 src/main/res/layout/activity_call.xml create mode 100644 src/main/res/layout/dialog_audio_device_picker.xml create mode 100644 src/main/res/layout/item_audio_device.xml diff --git a/build.gradle b/build.gradle index 66116c4e1..6795ce6d4 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ android { applicationId "com.b44t.messenger" multiDexEnabled true - minSdkVersion 21 + minSdkVersion 23 targetSdkVersion 36 vectorDrawables.useSupportLibrary = true @@ -155,6 +155,11 @@ dependencies { def media3_version = "1.8.0" // 1.9.0 need minSdkVersion 23 implementation 'androidx.concurrent:concurrent-futures:1.3.0' + implementation 'androidx.core:core:1.17.0' + implementation 'androidx.core:core-telecom:1.0.1' + implementation 'im.conversations.webrtc:webrtc-android:129.0.0' + // For Kotlin->Java interpolation + implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.10.0' implementation 'androidx.sharetarget:sharetarget:1.2.0' implementation 'androidx.webkit:webkit:1.14.0' implementation 'androidx.multidex:multidex:2.0.1' diff --git a/proguard-rules.pro b/proguard-rules.pro index 1047f540b..ea5c22225 100644 --- a/proguard-rules.pro +++ b/proguard-rules.pro @@ -12,4 +12,9 @@ # unused SealedData constructor needed by JsonUtils -keep class org.thoughtcrime.securesms.crypto.KeyStoreHelper* { *; } --dontwarn com.google.firebase.analytics.connector.AnalyticsConnector \ No newline at end of file +-dontwarn com.google.firebase.analytics.connector.AnalyticsConnector + +# Keep WebRTC classes +-keep class org.webrtc.** { *; } +-keepclassmembers class org.webrtc.** { *; } +-keepattributes InnerClasses diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 1a5ac2aeb..242b85771 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -16,9 +16,12 @@ + + + @@ -40,6 +43,9 @@ + + + @@ -355,9 +361,13 @@ + android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboard|keyboardHidden" + android:launchMode="singleTask" + android:taskAffinity=".calls.PipCallTask" + android:theme="@style/Theme.App.NoActionBar.Fullscreen" + android:exported="false" + android:supportsPictureInPicture="true" + android:excludeFromRecents="true"> + + @@ -411,14 +427,6 @@ - - - - - - diff --git a/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 37ade8c06..d1712f614 100644 --- a/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java +++ b/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java @@ -27,6 +27,7 @@ import com.b44t.messenger.DcEventChannel; import com.b44t.messenger.DcEventEmitter; import com.b44t.messenger.FFITransport; +import org.thoughtcrime.securesms.calls.CallCoordinator; import org.thoughtcrime.securesms.connect.AccountManager; import org.thoughtcrime.securesms.connect.DcEventCenter; import org.thoughtcrime.securesms.connect.DcHelper; @@ -299,6 +300,12 @@ public class ApplicationContext extends MultiDexApplication { initializeJobManager(); InChatSounds.getInstance(this); + EglUtils.getEglBase(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallCoordinator.getInstance(this); + } + DynamicTheme.setDefaultDayNightMode(this); IntentFilter filter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); @@ -351,6 +358,12 @@ public class ApplicationContext extends MultiDexApplication { Log.i("DeltaChat", "+++++++++++ ApplicationContext.onCreate() finished ++++++++++"); } + @Override + public void onTerminate() { + super.onTerminate(); + EglUtils.release(); + } + public JobManager getJobManager() { return jobManager; } diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java index 7e72bd4b4..0bc2de67a 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationActivity.java @@ -34,6 +34,7 @@ import android.graphics.Color; import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.AsyncTask; +import android.os.Build; import android.os.Bundle; import android.os.Vibrator; import android.provider.Browser; @@ -612,10 +613,14 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity WebxdcActivity.openMaps(this, chatId); return true; } else if (itemId == R.id.menu_start_audio_call) { - CallUtil.startCall(this, chatId, false); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallUtil.startAudioCall(context, chatId); + } return true; } else if (itemId == R.id.menu_start_video_call) { - CallUtil.startCall(this, chatId, true); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallUtil.startVideoCall(context, chatId); + } return true; } else if (itemId == R.id.menu_all_media) { handleAllMedia(); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 59e7fc5e9..0f4dfe3e5 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -22,6 +22,7 @@ import android.content.res.TypedArray; import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.Rect; +import android.os.Build; import android.text.SpannableString; import android.text.TextUtils; import android.util.AttributeSet; @@ -41,6 +42,7 @@ import com.b44t.messenger.DcChat; import com.b44t.messenger.DcContact; import com.b44t.messenger.DcMsg; +import org.thoughtcrime.securesms.calls.CallCoordinator; import org.thoughtcrime.securesms.calls.CallUtil; import org.thoughtcrime.securesms.components.AvatarImageView; import org.thoughtcrime.securesms.components.BorderlessImageView; @@ -974,13 +976,21 @@ public class ConversationItem extends BaseConversationItem if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { performClick(); } else { - int accId = dcContext.getAccountId(); int chatId = messageRecord.getChatId(); if (!messageRecord.isOutgoing() && callInfo.state instanceof CallState.Alerting) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { int callId = messageRecord.getId(); - CallUtil.openCall(getContext(), accId, chatId, callId, callInfo.sdpOffer, callInfo.hasVideo); + CallCoordinator coordinator = CallCoordinator.getInstance(context); + coordinator.showIncomingCallScreen(callId); + } } else { - CallUtil.startCall(getContext(), accId, chatId, callInfo.hasVideo); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (callInfo.hasVideo) { + CallUtil.startVideoCall(getContext(), chatId); + } else { + CallUtil.startAudioCall(getContext(), chatId); + } + } } } } diff --git a/src/main/java/org/thoughtcrime/securesms/EglUtils.java b/src/main/java/org/thoughtcrime/securesms/EglUtils.java new file mode 100644 index 000000000..8e1b3d73b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/EglUtils.java @@ -0,0 +1,21 @@ +package org.thoughtcrime.securesms; + +import org.webrtc.EglBase; + +public class EglUtils { + private static EglBase eglBase; + + public static synchronized EglBase getEglBase() { + if (eglBase == null) { + eglBase = EglBase.create(); + } + return eglBase; + } + + public static synchronized void release() { + if (eglBase != null) { + eglBase.release(); + eglBase = null; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/calls/AudioDevicePickerDialog.java b/src/main/java/org/thoughtcrime/securesms/calls/AudioDevicePickerDialog.java new file mode 100644 index 000000000..7d9aff9d4 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/AudioDevicePickerDialog.java @@ -0,0 +1,129 @@ +package org.thoughtcrime.securesms.calls; + +import android.content.Context; +import android.os.Build; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.telecom.CallEndpointCompat; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.bottomsheet.BottomSheetDialog; + +import org.thoughtcrime.securesms.R; + +import java.util.List; + +/** + * Bottom sheet dialog for selecting audio output device + */ +@RequiresApi(Build.VERSION_CODES.O) +public class AudioDevicePickerDialog extends BottomSheetDialog { + private static final String TAG = AudioDevicePickerDialog.class.getSimpleName(); + + public interface OnDeviceSelectedListener { + void onDeviceSelected(CallEndpointCompat endpoint); + } + + public AudioDevicePickerDialog(@NonNull Context context, + @NonNull List endpoints, + CallEndpointCompat currentEndpoint, + @NonNull OnDeviceSelectedListener listener) { + super(context); + + setContentView(R.layout.dialog_audio_device_picker); + + RecyclerView recyclerView = findViewById(R.id.audio_device_list); + if (recyclerView == null) { + Log.e(TAG, "RecyclerView not found in layout"); + return; + } + + recyclerView.setLayoutManager(new LinearLayoutManager(context)); + + AudioDeviceAdapter adapter = new AudioDeviceAdapter( + endpoints, + currentEndpoint, + endpoint -> { + listener.onDeviceSelected(endpoint); + dismiss(); + } + ); + + recyclerView.setAdapter(adapter); + } + + /** + * RecyclerView adapter for audio devices + */ + private static class AudioDeviceAdapter extends RecyclerView.Adapter { + + private final List endpoints; + private final CallEndpointCompat currentEndpoint; + private final OnDeviceSelectedListener listener; + + AudioDeviceAdapter(List endpoints, + CallEndpointCompat currentEndpoint, + OnDeviceSelectedListener listener) { + this.endpoints = endpoints; + this.currentEndpoint = currentEndpoint; + this.listener = listener; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = LayoutInflater.from(parent.getContext()).inflate( + R.layout.item_audio_device, + parent, + false + ); + return new ViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + CallEndpointCompat endpoint = endpoints.get(position); + boolean isCurrent = currentEndpoint != null && + endpoint.getIdentifier().equals(currentEndpoint.getIdentifier()); + + holder.bind(endpoint, isCurrent, listener); + } + + @Override + public int getItemCount() { + return endpoints.size(); + } + + static class ViewHolder extends RecyclerView.ViewHolder { + private final ImageView iconView; + private final TextView nameView; + private final ImageView checkView; + + ViewHolder(@NonNull View itemView) { + super(itemView); + iconView = itemView.findViewById(R.id.device_icon); + nameView = itemView.findViewById(R.id.device_name); + checkView = itemView.findViewById(R.id.device_check); + } + + void bind(CallEndpointCompat endpoint, boolean isCurrent, OnDeviceSelectedListener listener) { + nameView.setText(endpoint.getName()); + + int iconRes = CallUtil.getIconResByCallEndpoint(endpoint); + iconView.setImageResource(iconRes); + + checkView.setVisibility(isCurrent ? View.VISIBLE : View.GONE); + + itemView.setOnClickListener(v -> listener.onDeviceSelected(endpoint)); + } + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java index b7da6e461..149a72306 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java @@ -1,207 +1,798 @@ package org.thoughtcrime.securesms.calls; import android.Manifest; -import android.annotation.SuppressLint; +import android.app.KeyguardManager; +import android.app.PictureInPictureParams; import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; -import android.text.TextUtils; -import android.util.Base64; +import android.os.Handler; +import android.os.Looper; import android.util.Log; -import android.view.Menu; -import android.webkit.JavascriptInterface; -import android.webkit.PermissionRequest; -import android.webkit.WebChromeClient; -import android.webkit.WebSettings; +import android.util.Rational; +import android.view.View; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; +import androidx.activity.OnBackPressedCallback; import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.appcompat.app.AppCompatActivity; +import androidx.cardview.widget.CardView; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.telecom.CallEndpointCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.ViewModelProvider; -import com.b44t.messenger.DcChat; -import com.b44t.messenger.DcContext; -import com.b44t.messenger.DcEvent; +import com.google.android.material.button.MaterialButton; +import com.google.android.material.button.MaterialButtonToggleGroup; +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.thoughtcrime.securesms.BuildConfig; +import org.thoughtcrime.securesms.EglUtils; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.WebViewActivity; -import org.thoughtcrime.securesms.connect.DcEventCenter; -import org.thoughtcrime.securesms.connect.DcHelper; -import org.thoughtcrime.securesms.permissions.Permissions; -import org.thoughtcrime.securesms.recipients.Recipient; -import org.thoughtcrime.securesms.util.AvatarUtil; -import org.thoughtcrime.securesms.util.Util; +import org.webrtc.RendererCommon; +import org.webrtc.SurfaceViewRenderer; +import org.webrtc.VideoTrack; -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; -import java.util.Objects; +import java.util.ArrayList; +import java.util.List; -import chat.delta.rpc.Rpc; -import chat.delta.rpc.RpcException; +/** + * Full-screen call activity for VoIP calls + */ +@RequiresApi(api = Build.VERSION_CODES.O) +public class CallActivity extends AppCompatActivity { -public class CallActivity extends WebViewActivity implements DcEventCenter.DcEventDelegate { private static final String TAG = CallActivity.class.getSimpleName(); + private static final int PERMISSION_REQUEST_CODE = 1001; - public static final String EXTRA_ACCOUNT_ID = "acc_id"; - public static final String EXTRA_CHAT_ID = "chat_id"; - public static final String EXTRA_CALL_ID = "call_id"; - public static final String EXTRA_HASH = "hash"; - public static final String EXTRA_HAS_VIDEO = "has_video"; + public static final String ACTION_ANSWER_CALL = BuildConfig.APPLICATION_ID + ".ANSWER_CALL"; + public static final String ACTION_DECLINE_CALL = BuildConfig.APPLICATION_ID + ".DECLINE_CALL"; + public static final String ACTION_HANGUP_CALL = BuildConfig.APPLICATION_ID + ".HANGUP_CALL"; - private DcContext dcContext; - private Rpc rpc; - private int accId; - private int chatId; - private int callId; - private boolean hasVideo; - private boolean ended = false; + // Views + + private CallViewModel viewModel; + + private SurfaceViewRenderer localVideoView; + private SurfaceViewRenderer remoteVideoView; + + // Status and info + private TextView statusText; + private TextView displayNameText; + private View incomingCallPrompt; + + private View callerIconContainer; + private ImageView callerIconView; + private ImageView remoteAvatarView; + + private MaterialButtonToggleGroup answerModeSelector; + + // Layouts and elements + private ConstraintLayout topBar; + private MaterialCardView bottomSheetContainer; + private CardView localVideoContainer; + private ImageButton endCallButton; + private MaterialButton answerButton; + private MaterialButton declineButton; + private ImageButton muteButton; + private ImageButton videoButton; + private ImageButton speakerButton; + private ImageButton switchCameraButton; + + private final MediatorLiveData videoConfigChanged = new MediatorLiveData<>(); - @SuppressLint("SetJavaScriptEnabled") @Override - protected void onCreate(Bundle state, boolean ready) { - super.onCreate(state, ready); + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); - Bundle bundle = getIntent().getExtras(); - assert bundle != null; - hasVideo = bundle.getBoolean(EXTRA_HAS_VIDEO, true); - String hash = bundle.getString(EXTRA_HASH, ""); - String query = hasVideo? "" : "?noOutgoingVideoInitially"; - accId = bundle.getInt(EXTRA_ACCOUNT_ID, -1); - chatId = bundle.getInt(EXTRA_CHAT_ID, 0); - callId = bundle.getInt(EXTRA_CALL_ID, 0); - rpc = DcHelper.getRpc(this); - dcContext = DcHelper.getAccounts(this).getAccount(accId); + // TODO: check if we need landscape layout + setContentView(R.layout.activity_call); - DcHelper.getNotificationCenter(this).removeCallNotification(accId, callId); + setupWindowFlags(); - WebSettings webSettings = webView.getSettings(); - webSettings.setJavaScriptEnabled(true); - webSettings.setMediaPlaybackRequiresUserGesture(false); - webView.addJavascriptInterface(new InternalJSApi(), "calls"); + initializeViews(); - webView.setWebChromeClient(new WebChromeClient() { - @Override - public void onPermissionRequest(PermissionRequest request) { - Util.runOnMain(() -> request.grant(request.getResources())); + setupInsets(); + + if (!hasRequiredPermissions()) { + requestRequiredPermissions(); + return; + } + + // PiP listener + addOnPictureInPictureModeChangedListener(pipModeInfo -> { + boolean isInPipMode = pipModeInfo.isInPictureInPictureMode(); + + if (isInPipMode) { + topBar.setVisibility(View.GONE); + bottomSheetContainer.setVisibility(View.GONE); + localVideoContainer.setVisibility(View.GONE); + incomingCallPrompt.setVisibility(View.GONE); + } else { + topBar.setVisibility(View.VISIBLE); + bottomSheetContainer.setVisibility(View.VISIBLE); + if (viewModel != null) { + CallViewModel.CallState state = viewModel.getCallState().getValue(); + if (state != null) { + updateUIForState(state); + } + } + layoutVideos(); } }); - DcEventCenter eventCenter = DcHelper.getEventCenter(getApplicationContext()); - eventCenter.addObserver(DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED, this); - eventCenter.addObserver(DcContext.DC_EVENT_CALL_ENDED, this); + initializeViewModel(); - Util.runOnAnyBackgroundThread(() -> { - final DcChat chat = dcContext.getChat(chatId); - Util.runOnMain(() -> Objects.requireNonNull(getSupportActionBar()).setTitle(chat.getName())); + handleIntents(getIntent()); + } + + private void handleIntents(Intent intent) { + if (intent == null || viewModel == null) { + return; + } + + CallCoordinator coordinator = CallCoordinator.getInstance(getApplication()); + + if (!coordinator.hasActiveCall()) { + Log.e(TAG, "No active call exists, cannot proceed"); + Toast.makeText(this, "No active call", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + String action = intent.getAction(); + Log.d(TAG, "handleIntents: action=" + action); + + // Handle notification actions + if (ACTION_ANSWER_CALL.equals(action)) { + Log.d(TAG, "Handling ANSWER_CALL action from notification"); + viewModel.handleNotificationAnswer(); + return; + } + + if (ACTION_DECLINE_CALL.equals(action)) { + Log.d(TAG, "Handling DECLINE_CALL action from notification"); + viewModel.handleNotificationDecline(); + finish(); + return; + } + + if (ACTION_HANGUP_CALL.equals(action)) { + Log.d(TAG, "Handling HANGUP_CALL action from notification"); + viewModel.handleNotificationHangup(); + finish(); + return; + } + + if (coordinator.hasOngoingCall()) { + Log.d(TAG, "Resuming existing call"); + } else if (!coordinator.isIncomingCall()) { + Log.d(TAG, "Starting outgoing call"); + viewModel.startOutgoingCallWhenReady(); + } + } + + private void setupInsets() { + View rootView = findViewById(R.id.call_root_view); + + ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, windowInsets) -> { + Insets systemBars = windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() + ); + + v.setPadding( + systemBars.left, + systemBars.top, + systemBars.right, + systemBars.bottom + ); + + return windowInsets; + }); + } + + private void setupWindowFlags() { + WindowCompat.enableEdgeToEdge(getWindow()); + + // Show when locked + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true); + setTurnScreenOn(true); + + KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + if (keyguardManager != null) { + keyguardManager.requestDismissKeyguard(this, null); + } + } else { + getWindow().addFlags( + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED | + WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD | + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ); + } + + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + // Control status bar icon colors + WindowInsetsControllerCompat controller = + WindowCompat.getInsetsController(getWindow(), getWindow().getDecorView()); + controller.setAppearanceLightStatusBars(false); + controller.setAppearanceLightNavigationBars(false); + } + + private void initializeViews() { + topBar = findViewById(R.id.top_bar); + bottomSheetContainer = findViewById(R.id.bottom_sheet_container); + localVideoContainer = findViewById(R.id.local_video_container); + + localVideoView = findViewById(R.id.local_video_view); + remoteVideoView = findViewById(R.id.remote_video_view); + + statusText = findViewById(R.id.status_text); + displayNameText = findViewById(R.id.display_name_text); + incomingCallPrompt = findViewById(R.id.incoming_call_prompt); + + callerIconContainer = findViewById(R.id.caller_icon_container); + callerIconView = findViewById(R.id.caller_icon); + remoteAvatarView = findViewById(R.id.remote_avatar_view); + + answerModeSelector = findViewById(R.id.answer_mode_selector); + + endCallButton = findViewById(R.id.end_call_button); + answerButton = findViewById(R.id.answer_button); + declineButton = findViewById(R.id.decline_button); + muteButton = findViewById(R.id.mute_button); + videoButton = findViewById(R.id.video_button); + speakerButton = findViewById(R.id.speaker_button); + switchCameraButton = findViewById(R.id.switch_camera_button); + + initializeVideoRenderers(); + + setupButtonListeners(); + } + + private void initializeVideoRenderers() { + // Local video (the small one) + localVideoView.init(EglUtils.getEglBase().getEglBaseContext(), null); + localVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FIT); + localVideoView.setZOrderMediaOverlay(true); + localVideoView.setEnableHardwareScaler(true); + + // Remote video (full screen one) + remoteVideoView.init(EglUtils.getEglBase().getEglBaseContext(), null); + remoteVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL); + remoteVideoView.setEnableHardwareScaler(true); + + Log.d(TAG, "Video renderers initialized"); + } + + private void setupButtonListeners() { + answerButton.setOnClickListener(v -> { + if (viewModel != null) { + viewModel.answerCall(); + } }); - Permissions.with(this) - .request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) - .ifNecessary() - .withPermanentDenialDialog(getString(R.string.perm_explain_access_to_camera_denied)) - .onAllGranted(() -> { - String url = "file:///android_asset/calls/index.html"; - webView.loadUrl(url + query + hash); - }).onAnyDenied(this::finish) - .execute(); + declineButton.setOnClickListener(v -> { + if (viewModel != null) { + viewModel.declineCall(); + } + finish(); + }); + + endCallButton.setOnClickListener(v -> { + if (viewModel != null) { + viewModel.hangUp(); + } + finish(); + }); + + muteButton.setOnClickListener(v -> { + if (viewModel != null) { + viewModel.toggleAudio(); + } + }); + + videoButton.setOnClickListener(v -> { + if (viewModel != null) { + viewModel.toggleVideo(); + } + }); + + speakerButton.setOnClickListener(v -> { + if (viewModel != null) { + showAudioDevicePicker(); + } + }); + + switchCameraButton.setOnClickListener(v -> { + if (viewModel != null) { + viewModel.switchCamera(); + } + }); + + getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + CallViewModel.CallState state = viewModel.getCallState().getValue(); + + if (isDeviceLocked() || state == null) { + finish(); + return; + } + + switch (state) { + case RINGING: + case CONNECTING: + case CONNECTED: + case RECONNECTING: + enterPictureInPictureMode(createPipParams()); + break; + + case INITIALIZING: + case PROMPTING_USER_ACCEPT: + case ENDED: + case ERROR: + default: + finish(); + break; + } + } + }); + } + + private void initializeViewModel() { + viewModel = new ViewModelProvider(this).get(CallViewModel.class); + + observeViewModel(); + + viewModel.initialize(); + + Log.d(TAG, "CallViewModel initialized"); + } + + private void observeViewModel() { + // Call state + viewModel.getCallState().observe(this, this::updateUIForState); + + // Display info + viewModel.getDisplayName().observe(this, name -> { + displayNameText.setText(name != null ? name : "Unknown"); + }); + + viewModel.getDisplayIcon().observe(this, icon -> { + if (callerIconView != null) { + if (icon != null) { + callerIconView.setImageIcon(icon); + } else { + callerIconView.setImageResource(R.drawable.ic_person); + } + } + + if (remoteAvatarView != null) { + if (icon != null) { + remoteAvatarView.setImageIcon(icon); + } else { + remoteAvatarView.setImageResource(R.drawable.ic_person); + } + } + }); + + // Audio/video state + viewModel.getAudioEnabled().observe(this, enabled -> { + muteButton.setSelected(!enabled); + muteButton.setImageResource(enabled ? + R.drawable.ic_mic_on : R.drawable.ic_mic_off); + }); + + viewModel.getVideoEnabled().observe(this, enabled -> { + videoButton.setSelected(!enabled); + videoButton.setImageResource(enabled ? + R.drawable.ic_videocam_on : R.drawable.ic_videocam_off); + }); + + viewModel.getCurrentAudioEndpoint().observe(this, this::updateSpeakerButton); + + viewModel.getAvailableAudioEndpoints().observe(this, endpoints -> { + // Need observe to trigger flow emit + Log.d(TAG, "Available endpoints updated, count: " + + (endpoints != null ? endpoints.size() : 0)); + }); + + // Errors + viewModel.getErrorMessage().observe(this, error -> { + if (error != null && !error.isEmpty()) { + Toast.makeText(this, error, Toast.LENGTH_LONG).show(); + } + }); + + // Relay usage + viewModel.getIsRelayUsed().observe(this, isRelayUsed -> { + if (isRelayUsed != null) { + Log.d(TAG, "TURN relay is " + (isRelayUsed ? "being used" : "NOT being used")); + } + }); + + // Set up LiveData sources + videoConfigChanged.addSource(viewModel.getCallState(), v -> videoConfigChanged.setValue(true)); + videoConfigChanged.addSource(viewModel.getLocalVideoTrack(), v -> videoConfigChanged.setValue(true)); + videoConfigChanged.addSource(viewModel.getRemoteVideoTrack(), v -> videoConfigChanged.setValue(true)); + videoConfigChanged.addSource(viewModel.getVideoEnabled(), v -> videoConfigChanged.setValue(true)); + videoConfigChanged.addSource(viewModel.getRemoteVideoEnabled(), v -> videoConfigChanged.setValue(true)); + + // Video layout + videoConfigChanged.observe(this, changed -> { + if (!isFinishing() && !isDestroyed()) { + layoutVideos(); + } + }); } @Override - public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - Permissions.onRequestPermissionsResult(this, requestCode, permissions, grantResults); + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleIntents(intent); + Log.d(TAG, "onNewIntent called - activity reused"); } + // TODO: resource strings + private void updateUIForState(CallViewModel.CallState state) { + Log.d(TAG, "Call state: " + state); + + if (isInPictureInPictureMode() && + state != CallViewModel.CallState.ENDED && + state != CallViewModel.CallState.ERROR) { + return; + } + + switch (state) { + case INITIALIZING: + statusText.setText("Initializing..."); + incomingCallPrompt.setVisibility(View.GONE); + bottomSheetContainer.setVisibility(View.GONE); + callerIconContainer.setVisibility(View.GONE); + answerModeSelector.setVisibility(View.GONE); + remoteAvatarView.setVisibility(View.GONE); + break; + + case PROMPTING_USER_ACCEPT: + statusText.setText("Incoming call"); + incomingCallPrompt.setVisibility(View.VISIBLE); + bottomSheetContainer.setVisibility(View.GONE); + callerIconContainer.setVisibility(View.VISIBLE); + answerModeSelector.setVisibility(View.VISIBLE); + remoteAvatarView.setVisibility(View.GONE); + initializeAnswerModeSelector(); + break; + + case RINGING: + statusText.setText("Ringing..."); + incomingCallPrompt.setVisibility(View.GONE); + bottomSheetContainer.setVisibility(View.VISIBLE); + callerIconContainer.setVisibility(View.GONE); + answerModeSelector.setVisibility(View.GONE); + remoteAvatarView.setVisibility(View.VISIBLE); + break; + + case CONNECTING: + statusText.setText("Connecting..."); + incomingCallPrompt.setVisibility(View.GONE); + bottomSheetContainer.setVisibility(View.VISIBLE); + callerIconContainer.setVisibility(View.GONE); + answerModeSelector.setVisibility(View.GONE); + remoteAvatarView.setVisibility(View.VISIBLE); + break; + + case CONNECTED: + statusText.setText("Connected"); + incomingCallPrompt.setVisibility(View.GONE); + bottomSheetContainer.setVisibility(View.VISIBLE); + callerIconContainer.setVisibility(View.GONE); + answerModeSelector.setVisibility(View.GONE); + remoteAvatarView.setVisibility(View.VISIBLE); + break; + + case RECONNECTING: + statusText.setText("Reconnecting..."); + remoteAvatarView.setVisibility(View.VISIBLE); + break; + + case ENDED: + statusText.setText("Call ended"); + finish(); + break; + + case ERROR: + statusText.setText("Call failed"); + incomingCallPrompt.setVisibility(View.GONE); + bottomSheetContainer.setVisibility(View.GONE); + callerIconContainer.setVisibility(View.GONE); + answerModeSelector.setVisibility(View.GONE); + remoteAvatarView.setVisibility(View.VISIBLE); + + new Handler(Looper.getMainLooper()).postDelayed(() -> { + if (!isFinishing()) { + finish(); + } + }, 2500); + break; + } + } + + private void updateSpeakerButton(CallEndpointCompat endpoint) { + if (endpoint == null) { + speakerButton.setImageResource(R.drawable.ic_volume_up); + return; + } + + int iconRes = CallUtil.getIconResByCallEndpoint(endpoint); + + speakerButton.setImageResource(iconRes); + Log.d(TAG, "Speaker button updated for endpoint: " + endpoint.getName()); + } + + private void showAudioDevicePicker() { + List endpoints = viewModel.getAvailableAudioEndpoints().getValue(); + CallEndpointCompat currentEndpoint = viewModel.getCurrentAudioEndpoint().getValue(); + + if (endpoints == null || endpoints.isEmpty()) { + Log.w(TAG, "No audio endpoints available"); + Toast.makeText(this, "No audio devices available", Toast.LENGTH_SHORT).show(); + return; + } + + // Create and show BottomSheetDialog + AudioDevicePickerDialog dialog = new AudioDevicePickerDialog( + this, + endpoints, + currentEndpoint, + selectedEndpoint -> { + viewModel.selectAudioDevice(selectedEndpoint); + } + ); + + dialog.show(); + } + + private void initializeAnswerModeSelector() { + CallCoordinator coordinator = CallCoordinator.getInstance(getApplication()); + + // Set initial selection without triggering listener + answerModeSelector.clearOnButtonCheckedListeners(); + answerModeSelector.check(coordinator.isStartsWithVideo() ? + R.id.answer_video_button : R.id.answer_audio_only_button); + + // Set listener + answerModeSelector.addOnButtonCheckedListener((group, checkedId, isChecked) -> { + if (isChecked && viewModel != null) { + boolean startsWithVideo = (checkedId == R.id.answer_video_button); + viewModel.setStartsWithVideo(startsWithVideo); + viewModel.switchToEarpiece(!startsWithVideo); + Log.d(TAG, "Answer mode changed to: " + (startsWithVideo ? "Video" : "Audio")); + } + }); + } + + private void layoutVideos() { + if (isFinishing() || isDestroyed()) return; + if (viewModel == null) return; + + CallViewModel.CallState state = viewModel.getCallState().getValue(); + if (state == CallViewModel.CallState.ENDED || + state == CallViewModel.CallState.ERROR) return; + + detachAllTracks(); + + Boolean videoEnabled = viewModel.getVideoEnabled().getValue(); + Boolean remoteVideoEnabled = viewModel.getRemoteVideoEnabled().getValue(); + CallCoordinator coordinator = CallCoordinator.getInstance(getApplication()); + VideoTrack localTrack = viewModel.getLocalVideoTrack().getValue(); + VideoTrack remoteTrack = viewModel.getRemoteVideoTrack().getValue(); + + boolean shouldShowRemoteVideoView = false; + + if (state == CallViewModel.CallState.CONNECTED) { + // Call established: local in corner, remote in center + + if (remoteTrack != null && Boolean.TRUE.equals(remoteVideoEnabled)) { + remoteTrack.addSink(remoteVideoView); + shouldShowRemoteVideoView = true; + } + + if (localTrack != null && Boolean.TRUE.equals(videoEnabled)) { + localTrack.addSink(localVideoView); + localVideoContainer.setVisibility(View.VISIBLE); + } else { + localVideoContainer.setVisibility(View.GONE); + } + + Log.d(TAG, "Video layout: Connected (local corner, remote full-screen)"); + + } else if (!coordinator.isIncomingCall() && + (state == CallViewModel.CallState.RINGING || + state == CallViewModel.CallState.CONNECTING)) { + if (localTrack != null && Boolean.TRUE.equals(videoEnabled)) { + // Outgoing call before connected: local preview in center, hide corner + localTrack.addSink(remoteVideoView); + shouldShowRemoteVideoView = true; + localVideoContainer.setVisibility(View.GONE); + Log.d(TAG, "Video layout: Outgoing preview (local full-screen)"); + } else { + localVideoContainer.setVisibility(View.GONE); + Log.d(TAG, "Video layout: Outgoing audio-only"); + } + + } else { + // All other cases: just show avatar + localVideoContainer.setVisibility(View.GONE); + Log.d(TAG, "Video layout: Showing avatar only"); + } + + remoteVideoView.setVisibility(shouldShowRemoteVideoView ? View.VISIBLE : View.GONE); + } + + private void detachAllTracks() { + VideoTrack localTrack = viewModel.getLocalVideoTrack().getValue(); + VideoTrack remoteTrack = viewModel.getRemoteVideoTrack().getValue(); + + // Clear all sinks first + if (localTrack != null) { + localTrack.removeSink(localVideoView); + localTrack.removeSink(remoteVideoView); + } + if (remoteTrack != null) { + remoteTrack.removeSink(remoteVideoView); + } + } + + // Permissions + + private boolean hasRequiredPermissions() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED && + ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + } + + private void requestRequiredPermissions() { + ActivityCompat.requestPermissions( + this, + new String[] { + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + }, + PERMISSION_REQUEST_CODE + ); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + + if (requestCode == PERMISSION_REQUEST_CODE) { + boolean microphoneGranted = false; + boolean cameraGranted = false; + + for (int i = 0; i < permissions.length; i++) { + if (permissions[i].equals(Manifest.permission.RECORD_AUDIO)) { + microphoneGranted = (grantResults[i] == PackageManager.PERMISSION_GRANTED); + } else if (permissions[i].equals(Manifest.permission.CAMERA)) { + cameraGranted = (grantResults[i] == PackageManager.PERMISSION_GRANTED); + } + } + + if (!microphoneGranted) { + Toast.makeText(this, + "Microphone permission is required for calls", + Toast.LENGTH_LONG).show(); + finish(); + return; + } + + CallCoordinator coordinator = CallCoordinator.getInstance(getApplication()); + + if (!cameraGranted && coordinator.isStartsWithVideo()) { + Log.w(TAG, "Camera permission denied, switching to audio-only"); + Toast.makeText(this, + "Starting audio-only call (camera permission denied)", + Toast.LENGTH_SHORT).show(); + coordinator.setStartsWithVideo(false); + } + + initializeViewModel(); + handleIntents(getIntent()); + } + } + + // Picture-in-Picture + + @Override + public void onUserLeaveHint() { + super.onUserLeaveHint(); + + // Enter PiP mode when user presses home button during active call + if (viewModel != null) { + CallViewModel.CallState state = viewModel.getCallState().getValue(); + + if (state != null) { + switch (state) { + case RINGING: + case CONNECTING: + case CONNECTED: + case RECONNECTING: + enterPictureInPictureMode(createPipParams()); + break; + + case INITIALIZING: + case PROMPTING_USER_ACCEPT: + case ENDED: + case ERROR: + default: + finish(); + break; + } + } + } else { + Log.w(TAG, "No View Model exists"); + } + } + + private PictureInPictureParams createPipParams() { + Rational aspectRatio = new Rational(9, 16); + PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder() + .setAspectRatio(aspectRatio) + .setActions(new ArrayList<>()); + + // FIXME: PiP currently shows media controls. + // Will fix later: to fix this, we may need changes to audio playback implementation. + + return builder.build(); + } + + // Helper + + private boolean isDeviceLocked() { + KeyguardManager keyguardManager = (KeyguardManager) getSystemService(Context.KEYGUARD_SERVICE); + if (keyguardManager != null) { + return keyguardManager.isKeyguardLocked(); + } + return false; + } + + // Cleanup + @Override protected void onDestroy() { - DcHelper.getEventCenter(this).removeObservers(this); - if (callId != 0 && !ended) { - try { - rpc.endCall(accId, callId); - } catch (RpcException e) { - Log.e(TAG, "Error", e); - } - } super.onDestroy(); - } - @Override - public boolean onPrepareOptionsMenu(Menu menu) { - // do not call super.onPrepareOptionsMenu() as the default "Search" menu is not needed - return true; - } + detachAllTracks(); - @Override - protected boolean openOnlineUrl(String url) { - finish(); - return true; - } - - @Override - public void handleEvent(@NonNull DcEvent event) { - switch (event.getId()) { - case DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED: - if (event.getData1Int() == callId) { - try { - String base64 = Base64.encodeToString(event.getData2Str().getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); - String hash = "#onAnswer=" + URLEncoder.encode(base64, "UTF-8"); - webView.evaluateJavascript("window.location.hash = `"+hash+"`", null); - } catch (UnsupportedEncodingException e) { - Log.e(TAG, "Error", e); - } - } - break; - case DcContext.DC_EVENT_CALL_ENDED: - if (event.getData1Int() == callId) { - ended = true; - finish(); - } - break; + // Release video renderers + if (localVideoView != null) { + localVideoView.release(); } - } - - - class InternalJSApi { - @JavascriptInterface - public String getIceServers() { - try { - return rpc.iceServers(accId); - } catch (RpcException e) { - Log.e(TAG, "Error", e); - return null; - } + if (remoteVideoView != null) { + remoteVideoView.release(); } - @JavascriptInterface - public void startCall(String payload) { - try { - callId = rpc.placeOutgoingCall(accId, chatId, payload, hasVideo); - } catch (RpcException e) { - Log.e(TAG, "Error", e); - } - } - - @JavascriptInterface - public void acceptCall(String payload) { - try { - rpc.acceptIncomingCall(accId, callId, payload); - } catch (RpcException e) { - Log.e(TAG, "Error", e); - } - } - - @JavascriptInterface - public void endCall() { - finish(); - } - - @JavascriptInterface - public String getAvatar() { - final Context context = CallActivity.this; - final DcChat dcChat = dcContext.getChat(chatId); - if (!TextUtils.isEmpty(dcChat.getProfileImage())) { - return AvatarUtil.asDataUri(dcChat.getProfileImage()); - } else { - final Recipient recipient = new Recipient(context, dcChat); - return AvatarUtil.asDataUri(recipient.getFallbackAvatarDrawable(context, false)); - } - } + Log.d(TAG, "CallActivity destroyed"); } } diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java new file mode 100644 index 000000000..723a70408 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java @@ -0,0 +1,1456 @@ +package org.thoughtcrime.securesms.calls; + +import static org.thoughtcrime.securesms.calls.CallUtil.getIconFromChat; +import static org.thoughtcrime.securesms.calls.CallUtil.getNameFromChat; + +import android.Manifest; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Person; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.content.pm.PackageManager; +import android.graphics.drawable.Icon; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.telecom.DisconnectCause; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; +import androidx.core.telecom.CallAttributesCompat; +import androidx.core.telecom.CallControlResult; +import androidx.core.telecom.CallControlScope; +import androidx.core.telecom.CallEndpointCompat; +import androidx.core.telecom.CallException; +import androidx.core.telecom.CallsManager; +import androidx.lifecycle.FlowLiveDataConversions; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.MutableLiveData; + +import com.b44t.messenger.DcChat; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcEvent; + +import org.thoughtcrime.securesms.ApplicationContext; +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.connect.DcEventCenter; +import org.thoughtcrime.securesms.connect.DcHelper; +import org.webrtc.PeerConnection; +import org.webrtc.VideoTrack; + +import java.util.List; + +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; +import kotlin.Unit; +import kotlin.coroutines.Continuation; +import kotlin.coroutines.CoroutineContext; +import kotlin.coroutines.EmptyCoroutineContext; +import kotlinx.coroutines.CoroutineScope; +import kotlinx.coroutines.CoroutineScopeKt; +import kotlinx.coroutines.Dispatchers; + +@RequiresApi(api = Build.VERSION_CODES.O) +public class CallCoordinator implements DcEventCenter.DcEventDelegate { + private static final String TAG = CallCoordinator.class.getSimpleName(); + + // Notification channels + private static final String CHANNEL_ID_INCOMING = "voip_incoming_calls"; + private static final String CHANNEL_ID_ONGOING = "voip_ongoing_calls"; + private static final int NOTIFICATION_ID_CALL = 1001; + + private static final String CALL_IDENTIFIER_SCHEME = "deltachat:"; + + private static CallCoordinator instance; + private final Context appContext; + private final CallsManager callsManager; + private final NotificationManagerCompat notificationManager; + private final Rpc rpc; + private CoroutineScope audioFlowScope; + + // LiveData for Observable State + private final MutableLiveData connectionState = + new MutableLiveData<>(PeerConnection.PeerConnectionState.NEW); + private final MutableLiveData localVideoTrack = new MutableLiveData<>(); + private final MutableLiveData remoteVideoTrack = new MutableLiveData<>(); + private final MutableLiveData localAudioEnabled = new MutableLiveData<>(true); + private final MutableLiveData localVideoEnabled = new MutableLiveData<>(true); + private final MutableLiveData remoteAudioEnabled = new MutableLiveData<>(true); + private final MutableLiveData remoteVideoEnabled = new MutableLiveData<>(true); + private final MutableLiveData isRelayUsed = new MutableLiveData<>(false); + private final MutableLiveData errorMessage = new MutableLiveData<>(); + private final MutableLiveData displayName = new MutableLiveData<>(); + private final MutableLiveData displayIcon = new MutableLiveData<>(); + + // Audio Routing Support + private final MediatorLiveData currentAudioEndpoint = new MediatorLiveData<>(); + private final MediatorLiveData> availableAudioEndpoints = new MediatorLiveData<>(); + private LiveData currentAudioEndpointSource; + private LiveData> availableAudioEndpointsSource; + + private CallService callService; + private ServiceConnection serviceConnection; + private boolean isServiceBound = false; + + // Call metadata, single source of truth + private Integer activeAccId; + private Integer activeCallId; + private Integer activeChatId; + private boolean isIncomingCall; + private boolean startsWithVideo; + private String pendingOfferSdp; + private boolean hasNotifiedBackend = false; + private boolean hasAutoSelectedEarpiece = false; + + private CallControlScope activeCallControlScope; + private CallViewModel activeCallViewModel; + + private CallCoordinator(Context context) { + this.appContext = context.getApplicationContext(); + this.rpc = DcHelper.getRpc(this.appContext); + this.callsManager = new CallsManager(this.appContext); + this.notificationManager = NotificationManagerCompat.from(this.appContext); + + addEventListeners(); + createNotificationChannels(); + registerTelecom(); + createServiceConnection(); + } + + public static synchronized CallCoordinator getInstance(Context context) { + if (instance == null) { + instance = new CallCoordinator(context); + } + return instance; + } + + private void createNotificationChannels() { + NotificationChannel incomingChannel = new NotificationChannel( + CHANNEL_ID_INCOMING, + "Incoming Calls", + NotificationManager.IMPORTANCE_HIGH + ); + incomingChannel.setDescription("Notifications for incoming DeltaChat calls"); + incomingChannel.setSound(null, null); + + NotificationChannel ongoingChannel = new NotificationChannel( + CHANNEL_ID_ONGOING, + "Active Calls", + NotificationManager.IMPORTANCE_DEFAULT + ); + ongoingChannel.setDescription("Notifications for active DeltaChat calls"); + ongoingChannel.setSound(null, null); + + notificationManager.createNotificationChannel(incomingChannel); + notificationManager.createNotificationChannel(ongoingChannel); + } + + private void registerTelecom() { + try { + int capabilities = CallsManager.CAPABILITY_BASELINE + | CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING; + callsManager.registerAppWithTelecom(capabilities); + Log.d(TAG, "Successfully registered through Telecom"); + } catch (Exception e) { + Log.e(TAG, "Failed to register with Telecom", e); + } + } + + private void addEventListeners() { + DcEventCenter eventCenter = DcHelper.getEventCenter(this.appContext); + eventCenter.removeObservers(this); + + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_INCOMING_CALL, this); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_INCOMING_CALL_ACCEPTED, this); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED, this); + eventCenter.addMultiAccountObserver(DcContext.DC_EVENT_CALL_ENDED, this); + } + + private void createServiceConnection() { + serviceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + CallService.LocalBinder binder = (CallService.LocalBinder) service; + callService = binder.getService(); + Log.d(TAG, "Bound to CallService"); + + if (!isIncomingCall) { + + // For outgoing call, show notification immediately + String calleeName = displayName.getValue(); + if (calleeName == null) { + calleeName = "Unknown"; + } + + showOrUpdateOngoingNotification("Calling " + calleeName + "..."); + } + + // Initialize call + callService.initializeCall(); + + if (isIncomingCall) { + callService.startIncomingRingtone(); + } else { + callService.startOutgoingRingtone(); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + callService = null; + isServiceBound = false; + Log.d(TAG, "Unbound from CallService"); + } + }; + } + + private void startAndBindService() { + Intent serviceIntent = new Intent(this.appContext, CallService.class); + this.appContext.startService(serviceIntent); + + // Set isServiceBound based on bindService return value + isServiceBound = this.appContext.bindService( + serviceIntent, + serviceConnection, + Context.BIND_AUTO_CREATE + ); + + Log.d(TAG, "Bind service result: " + isServiceBound); + } + + private void stopAndUnbindService() { + if (isServiceBound) { + try { + this.appContext.unbindService(serviceConnection); + Log.d(TAG, "Service unbound successfully"); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Service was not registered or already unbound", e); + } finally { + isServiceBound = false; + callService = null; + } + } + + Intent serviceIntent = new Intent(this.appContext, CallService.class); + this.appContext.stopService(serviceIntent); + } + + // LiveData Getters + + public LiveData getConnectionState() { + return connectionState; + } + + public LiveData getLocalVideoTrack() { + return localVideoTrack; + } + + public LiveData getRemoteVideoTrack() { + return remoteVideoTrack; + } + + public LiveData getLocalAudioEnabled() { + return localAudioEnabled; + } + + public LiveData getLocalVideoEnabled() { + return localVideoEnabled; + } + + public LiveData getRemoteAudioEnabled() { + return remoteAudioEnabled; + } + + public LiveData getRemoteVideoEnabled() { + return remoteVideoEnabled; + } + + public LiveData getIsRelayUsed() { + return isRelayUsed; + } + + public LiveData getErrorMessage() { + return errorMessage; + } + + public LiveData getDisplayName() { + return displayName; + } + + public LiveData getDisplayIcon() { + return displayIcon; + } + + public LiveData getCurrentAudioEndpoint() { + return currentAudioEndpoint; + } + + public LiveData> getAvailableAudioEndpoints() { + return availableAudioEndpoints; + } + + // State Update Methods (CallService) + + public void updateConnectionState(PeerConnection.PeerConnectionState state) { + Log.d(TAG, "updateConnectionState: " + state); + connectionState.postValue(state); + + // Handle connection failures + if (state == PeerConnection.PeerConnectionState.FAILED || + state == PeerConnection.PeerConnectionState.CLOSED) { + handleConnectionEnded(state); + } + } + + public void updateLocalVideoTrack(VideoTrack track) { + Log.d(TAG, "updateLocalVideoTrack: " + (track != null)); + localVideoTrack.postValue(track); + } + + public void updateRemoteVideoTrack(VideoTrack track) { + Log.d(TAG, "updateRemoteVideoTrack: " + (track != null)); + remoteVideoTrack.postValue(track); + } + + public void updateRemoteMutedState(boolean audioEnabled, boolean videoEnabled) { + Log.d(TAG, "updateRemoteMutedState: audio=" + audioEnabled + ", video=" + videoEnabled); + remoteAudioEnabled.postValue(audioEnabled); + remoteVideoEnabled.postValue(videoEnabled); + } + + public void updateRelayUsage(Boolean isRelay) { + Log.d(TAG, "updateRelayUsage: " + isRelay); + isRelayUsed.postValue(isRelay); + } + + public void reportError(String error) { + Log.e(TAG, "reportError: " + error); + errorMessage.postValue(error); + } + + // Delayed Media Initialization Support + + public void startMediaCapture() { + Log.d(TAG, "startMediaCapture"); + if (callService != null) { + callService.startMediaCapture(); + } else { + Log.w(TAG, "Cannot start media capture, service not ready"); + } + } + + public void handleCallControlScopeAnswer() { + Log.d(TAG, "handleCallControlScopeAnswer"); + + if (!isIncomingCall) { + Log.w(TAG, "Not an incoming call"); + return; + } + + if (callService != null) { + callService.stopRingtone(); + } + + // Notify Android system with CallControlScope + CallControlScope scope = activeCallControlScope; + if (scope != null) { + scope.answer( + CallAttributesCompat.CALL_TYPE_VIDEO_CALL, + new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object result) { + if (result instanceof CallControlResult) { + Log.d(TAG, "Answer succeeded with CallControlScope"); + } else if (result instanceof kotlin.Result.Failure) { + Log.e(TAG, "Answer failed", + ((kotlin.Result.Failure) result).exception); + reportError("Failed to answer call"); + } + } + } + ); + } + + notificationManager.cancel(NOTIFICATION_ID_CALL); + } + + public void answerWebRTC() { + Log.d(TAG, "answerWebRTC"); + + if (!isIncomingCall) { + Log.w(TAG, "Not an incoming call"); + return; + } + + if (pendingOfferSdp == null) { + Log.e(TAG, "No pending offer SDP"); + reportError("Call data missing"); + return; + } + + // Tell service to process offer and create answer + if (callService != null) { + callService.answerIncomingCall(); + } else { + Log.e(TAG, "Cannot answer, service not ready"); + reportError("Service not ready"); + } + } + + public String getPendingOfferSdp() { + return pendingOfferSdp; + } + + public void clearPendingOfferSdp() { + pendingOfferSdp = null; + } + + // RPC Signaling (CallService) + + public void handleOfferReady(String offerSdp) { + Log.d(TAG, "Offer SDP ready, sending via RPC"); + + new Thread(() -> { + try { + // RPC returns the final callId + int callId = rpc.placeOutgoingCall(activeAccId, activeChatId, offerSdp, startsWithVideo); + + Log.d(TAG, "Outgoing call initiated, final callId: " + callId); + + // Update our stored callId + this.activeCallId = callId; + + completeOutgoingCall(activeAccId, callId, activeChatId); + + } catch (RpcException e) { + Log.e(TAG, "Failed to send offer with RPC", e); + reportError("Failed to initiate call: " + e.getMessage()); + } + }).start(); + } + + public void handleAnswerReady(String answerSdp) { + Log.d(TAG, "handleAnswerReady, sending via RPC"); + + new Thread(() -> { + try { + rpc.acceptIncomingCall(activeAccId, activeCallId, answerSdp); + Log.d(TAG, "Answer sent successfully"); + + } catch (RpcException e) { + Log.e(TAG, "Failed to send answer with RPC", e); + reportError("Failed to answer call: " + e.getMessage()); + } + }).start(); + } + + // Call Control Methods (CallViewModel) + + public void showIncomingCallScreen(int callId) { + if (!isIncomingCall) { + Log.d(TAG, "Not an incoming call"); + return; + } + + if (activeCallId == null || !activeCallId.equals(callId)) { + Log.d(TAG, "Call ID mismatch (active: " + + activeCallId + ", requested: " + callId + ")"); + return; + } + + if (hasOngoingCall()) { + Log.d(TAG, "Call already ongoing"); + return; + } + + // Check microphone and camera permissions + if (!hasMicrophonePermission()) { + Log.e(TAG, "Microphone permission not granted"); + Intent intent = new Intent(appContext, CallActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + appContext.startActivity(intent); + notificationManager.cancel(NOTIFICATION_ID_CALL); + return; + } + + if (startsWithVideo && !hasCameraPermission()) { + Log.w(TAG, "Camera permission not granted"); + startsWithVideo = false; + } + + // Launch CallActivity with answer action + Intent intent = new Intent(appContext, CallActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + appContext.startActivity(intent); + + // CallStyle is always on top, so it will overlap + // Downside is once notification is dismissed and user backs out, + // the only way to get back to the call is to click on the message again. + notificationManager.cancel(NOTIFICATION_ID_CALL); + + Log.d(TAG, "Answering call: " + callId); + } + + public void declineCall() { + Log.d(TAG, "declineCall called"); + + if (activeCallId == null) { + Log.w(TAG, "Call already ended or no active call"); + return; + } + + if (callService != null) { + callService.stopRingtone(); + } + + notifyBackendCallEnded(); + + // Disconnect with CallControlScope + CallControlScope scope = activeCallControlScope; + if (scope != null) { + scope.disconnect( + new DisconnectCause(DisconnectCause.REJECTED), + new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object result) { + if (result instanceof CallControlResult) { + Log.d(TAG, "Decline succeeded with CallControlScope"); + } else if (result instanceof kotlin.Result.Failure) { + Log.e(TAG, "Decline failed", + ((kotlin.Result.Failure) result).exception); + } + } + } + ); + } + + // End call on service + if (callService != null) { + callService.endCall(); + } + + // Cleanup + cleanupCall(activeAccId, activeCallId); + } + + public void hangUp() { + Log.d(TAG, "hangUp called"); + + if (activeCallId == null) { + Log.w(TAG, "Call already ended or no active call"); + return; + } + + notifyBackendCallEnded(); + + // Disconnect with CallControlScope + CallControlScope scope = activeCallControlScope; + if (scope != null) { + scope.disconnect( + new DisconnectCause(DisconnectCause.LOCAL), + new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object result) { + if (result instanceof CallControlResult) { + Log.d(TAG, "Hang up succeeded with CallControlScope"); + } else if (result instanceof kotlin.Result.Failure) { + Log.e(TAG, "Hang up failed", + ((kotlin.Result.Failure) result).exception); + } + } + } + ); + } + + // End call on service + if (callService != null) { + callService.endCall(); + } + + // Cleanup + cleanupCall(activeAccId, activeCallId); + } + + public void setAudioEnabled(boolean enabled) { + Log.d(TAG, "setAudioEnabled: " + enabled); + + localAudioEnabled.postValue(enabled); + + if (callService != null) { + callService.setAudioEnabled(enabled); + + callService.sendMutedState( + enabled, + Boolean.TRUE.equals(localVideoEnabled.getValue()) + ); + } + } + + public void setVideoEnabled(boolean enabled) { + Log.d(TAG, "setVideoEnabled: " + enabled); + + localVideoEnabled.postValue(enabled); + + if (callService != null) { + callService.setVideoEnabled(enabled); + + callService.sendMutedState( + Boolean.TRUE.equals(localAudioEnabled.getValue()), + enabled + ); + } + } + + public void switchCamera() { + Log.d(TAG, "switchCamera"); + if (callService != null) { + callService.switchCamera(); + } + } + + public void startOutgoingCall() { + Log.d(TAG, "startOutgoingCall"); + if (callService != null) { + callService.startOutgoingCall(); + } + } + + // Setup Audio Endpoint Flow Collection + + private void setupAudioEndpointCollection(CallControlScope scope) { + Log.d(TAG, "Setting up audio endpoint flow collection"); + + // Create CoroutineScope for Flow collection + audioFlowScope = CoroutineScopeKt.CoroutineScope( + Dispatchers.getMain() + ); + + availableAudioEndpointsSource = FlowLiveDataConversions.asLiveData( + scope.getAvailableEndpoints(), + audioFlowScope.getCoroutineContext(), + 5000L + ); + + availableAudioEndpoints.addSource(availableAudioEndpointsSource, value -> { + Log.d(TAG, "Available audio endpoints changed, count: " + + (value != null ? value.size() : 0)); + availableAudioEndpoints.setValue(value); + + if (!hasAutoSelectedEarpiece && !startsWithVideo) { + hasAutoSelectedEarpiece = true; + + if (value != null && !value.isEmpty()) { + // Find earpiece endpoint + CallEndpointCompat earpieceEndpoint = null; + for (CallEndpointCompat endpoint : value) { + if (endpoint.getType() == CallEndpointCompat.TYPE_EARPIECE) { + earpieceEndpoint = endpoint; + break; + } + } + + if (earpieceEndpoint != null) { + Log.d(TAG, "Auto-selecting earpiece for audio-only outgoing call"); + requestAudioEndpointChange(earpieceEndpoint); + } else { + Log.d(TAG, "No earpiece endpoint available on this device"); + } + + // Only start collecting current audio endpoint after we made the selection + // The delay is to avoid a Telecom problem where at the beginning it will switch endpoints rapidly + new Handler(Looper.getMainLooper()).postDelayed(() -> { + startCurrentEndpointCollection(scope); + }, 500); + } + } else { + new Handler(Looper.getMainLooper()).postDelayed(() -> { + startCurrentEndpointCollection(scope); + }, 500); + } + }); + } + + private void startCurrentEndpointCollection(CallControlScope scope) { + if (currentAudioEndpointSource != null) { + return; + } + + if (audioFlowScope == null) { + // Likely in cleaning + return; + } + + Log.d(TAG, "Starting current endpoint flow collection"); + + // Only add source if not already started + currentAudioEndpointSource = FlowLiveDataConversions.asLiveData( + scope.getCurrentCallEndpoint(), + audioFlowScope.getCoroutineContext(), + 5000L + ); + + currentAudioEndpoint.addSource(currentAudioEndpointSource, value1 -> { + Log.d(TAG, "Current audio endpoint changed: " + + (value1 != null ? value1.getName() : "null")); + currentAudioEndpoint.setValue(value1); + }); + } + + // Request Audio Endpoint Change + public void requestAudioEndpointChange(CallEndpointCompat endpoint) { + Log.d(TAG, "Requesting audio endpoint change to: " + endpoint.getName()); + + if (activeCallControlScope == null) { + Log.w(TAG, "No active call scope, cannot change endpoint"); + return; + } + + activeCallControlScope.requestEndpointChange( + endpoint, + new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object result) { + if (result instanceof CallControlResult.Success) { + Log.d(TAG, "Audio endpoint change succeeded"); + } else if (result instanceof CallControlResult.Error) { + int errorCode = ((CallControlResult.Error) result).getErrorCode(); + Log.e(TAG, "Audio endpoint change failed with error code: " + errorCode); + reportError("Failed to change audio device"); + } + } + } + ); + } + + // Helper (CallService) + + public String fetchIceServers() throws RpcException { + if (activeAccId == null) { + throw new RpcException("No active account"); + } + return rpc.iceServers(activeAccId); + } + + + @Override + public void handleEvent(@NonNull DcEvent event) { + int eventId = event.getId(); + int accId = event.getAccountId(); + int callId = event.getData1Int(); // This is not semantically correct but shall work fine + + // It seems this observer can fire on either main or background thread + // Always move to background + new Thread(() -> { + boolean hasVideo; + + switch (eventId) { + case DcContext.DC_EVENT_INCOMING_CALL: + try { + hasVideo = this.rpc.callInfo(accId, callId).hasVideo; + } catch (RpcException e) { + Log.e(TAG, "Rpc.callInfo() failed", e); + hasVideo = false; + } + onIncomingCall(accId, callId, event.getData2Str(), hasVideo); + break; + case DcContext.DC_EVENT_INCOMING_CALL_ACCEPTED: + onIncomingCallAccepted(callId); + break; + case DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED: + String answerSDP = event.getData2Str(); + onOutgoingCallAccepted(callId, answerSDP); + break; + case DcContext.DC_EVENT_CALL_ENDED: + // This event is problematic because it can trigger in both directions, + // in addition to multiple other scenarios which cannot easily be distinguished + // May cause problems in edge cases + onCallEnded(accId, callId); + break; + } + }).start(); + } + + private void onIncomingCall(int accId, int callId, String offerSdp, boolean startsWithVideo) { + Log.d(TAG, "onIncomingCall: accId=" + accId + ", callId=" + callId); + + if (activeCallId != null) { + Log.w(TAG, "Already have an active call, ignoring incoming call"); + return; + } + + resetLiveDataForNewCall(); + + this.activeAccId = accId; + this.activeCallId = callId; + this.isIncomingCall = true; + this.startsWithVideo = startsWithVideo; + this.pendingOfferSdp = offerSdp; + + // Get caller info + DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId); + int chatId = dcContext.getMsg(callId).getChatId(); + this.activeChatId = chatId; + DcChat dcChat = dcContext.getChat(chatId); + String callerName = getNameFromChat(dcChat); + Icon callerIcon = getIconFromChat(this.appContext, dcChat); + + displayName.postValue(callerName); + displayIcon.postValue(callerIcon); + + // Add to CallsManager + CallAttributesCompat callAttributes = createCallAttributes( + callerName, callId, true); + addCallToTelecom(callAttributes, callerName, callerIcon); + + // Show CallStyle notification + showIncomingCallNotification(callerName, callerIcon); + + startAndBindService(); + } + + private void onIncomingCallAccepted(int callId) { + Log.d(TAG, "onIncomingCallAccepted: callId=" + callId); + + if (activeCallId == null || !activeCallId.equals(callId)) { + Log.w(TAG, "Accepted call ID doesn't match active call"); + return; + } + + String callerName = displayName.getValue(); + if (callerName == null) { + callerName = "Unknown"; + } + + showOrUpdateOngoingNotification("Call with " + callerName); + } + + private void onOutgoingCallAccepted(int callId, String answerSdp) { + Log.d(TAG, "onOutgoingCallAccepted: callId=" + callId + ", got answer SDP"); + + if (activeCallId == null || !activeCallId.equals(callId)) { + Log.w(TAG, "Answered call ID doesn't match active call"); + return; + } + + if (callService != null) { + callService.stopRingtone(); + callService.handleAnswerSdp(answerSdp); + } + + // Call control scope should transition to ACTIVE + if (activeCallControlScope != null) { + activeCallControlScope.setActive(new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object result) { + if (result instanceof CallControlResult) { + Log.d(TAG, "Outgoing call set to active: " + result); + } else if (result instanceof kotlin.Result.Failure) { + Log.e(TAG, "Failed to set active", ((kotlin.Result.Failure) result).exception); + } + } + }); + } + + String calleeName = displayName.getValue(); + if (calleeName == null) { + calleeName = "Unknown"; + } + showOrUpdateOngoingNotification("Call with " + calleeName); + } + + private void onCallEnded(int accId, int callId) { + Log.d(TAG, "onCallEnded: accId=" + accId + ", callId=" + callId); + + if (callService != null) { + callService.stopRingtone(); + } + + // Disconnect from CallControlScope + if (activeCallControlScope != null) { + activeCallControlScope.disconnect( + // We actually don't know if this is incoming or outgoing + // But we have to provide one of LOCAL, REMOTE, MISSED, REJECTED + new DisconnectCause(DisconnectCause.REMOTE), + new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object result) { + Log.d(TAG, "Disconnect completed"); + } + } + ); + } + + if (callService != null) { + callService.endCall(); + } + + // Clear active states + cleanupCall(accId, callId); + } + + private void handleConnectionEnded(PeerConnection.PeerConnectionState state) { + Log.d(TAG, "handleConnectionEnded: " + state); + + if (activeCallId == null) { + Log.w(TAG, "Call already ended or no active call"); + return; + } + + if (callService != null) { + callService.stopRingtone(); + } + + notifyBackendCallEnded(); + + // Cleanup + if (activeAccId != null && activeCallId != null) { + cleanupCall(activeAccId, activeCallId); + } + } + + /** + * Cleanup call state, used for clean up not initialized from backend events + */ + public void cleanupCall(int accId, int callId) { + Log.d(TAG, "cleanupCall: accId=" + accId + ", callId=" + callId); + + if (activeCallId != null && !activeCallId.equals(callId) || + activeAccId != null && !activeAccId.equals(accId)) { + Log.w(TAG, "Cleanup accountId or callId doesn't match active call"); + // Clean up anyway. Otherwise, no new calls can happen. + } + + // Clear state + this.activeAccId = null; + this.activeCallId = null; + this.activeChatId = null; + this.activeCallControlScope = null; + this.activeCallViewModel = null; + this.isIncomingCall = false; + this.startsWithVideo = false; + this.pendingOfferSdp = null; + this.hasNotifiedBackend = false; + this.hasAutoSelectedEarpiece = false; + + new Handler(Looper.getMainLooper()).post(() -> { + if (currentAudioEndpointSource != null) { + currentAudioEndpoint.removeSource(currentAudioEndpointSource); + currentAudioEndpointSource = null; + Log.d(TAG, "Removed current audio endpoint source"); + } + + if (availableAudioEndpointsSource != null) { + availableAudioEndpoints.removeSource(availableAudioEndpointsSource); + availableAudioEndpointsSource = null; + Log.d(TAG, "Removed available audio endpoints source"); + } + }); + + // Clear LiveData + connectionState.postValue(PeerConnection.PeerConnectionState.CLOSED); + clearLiveData(); + + if (audioFlowScope != null) { + CoroutineScopeKt.cancel(audioFlowScope, null); + audioFlowScope = null; + Log.d(TAG, "Cancelled audio flow scope"); + } + + // Clear notifications + notificationManager.cancel(NOTIFICATION_ID_CALL); + + // Stop service + stopAndUnbindService(); + + Log.d(TAG, "Call cleanup complete"); + } + + private void notifyBackendCallEnded() { + if (hasNotifiedBackend) { + Log.d(TAG, "Backend already notified of call end"); + return; + } + + if (activeCallId == null || activeCallId < 0) { + Log.w(TAG, "Cannot notify backend, invalid callId"); + return; + } + + hasNotifiedBackend = true; + + new Thread(() -> { + try { + rpc.endCall(activeAccId, activeCallId); + Log.d(TAG, "Backend notified: call ended"); + } catch (RpcException e) { + Log.e(TAG, "Failed to notify backend of call end", e); + } + }).start(); + } + + private void resetLiveDataForNewCall() { + connectionState.postValue(PeerConnection.PeerConnectionState.NEW); + clearLiveData(); + } + + private void clearLiveData() { + localVideoTrack.postValue(null); + remoteVideoTrack.postValue(null); + localAudioEnabled.postValue(true); + localVideoEnabled.postValue(true); + remoteAudioEnabled.postValue(true); + remoteVideoEnabled.postValue(true); + isRelayUsed.postValue(false); + errorMessage.postValue(null); + currentAudioEndpoint.postValue(null); + availableAudioEndpoints.postValue(null); + } + + public void initiateOutgoingCall(int accId, int chatId, boolean startsWithVideo) { + Log.d(TAG, "Initiating outgoing call:accId=" + accId + ", chatId=" + chatId); + + if (activeCallId != null) { + Log.w(TAG, "Already have an active call, cannot start new one"); + return; + } + + // Check camera and microphone permissions + if (!hasMicrophonePermission()) { + Log.e(TAG, "Microphone permission not granted"); + + Intent intent = new Intent(appContext, CallActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + appContext.startActivity(intent); + return; + } + + if (startsWithVideo && !hasCameraPermission()) { + Log.w(TAG, "Camera permission not granted, will start audio-only"); + startsWithVideo = false; + } + + resetLiveDataForNewCall(); + + this.activeCallId = -1; // Placeholder call ID for Intent + this.activeAccId = accId; + this.activeChatId = chatId; + this.isIncomingCall = false; + this.startsWithVideo = startsWithVideo; + this.pendingOfferSdp = null; + + // Get callee info + DcContext dcContext = ApplicationContext.getDcAccounts().getAccount(accId); + DcChat dcChat = dcContext.getChat(chatId); + String calleeName = getNameFromChat(dcChat); + Icon calleeIcon = getIconFromChat(this.appContext, dcChat); + + displayName.postValue(calleeName); + displayIcon.postValue(calleeIcon); + + startAndBindService(); + + launchCallActivity(); + } + + public void completeOutgoingCall(int accId, int callId, int chatId) { + Log.d(TAG, "Completing outgoing call with accId=" + accId + ", callId=" + callId); + + if (activeAccId == null || activeCallId == null || activeChatId == null) { + Log.w(TAG, "No active call, cannot complete setting up outgoing call"); + return; + } + + if (!activeChatId.equals(chatId) || !activeAccId.equals(accId)) { + Log.w(TAG, "Cannot complete outgoing call,mismatch in call parameters"); + return; + } + + this.activeCallId = callId; + + // Get callee info + String calleeName = displayName.getValue(); + Icon calleeIcon = displayIcon.getValue(); + + // Create call attributes + CallAttributesCompat callAttributes = createCallAttributes( + calleeName, activeCallId, false + ); + + // Add call to CallsManager + addCallToTelecom(callAttributes, calleeName, calleeIcon); + } + + private void addCallToTelecom(CallAttributesCompat callAttributes, + String displayName, Icon icon) { + try { + callsManager.addCall( + callAttributes, + // onAnswer + (callType, continuation) -> { + Log.d(TAG, "CallControlScope: onAnswer with type: " + callType); + if (activeCallViewModel != null && isIncomingCall) { + activeCallViewModel.onCallAnswered(); + } + return Unit.INSTANCE; + }, + // onDisconnect + (disconnectCause, continuation) -> { + Log.d(TAG, "CallControlScope: onDisconnect, cause: " + disconnectCause); + if (activeCallViewModel != null) { + activeCallViewModel.onCallDisconnected(disconnectCause); + } + return Unit.INSTANCE; + }, + // onSetActive + continuation -> { + Log.d(TAG, "CallControlScope: onSetActive"); + if (activeCallViewModel != null) { + activeCallViewModel.onCallActive(); + } + return Unit.INSTANCE; + }, + // onSetInactive + continuation -> { + Log.d(TAG, "CallControlScope: onSetInactive"); + if (activeCallViewModel != null) { + activeCallViewModel.onCallInactive(); + } + return Unit.INSTANCE; + }, + // CallControlScope lambda + scope -> { + Log.d(TAG, "CallControlScope initialized"); + activeCallControlScope = scope; + + new Handler(Looper.getMainLooper()).post(() -> { + setupAudioEndpointCollection(scope); + }); + + return Unit.INSTANCE; + }, + new Continuation() { + @NonNull + @Override + public CoroutineContext getContext() { + return EmptyCoroutineContext.INSTANCE; + } + + @Override + public void resumeWith(@NonNull Object result) { + Log.d(TAG, "addCall completed"); + } + } + ); + } catch (CallException e) { + Log.e(TAG, "Failed to add call to Telecom", e); + } + } + + private CallAttributesCompat createCallAttributes(String callerName, + int callId, + boolean isIncomingCall) { + Uri addressUri = Uri.parse(CALL_IDENTIFIER_SCHEME + callId); + + return new CallAttributesCompat( + callerName, + addressUri, + isIncomingCall ? + CallAttributesCompat.DIRECTION_INCOMING : + CallAttributesCompat.DIRECTION_OUTGOING, + CallAttributesCompat.CALL_TYPE_VIDEO_CALL, + CallAttributesCompat.SUPPORTS_SET_INACTIVE, + null + ); + } + + private void showIncomingCallNotification(String callerName, Icon callerIcon) { + // Check notification permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!hasNotificationPermission()) { + Log.w(TAG, "POST_NOTIFICATIONS permission not granted, cannot show notification"); + return; + } + } + + // Check full screen intent permission on Android 14+ + boolean canUseFullScreen; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + canUseFullScreen = canUseFullScreenIntent(); + if (!canUseFullScreen) { + Log.w(TAG, "Full screen intent permission not granted, notification will appear normally"); + } + } + + // Answer intent + Intent answerIntent = new Intent(this.appContext, CallActivity.class); + answerIntent.setAction(CallActivity.ACTION_ANSWER_CALL); + answerIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + + PendingIntent answerPendingIntent = PendingIntent.getActivity( + this.appContext, 0, answerIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + // Decline intent + Intent declineIntent = new Intent(this.appContext, CallActivity.class); + declineIntent.setAction(CallActivity.ACTION_DECLINE_CALL); + declineIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + PendingIntent declinePendingIntent = PendingIntent.getActivity( + this.appContext, 1, declineIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + // Full screen intent + Intent fullScreenIntent = new Intent(this.appContext, CallActivity.class); + fullScreenIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + PendingIntent fullScreenPendingIntent = PendingIntent.getActivity( + this.appContext, 2, fullScreenIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Android 12+, CallStyle + Person caller = new Person.Builder() + .setName(callerName) + .setIcon(callerIcon) + .setImportant(true) + .build(); + + builder = new Notification.Builder(this.appContext, CHANNEL_ID_INCOMING) + .setSmallIcon(R.drawable.icon_notification) + .setContentTitle("Incoming call") + .setContentText("Incoming call from " + callerName) + .setStyle(Notification.CallStyle.forIncomingCall( + caller, declinePendingIntent, answerPendingIntent) + .setIsVideo(startsWithVideo)) + .addPerson(caller) + .setFullScreenIntent(fullScreenPendingIntent, true) + .setOngoing(true) + .setCategory(Notification.CATEGORY_CALL); + } else { + // Android 8-12: Notification with actions + builder = new Notification.Builder(this.appContext, CHANNEL_ID_INCOMING) + .setSmallIcon(R.drawable.icon_notification) + .setContentTitle("Incoming call") + .setContentText("Incoming call from " + callerName) + .setFullScreenIntent(fullScreenPendingIntent, true) + .setOngoing(true) + .setColorized(true) + .setCategory(Notification.CATEGORY_CALL) + .addAction(new Notification.Action.Builder( + Icon.createWithResource(this.appContext, R.drawable.baseline_call_end_24), + "Decline", declinePendingIntent).build()) + .addAction(new Notification.Action.Builder( + Icon.createWithResource(this.appContext, R.drawable.baseline_call_24), + "Answer", answerPendingIntent).build()); + } + + notificationManager.notify(NOTIFICATION_ID_CALL, builder.build()); + } + + private Notification buildOngoingCallNotification(String statusText, String displayName, Icon icon) { + Intent activityIntent = new Intent(this.appContext, CallActivity.class); + activityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP); + + Intent hangupIntent = new Intent(this.appContext, CallActivity.class); + hangupIntent.setAction(CallActivity.ACTION_HANGUP_CALL); + hangupIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + PendingIntent hangupPendingIntent = PendingIntent.getActivity( + this.appContext, 3, hangupIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + PendingIntent contentIntent = PendingIntent.getActivity( + this.appContext, 4, activityIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Person person = new Person.Builder() + .setName(displayName) + .setIcon(icon) + .setImportant(true) + .build(); + + builder = new Notification.Builder(this.appContext, CHANNEL_ID_ONGOING) + .setSmallIcon(R.drawable.icon_notification) + .setContentTitle("Ongoing call") + .setContentText(statusText) + .setContentIntent(contentIntent) + .setStyle(Notification.CallStyle.forOngoingCall(person, hangupPendingIntent)) + .addPerson(person) + .setOngoing(true) + .setCategory(Notification.CATEGORY_CALL); + } else { + builder = new Notification.Builder(this.appContext, CHANNEL_ID_ONGOING) + .setSmallIcon(R.drawable.icon_notification) + .setContentTitle("Ongoing call") + .setContentText(statusText) + .setContentIntent(contentIntent) + .setOngoing(true) + .setColorized(true) + .setCategory(Notification.CATEGORY_CALL) + .addAction(new Notification.Action.Builder( + Icon.createWithResource(this.appContext, R.drawable.baseline_call_end_24), + "Hang up", hangupPendingIntent).build()); + } + + return builder.build(); + } + + private void showOrUpdateOngoingNotification(String statusText) { + if (callService == null) { + Log.w(TAG, "Cannot show notification, service not ready"); + return; + } + + // Check notification permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (!hasNotificationPermission()) { + Log.w(TAG, "POST_NOTIFICATIONS permission not granted, cannot update notification"); + return; + } + } + + String name = displayName.getValue(); + if (name == null) { + name = "Unknown"; + } + + Icon icon = displayIcon.getValue(); + + Notification notification = buildOngoingCallNotification(statusText, name, icon); + callService.startForegroundWithNotification(NOTIFICATION_ID_CALL, notification); + + Log.d(TAG, "Ongoing notification shown/updated: " + statusText); + } + + private void launchCallActivity() { + Intent intent = new Intent(this.appContext, CallActivity.class); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | + Intent.FLAG_ACTIVITY_SINGLE_TOP); + this.appContext.startActivity(intent); + + Log.d(TAG, "Launching CallActivity, hasActiveViewModel: " + (activeCallViewModel != null)); + } + + public void setActiveCallViewModel(CallViewModel viewModel) { + this.activeCallViewModel = viewModel; + } + + public void clearActiveCallViewModel() { + this.activeCallViewModel = null; + } + + public CallControlScope getActiveCallControlScope() { + return activeCallControlScope; + } + + public CallService getCallService() { + return callService; + } + + public boolean hasActiveCall() { + return activeCallId != null; + } + + public boolean hasOngoingCall() { + if (activeCallId == null) return false; + + PeerConnection.PeerConnectionState state = connectionState.getValue(); + return state != null && state != PeerConnection.PeerConnectionState.NEW; + } + + public boolean isIncomingCall() { + return isIncomingCall; + } + + public boolean isStartsWithVideo() { + return startsWithVideo; + } + + public void setStartsWithVideo(boolean startsWithVideo) { + this.startsWithVideo = startsWithVideo; + } + + // Permission helpers + + public boolean hasMicrophonePermission() { + return ContextCompat.checkSelfPermission(appContext, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED; + } + + public boolean hasCameraPermission() { + return ContextCompat.checkSelfPermission(appContext, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED; + } + + public boolean hasMediaPermissions() { + return hasMicrophonePermission() && hasCameraPermission(); + } + + @RequiresApi(api = Build.VERSION_CODES.TIRAMISU) + public boolean hasNotificationPermission() { + return ContextCompat.checkSelfPermission(appContext, Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } + + @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + public boolean canUseFullScreenIntent() { + NotificationManager notificationManager = + (NotificationManager) appContext.getSystemService(Context.NOTIFICATION_SERVICE); + return notificationManager != null && notificationManager.canUseFullScreenIntent(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallService.java b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java new file mode 100644 index 000000000..de31f99bf --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallService.java @@ -0,0 +1,527 @@ +package org.thoughtcrime.securesms.calls; + +import android.app.Notification; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.media.AudioAttributes; +import android.media.AudioFocusRequest; +import android.media.AudioManager; +import android.media.Ringtone; +import android.media.RingtoneManager; +import android.media.ToneGenerator; +import android.net.Uri; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; + +import org.thoughtcrime.securesms.webrtc.WebRTCClient; +import org.webrtc.AudioTrack; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnection; +import org.webrtc.VideoTrack; + +import chat.delta.rpc.RpcException; + +/** + * Foreground service for VoIP calls + * Required to post CallStyle notifications on Android 12+ + * Owns WebRTC resources and keeps call alive + */ +@RequiresApi(api = Build.VERSION_CODES.O) +public class CallService extends Service implements WebRTCClient.Callbacks { + + private static final String TAG = CallService.class.getSimpleName(); + + private final IBinder binder = new LocalBinder(); + + // WebRTC Resources + private WebRTCClient webRTCClient; + private MediaStreamManager mediaStreamManager; + + // Ringtone Resources + private Ringtone ringtone; + private AudioManager audioManager; + private AudioFocusRequest audioFocusRequest; + private ToneGenerator toneGenerator; + private Runnable toneLoopRunnable; + + private CallCoordinator callCoordinator; + + public class LocalBinder extends Binder { + CallService getService() { + return CallService.this; + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return binder; + } + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "CallService onCreate"); + + callCoordinator = CallCoordinator.getInstance(getApplicationContext()); + + audioManager = (AudioManager) getSystemService(Context.AUDIO_SERVICE); + + Log.d(TAG, "CallService created"); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.d(TAG, "CallService started"); + return START_STICKY; + } + + // Initialization + + /** + * Initialize call infrastructure + * Does NOT start camera/microphone + */ + public void initializeCall() { + Log.d(TAG, "initializeCall (Infrastructure only)"); + + if (webRTCClient != null) { + Log.w(TAG, "Infrastructure already initialized, ignoring"); + return; + } + + webRTCClient = new WebRTCClient(getApplicationContext(), this); + + mediaStreamManager = new MediaStreamManager( + getApplicationContext(), + webRTCClient.getPeerConnectionFactory() + ); + + fetchIceServersAndSetup(); + } + + private void fetchIceServersAndSetup() { + new Thread(() -> { + try { + String iceServersJson = callCoordinator.fetchIceServers(); + Log.d(TAG, "ICE servers fetched: " + iceServersJson); + + webRTCClient.configure(iceServersJson); + + Log.d(TAG, "Infrastructure initialized, waiting for media capture"); + + } catch (RpcException e) { + Log.e(TAG, "Failed to fetch ICE servers", e); + callCoordinator.reportError("Failed to connect: " + e.getMessage()); + } + }).start(); + } + + /** + * Start camera/microphone capture + * Must be called when app is in foreground + * Called by coordinator when ViewModel/Activity is ready + */ + public void startMediaCapture() { + Log.d(TAG, "startMediaCapture (Camera/Microphone)"); + + if (webRTCClient != null && webRTCClient.hasLocalMediaStream()) { + Log.w(TAG, "Media already initialized, skipping"); + return; + } + + if (mediaStreamManager == null) { + Log.e(TAG, "MediaStreamManager not initialized"); + callCoordinator.reportError("MediaStreamManager not initialized"); + return; + } + + // Check permissions + boolean hasMicrophone = callCoordinator.hasMicrophonePermission(); + boolean hasCamera = callCoordinator.hasCameraPermission(); + + if (!hasMicrophone) { + Log.e(TAG, "Microphone permission not granted, cannot start call"); + callCoordinator.reportError("Microphone permission is required for calls"); + return; + } + + boolean startsWithVideo = callCoordinator.isStartsWithVideo() && hasCamera; + + Log.d(TAG, "Creating media stream with video: " + startsWithVideo); + + mediaStreamManager.createMediaStream(new MediaStreamManager.Callback() { + @Override + public void onMediaStreamReady(MediaStream stream) { + Log.d(TAG, "Media stream ready"); + + webRTCClient.setLocalMediaStream(stream); + + callCoordinator.setVideoEnabled(startsWithVideo); + + if (!stream.videoTracks.isEmpty()) { + VideoTrack localTrack = stream.videoTracks.get(0); + callCoordinator.updateLocalVideoTrack(localTrack); + } else { + Log.w(TAG, "Camera unavailable, call will be audio-only"); + callCoordinator.reportError("Camera unavailable, using audio only"); + callCoordinator.setVideoEnabled(false); + } + + Log.d(TAG, "Media capture complete, ready for call"); + + } + + @Override + public void onError(String error) { + Log.e(TAG, "Failed to setup media: " + error); + callCoordinator.reportError("Camera/microphone error: " + error); + callCoordinator.setVideoEnabled(false); + } + }); + } + + // Ringtone Management + + public void startIncomingRingtone() { + Log.d(TAG, "startIncomingRingtone"); + + if (ringtone != null && ringtone.isPlaying()) { + Log.d(TAG, "Ringtone already playing"); + return; + } + + try { + // Get system default ringtone URI + Uri ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); + + if (ringtoneUri == null) { + Log.w(TAG, "No default ringtone available"); + return; + } + + ringtone = RingtoneManager.getRingtone(getApplicationContext(), ringtoneUri); + + if (ringtone == null) { + Log.e(TAG, "Failed to create Ringtone from URI: " + ringtoneUri); + return; + } + + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setLegacyStreamType(AudioManager.STREAM_RING) + .build(); + + ringtone.setAudioAttributes(audioAttributes); + + // Request audio focus + audioFocusRequest = new AudioFocusRequest.Builder( + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT + ) + .setAudioAttributes(audioAttributes) + .setWillPauseWhenDucked(false) + .build(); + + int result = audioManager.requestAudioFocus(audioFocusRequest); + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus not granted"); + } + + ringtone.play(); + Log.d(TAG, "Ringtone started playing"); + + } catch (Exception e) { + Log.e(TAG, "Failed to start ringtone", e); + // Clean up on error + stopRingtone(); + } + } + + public void startOutgoingRingtone() { + Log.d(TAG, "startOutgoingRingtone"); + + if (toneGenerator != null) { + Log.d(TAG, "Tone already playing"); + return; + } + + try { + AudioAttributes audioAttributes = new AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setLegacyStreamType(AudioManager.STREAM_VOICE_CALL) + .build(); + + audioFocusRequest = new AudioFocusRequest.Builder( + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK + ) + .setAudioAttributes(audioAttributes) + .setWillPauseWhenDucked(false) + .build(); + + int result = audioManager.requestAudioFocus(audioFocusRequest); + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + Log.w(TAG, "Audio focus not granted"); + } + + // Create ToneGenerator + toneGenerator = new ToneGenerator(AudioManager.STREAM_VOICE_CALL, 80); + + boolean started = toneGenerator.startTone(ToneGenerator.TONE_SUP_RINGTONE, -1); + + if (started) { + Log.d(TAG, "Outgoing ringback tone started playing"); + } else { + Log.e(TAG, "Failed to start tone"); + stopRingtone(); + } + + } catch (Exception e) { + Log.e(TAG, "Failed to start outgoing ringtone", e); + stopRingtone(); + } + } + + /** + * Stop playing ringtone + */ + public void stopRingtone() { + Log.d(TAG, "stopRingtone"); + + if (toneGenerator != null) { + try { + toneGenerator.stopTone(); + toneGenerator.release(); + Log.d(TAG, "ToneGenerator stopped and released"); + } catch (Exception e) { + Log.e(TAG, "Error stopping ToneGenerator", e); + } + toneGenerator = null; + } + + if (ringtone != null) { + try { + if (ringtone.isPlaying()) { + ringtone.stop(); + Log.d(TAG, "Ringtone stopped"); + } + } catch (Exception e) { + Log.e(TAG, "Error stopping ringtone", e); + } + ringtone = null; + } + + if (audioFocusRequest != null && audioManager != null) { + audioManager.abandonAudioFocusRequest(audioFocusRequest); + audioFocusRequest = null; + Log.d(TAG, "Audio focus abandoned"); + } + } + + // Call Control + + public void startOutgoingCall() { + Log.d(TAG, "startOutgoingCall"); + + if (webRTCClient == null) { + Log.e(TAG, "Cannot start call, not initialized"); + callCoordinator.reportError("Service not ready"); + return; + } + + if (!webRTCClient.hasLocalMediaStream()) { + Log.e(TAG, "Cannot start call, media not ready"); + callCoordinator.reportError("Media not ready"); + return; + } + + webRTCClient.startOutgoingCall(); + } + + public void answerIncomingCall() { + Log.d(TAG, "answerIncomingCall"); + + String offerSdp = callCoordinator.getPendingOfferSdp(); + + if (offerSdp == null) { + Log.e(TAG, "No offer SDP available"); + callCoordinator.reportError("Call data missing"); + return; + } + + if (webRTCClient == null) { + Log.e(TAG, "Cannot answer call, not initialized"); + callCoordinator.reportError("Service not ready"); + return; + } + + if (!webRTCClient.hasLocalMediaStream()) { + Log.e(TAG, "Cannot answer, media not ready"); + callCoordinator.reportError("Media not ready"); + return; + } + + callCoordinator.clearPendingOfferSdp(); + + webRTCClient.acceptIncomingCall(offerSdp); + } + + public void handleAnswerSdp(String answerSdp) { + Log.d(TAG, "handleAnswerSdp"); + + if (webRTCClient == null) { + Log.e(TAG, "Cannot handle answer, not initialized"); + return; + } + + webRTCClient.handleAnswerSdp(answerSdp); + } + + public void setAudioEnabled(boolean enabled) { + Log.d(TAG, "setAudioEnabled: " + enabled); + + if (webRTCClient != null) { + webRTCClient.setAudioEnabled(enabled); + } + } + + public void setVideoEnabled(boolean enabled) { + Log.d(TAG, "setVideoEnabled: " + enabled); + + if (webRTCClient != null) { + webRTCClient.setVideoEnabled(enabled); + } + } + + public void sendMutedState(boolean audioEnabled, boolean videoEnabled) { + Log.d(TAG, "sendMutedState: audio=" + audioEnabled + ", video=" + videoEnabled); + + if (webRTCClient != null) { + webRTCClient.sendMutedState(audioEnabled, videoEnabled); + } + } + + public void switchCamera() { + Log.d(TAG, "switchCamera"); + + if (mediaStreamManager != null) { + mediaStreamManager.switchCamera(); + } + } + + public void endCall() { + Log.d(TAG, "endCall"); + + disposeWebRTC(); + + stopService(); + } + + // WebRTCClient.Callbacks + + @Override + public void onOfferReady(String offerSdp) { + Log.d(TAG, "onOfferReady callback"); + + callCoordinator.handleOfferReady(offerSdp); + } + + @Override + public void onAnswerReady(String answerSdp) { + Log.d(TAG, "onAnswerReady callback"); + + callCoordinator.handleAnswerReady(answerSdp); + } + + @Override + public void onRemoteVideoTrack(VideoTrack videoTrack) { + Log.d(TAG, "onRemoteVideoTrack callback"); + + callCoordinator.updateRemoteVideoTrack(videoTrack); + } + + @Override + public void onRemoteAudioTrack(AudioTrack audioTrack) { + Log.d(TAG, "onRemoteAudioTrack callback"); + } + + @Override + public void onConnectionStateChanged(PeerConnection.PeerConnectionState state) { + Log.d(TAG, "onConnectionStateChanged: " + state); + + callCoordinator.updateConnectionState(state); + } + + @Override + public void onRemoteMutedStateChanged(boolean audioEnabled, boolean videoEnabled) { + Log.d(TAG, "onRemoteMutedStateChanged: audio=" + audioEnabled + ", video=" + videoEnabled); + + callCoordinator.updateRemoteMutedState(audioEnabled, videoEnabled); + } + + @Override + public void onRelayUsageChanged(Boolean isRelayUsed) { + Log.d(TAG, "onRelayUsageChanged: " + isRelayUsed); + + callCoordinator.updateRelayUsage(isRelayUsed); + } + + @Override + public void onError(String error) { + Log.e(TAG, "onError: " + error); + + callCoordinator.reportError(error); + } + + // Foreground Notification + + public void startForegroundWithNotification(int id, Notification notification) { + Log.d(TAG, "Starting foreground with notification id: " + id); + startForeground(id, notification); + } + + public void stopForegroundAndDismiss() { + Log.d(TAG, "Stopping foreground and dismissing notification"); + stopForeground(STOP_FOREGROUND_REMOVE); + } + + // Cleanup + + private void disposeWebRTC() { + Log.d(TAG, "Disposing WebRTC resources"); + + if (mediaStreamManager != null) { + mediaStreamManager.dispose(); + mediaStreamManager = null; + } + + if (webRTCClient != null) { + webRTCClient.dispose(); + webRTCClient = null; + } + } + + public void stopService() { + Log.d(TAG, "Stopping CallService"); + stopSelf(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "CallService onDestroy"); + + stopRingtone(); + + disposeWebRTC(); + + Log.d(TAG, "CallService destroyed"); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java b/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java index 3ef0e237f..270c9cb65 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java @@ -1,62 +1,115 @@ package org.thoughtcrime.securesms.calls; -import android.Manifest; -import android.app.Activity; import android.content.Context; -import android.content.Intent; -import android.util.Base64; +import android.graphics.Bitmap; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.os.Build; import android.util.Log; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.telecom.CallEndpointCompat; + +import com.b44t.messenger.DcChat; +import com.bumptech.glide.load.engine.DiskCacheStrategy; + import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.connect.DcHelper; -import org.thoughtcrime.securesms.permissions.Permissions; - -import java.io.UnsupportedEncodingException; -import java.net.URLEncoder; -import java.nio.charset.StandardCharsets; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.mms.GlideApp; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.util.BitmapUtil; public class CallUtil { private static final String TAG = CallUtil.class.getSimpleName(); - public static void startCall(Activity activity, int chatId, boolean hasVideo) { - Permissions.with(activity) - .request(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) - .ifNecessary() - .withPermanentDenialDialog(activity.getString(R.string.perm_explain_access_to_camera_denied)) - .onAllGranted(() -> { - int accId = DcHelper.getContext(activity).getAccountId(); - startCall(activity, accId, chatId, hasVideo); - }) - .execute(); + @RequiresApi(api = Build.VERSION_CODES.O) + public static void startAudioCall(Context context, int chatId) { + Log.d(TAG, "Starting audio call to " + chatId); + startCall(context, chatId, false); } - public static void startCall(Context context, int accId, int chatId, boolean hasVideo) { - Intent intent = new Intent(context, CallActivity.class); - intent.setAction(Intent.ACTION_VIEW); - intent.putExtra(CallActivity.EXTRA_ACCOUNT_ID, accId); - intent.putExtra(CallActivity.EXTRA_CHAT_ID, chatId); - intent.putExtra(CallActivity.EXTRA_HAS_VIDEO, hasVideo); - intent.putExtra(CallActivity.EXTRA_HASH, "#startCall"); - context.startActivity(intent); + @RequiresApi(api = Build.VERSION_CODES.O) + public static void startVideoCall(Context context, int chatId) { + Log.d(TAG, "Starting video call to " + chatId); + startCall(context, chatId, true); } - public static void openCall(Context context, int accId, int chatId, int callId, String payload, boolean hasVideo) { - String base64 = Base64.encodeToString(payload.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); - String hash = ""; - try { - hash = "#offerIncomingCall=" + URLEncoder.encode(base64, "UTF-8"); - } catch (UnsupportedEncodingException e) { - Log.e(TAG, "Error", e); + @RequiresApi(api = Build.VERSION_CODES.O) + private static void startCall(Context context, int chatId, boolean startsWithVideo) { + if (chatId < 0) { + Log.e(TAG, "Cannot start call: wrong chatId"); + return; } - Intent intent = new Intent(context, CallActivity.class); - intent.setAction(Intent.ACTION_VIEW); - intent.putExtra(CallActivity.EXTRA_ACCOUNT_ID, accId); - intent.putExtra(CallActivity.EXTRA_CHAT_ID, chatId); - intent.putExtra(CallActivity.EXTRA_CALL_ID, callId); - intent.putExtra(CallActivity.EXTRA_HAS_VIDEO, hasVideo); - intent.putExtra(CallActivity.EXTRA_HASH, hash); - context.startActivity(intent); + CallCoordinator coordinator = CallCoordinator.getInstance(context); + int accId = DcHelper.getContext(context).getAccountId(); + + coordinator.initiateOutgoingCall(accId, chatId, startsWithVideo); } + @Nullable + protected static Icon getIconFromChat(Context context, DcChat dcChat) { + try { + Recipient recipient = new Recipient(context, dcChat); + ContactPhoto contactPhoto = recipient.getContactPhoto(context); + + int wh = context.getResources() + .getDimensionPixelSize(R.dimen.contact_photo_target_size); + Bitmap bitmap; + + if (contactPhoto != null) { + bitmap = GlideApp.with(context) + .asBitmap() + .load(contactPhoto) + .diskCacheStrategy(DiskCacheStrategy.ALL) + .circleCrop() + .submit(wh, wh) + .get(); + } else { + Drawable drawable = recipient.getFallbackContactPhoto() + .asDrawable(context, recipient.getFallbackAvatarColor()); + bitmap = BitmapUtil.createFromDrawable(drawable, wh, wh); + } + + if (bitmap != null) { + return Icon.createWithBitmap(bitmap); + } + } catch (Exception e) { + Log.e(TAG, "Failed to load caller icon", e); + } + + return null; + } + + protected static String getNameFromChat(DcChat dcChat) { + return dcChat.getName(); + } + + @RequiresApi(api = Build.VERSION_CODES.O) + public static int getIconResByCallEndpoint(CallEndpointCompat endpoint) { + int iconRes; + switch (endpoint.getType()) { + case CallEndpointCompat.TYPE_EARPIECE: + iconRes = R.drawable.ic_phone_in_talk; + break; + case CallEndpointCompat.TYPE_SPEAKER: + iconRes = R.drawable.ic_volume_up; + break; + case CallEndpointCompat.TYPE_BLUETOOTH: + iconRes = R.drawable.ic_bluetooth_audio; + break; + case CallEndpointCompat.TYPE_WIRED_HEADSET: + iconRes = R.drawable.ic_headset; + break; + case CallEndpointCompat.TYPE_STREAMING: + iconRes = R.drawable.ic_cast; + break; + default: + iconRes = R.drawable.ic_volume_up; + break; + } + return iconRes; + } } diff --git a/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java new file mode 100644 index 000000000..5c7d12a3e --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallViewModel.java @@ -0,0 +1,448 @@ +package org.thoughtcrime.securesms.calls; + +import android.app.Application; +import android.graphics.drawable.Icon; +import android.os.Build; +import android.telecom.DisconnectCause; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.telecom.CallEndpointCompat; +import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MediatorLiveData; +import androidx.lifecycle.Observer; + +import org.webrtc.PeerConnection; +import org.webrtc.VideoTrack; + +import java.util.List; + +@RequiresApi(api = Build.VERSION_CODES.O) +public class CallViewModel extends AndroidViewModel { + + private static final String TAG = CallViewModel.class.getSimpleName(); + + private final CallCoordinator callCoordinator; + + // UI State LiveData (from Coordinator) + + private final LiveData localVideoTrack; + private final LiveData remoteVideoTrack; + private final LiveData localAudioEnabled; + private final LiveData localVideoEnabled; + private final LiveData remoteAudioEnabled; + private final LiveData remoteVideoEnabled; + private final LiveData isRelayUsed; + private final LiveData errorMessage; + private final LiveData displayName; + private final LiveData displayIcon; + private final LiveData currentAudioEndpoint; + private final LiveData> availableAudioEndpoints; + + // Translated from coordinator's connectionState + private final MediatorLiveData callState; + + // Observer References for one-time observe + private Observer answerCallObserver; + private Observer startOutgoingCallObserver; + + private boolean hasCallEnded = false; + + // User-facing call states + public enum CallState { + INITIALIZING, + PROMPTING_USER_ACCEPT, + RINGING, + CONNECTING, + CONNECTED, + RECONNECTING, + ENDED, + ERROR + } + + public CallViewModel(@NonNull Application application) { + super(application); + + this.callCoordinator = CallCoordinator.getInstance(application); + + // Setup LiveData observations + this.localVideoTrack = callCoordinator.getLocalVideoTrack(); + this.remoteVideoTrack = callCoordinator.getRemoteVideoTrack(); + this.localAudioEnabled = callCoordinator.getLocalAudioEnabled(); + this.localVideoEnabled = callCoordinator.getLocalVideoEnabled(); + this.remoteAudioEnabled = callCoordinator.getRemoteAudioEnabled(); + this.remoteVideoEnabled = callCoordinator.getRemoteVideoEnabled(); + this.isRelayUsed = callCoordinator.getIsRelayUsed(); + this.errorMessage = callCoordinator.getErrorMessage(); + this.displayName = callCoordinator.getDisplayName(); + this.displayIcon = callCoordinator.getDisplayIcon(); + this.currentAudioEndpoint = callCoordinator.getCurrentAudioEndpoint(); + this.availableAudioEndpoints = callCoordinator.getAvailableAudioEndpoints(); + + this.callState = new MediatorLiveData<>(CallState.INITIALIZING); + + setupConnectionStateObserver(); + + Log.d(TAG, "CallViewModel created"); + } + + public void initialize() { + Log.d(TAG, "Initializing CallViewModel"); + + callCoordinator.setActiveCallViewModel(this); + + if (callCoordinator.isIncomingCall()) { + callState.setValue(CallState.PROMPTING_USER_ACCEPT); + } else { + callState.setValue(CallState.RINGING); + } + + Log.d(TAG, "CallViewModel initialized"); + } + + private void setupConnectionStateObserver() { + callState.addSource(callCoordinator.getConnectionState(), state -> { + CallState newState = translateConnectionState(state); + + if (callState.getValue() != newState) { + callState.setValue(newState); + } + + if (state == PeerConnection.PeerConnectionState.FAILED || + state == PeerConnection.PeerConnectionState.CLOSED) { + if (!hasCallEnded) { + hasCallEnded = true; + } + } + }); + } + + private CallState translateConnectionState(PeerConnection.PeerConnectionState state) { + switch (state) { + case NEW: + if (callCoordinator.isIncomingCall()) { + return CallState.PROMPTING_USER_ACCEPT; + } else { + return CallState.RINGING; + } + + case CONNECTING: + if (callCoordinator.isIncomingCall()) { + return CallState.CONNECTING; + } else { + return CallState.RINGING; // Mirror TypeScript + } + + case CONNECTED: + return CallState.CONNECTED; + + case DISCONNECTED: + return CallState.RECONNECTING; + + case FAILED: + return CallState.ERROR; + + case CLOSED: + return CallState.ENDED; + + default: + return CallState.INITIALIZING; + } + } + + // Call Control + + public void answerCall() { + Log.d(TAG, "answerCall"); + + if (!callCoordinator.isIncomingCall()) { + Log.w(TAG, "answerCall() called but this is not an incoming call"); + return; + } + + // System integration + callCoordinator.handleCallControlScopeAnswer(); + + answerCallWhenReady(); + } + + /** + * Answer incoming call (WebRTC only) + * Used when system answer already happened + */ + public void answerCallWhenReady() { + Log.d(TAG, "answerCallWhenReady"); + + if (callCoordinator.hasOngoingCall()) { + Log.w(TAG, "Call already ongoing, not starting duplicate"); + return; + } + + // Start media capture + callCoordinator.startMediaCapture(); + + // Create one-time observer + LiveData localTrack = callCoordinator.getLocalVideoTrack(); + + answerCallObserver = new Observer() { + @Override + public void onChanged(VideoTrack videoTrack) { + if (videoTrack != null) { + // Media is ready, remove observer + localTrack.removeObserver(this); + answerCallObserver = null; + + Log.d(TAG, "Local video ready, answering call (WebRTC)"); + + callCoordinator.answerWebRTC(); + } + } + }; + + localTrack.observeForever(answerCallObserver); + } + + /** + * Start outgoing call with media capture + * Called by Activity for outgoing calls + */ + public void startOutgoingCallWhenReady() { + Log.d(TAG, "startOutgoingCallWhenReady"); + + if (callCoordinator.hasOngoingCall()) { + Log.w(TAG, "Call already ongoing, not starting duplicate"); + return; + } + + callCoordinator.startMediaCapture(); + + // Create one-time observer + LiveData localTrack = callCoordinator.getLocalVideoTrack(); + VideoTrack currentValue = localTrack.getValue(); + + if (currentValue != null) { + Log.d(TAG, "Media already ready, starting call immediately"); + callCoordinator.startOutgoingCall(); + } else { + startOutgoingCallObserver = new Observer() { + @Override + public void onChanged(VideoTrack videoTrack) { + if (videoTrack != null) { + // Media is ready, remove observer + localTrack.removeObserver(this); + startOutgoingCallObserver = null; + + Log.d(TAG, "Local video ready, starting outgoing call"); + + callCoordinator.startOutgoingCall(); + } + } + }; + + localTrack.observeForever(startOutgoingCallObserver); + } + } + + public void declineCall() { + Log.d(TAG, "declineCall"); + + if (hasCallEnded) { + Log.w(TAG, "Call already ended"); + return; + } + hasCallEnded = true; + + callCoordinator.declineCall(); + } + + public void hangUp() { + Log.d(TAG, "hangUp"); + + if (hasCallEnded) { + Log.w(TAG, "Call already ended"); + return; + } + hasCallEnded = true; + + callCoordinator.hangUp(); + } + + public void toggleAudio() { + Boolean current = localAudioEnabled.getValue(); + boolean newState = current == null || !current; + + Log.d(TAG, "toggleAudio: " + newState); + + callCoordinator.setAudioEnabled(newState); + } + + public void toggleVideo() { + Boolean current = localVideoEnabled.getValue(); + boolean newState = current == null || !current; + + Log.d(TAG, "toggleVideo: " + newState); + + callCoordinator.setVideoEnabled(newState); + } + + public void switchCamera() { + Log.d(TAG, "switchCamera"); + + callCoordinator.switchCamera(); + } + + public void selectAudioDevice(CallEndpointCompat endpoint) { + Log.d(TAG, "selectAudioDevice: " + endpoint.getName()); + + callCoordinator.requestAudioEndpointChange(endpoint); + } + + public void switchToEarpiece(boolean shallUseEarpiece) { + Log.d(TAG, "switchToEarpiece: shallUseEarpiece: " + shallUseEarpiece); + + List endpoints = availableAudioEndpoints.getValue(); + + if (endpoints == null) { + Log.w(TAG, "No available audio endpoint"); + return; + } + + CallEndpointCompat targetEndpoint = null; + for (CallEndpointCompat endpoint : endpoints) { + boolean isEarpiece = endpoint.getType() == CallEndpointCompat.TYPE_EARPIECE; + if (isEarpiece == shallUseEarpiece) { + targetEndpoint = endpoint; + break; + } + } + + if (targetEndpoint != null) { + selectAudioDevice(targetEndpoint); + } + } + + public void setStartsWithVideo(boolean startsWithVideo) { + Log.d(TAG, "setStartsWithVideo: " + startsWithVideo); + callCoordinator.setStartsWithVideo(startsWithVideo); + } + + // CallControlScope Callbacks + + public void onCallAnswered() { + Log.d(TAG, "onCallAnswered callback from CallControlScope"); + } + + public void onCallActive() { + Log.d(TAG, "onCallActive callback from CallControlScope"); + } + + public void onCallInactive() { + Log.d(TAG, "onCallInactive callback from CallControlScope"); + } + + public void onCallDisconnected(DisconnectCause disconnectCause) { + Log.d(TAG, "onCallDisconnected callback from CallControlScope, cause: " + disconnectCause); + + if (!hasCallEnded) { + hasCallEnded = true; + callState.postValue(CallState.ENDED); + } + } + + // LiveData Getters + + public LiveData getCallState() { + return callState; + } + + public LiveData getAudioEnabled() { + return localAudioEnabled; + } + + public LiveData getVideoEnabled() { + return localVideoEnabled; + } + + public LiveData getRemoteAudioEnabled() { + return remoteAudioEnabled; + } + + public LiveData getRemoteVideoEnabled() { + return remoteVideoEnabled; + } + + public LiveData getLocalVideoTrack() { + return localVideoTrack; + } + + public LiveData getRemoteVideoTrack() { + return remoteVideoTrack; + } + + public LiveData getErrorMessage() { + return errorMessage; + } + + public LiveData getIsRelayUsed() { + return isRelayUsed; + } + + public LiveData getDisplayName() { + return displayName; + } + + public LiveData getDisplayIcon() { + return displayIcon; + } + + public LiveData getCurrentAudioEndpoint() { + return currentAudioEndpoint; + } + + public LiveData> getAvailableAudioEndpoints() { + return availableAudioEndpoints; + } + + + // Notification Action Handlers + + public void handleNotificationAnswer() { + Log.d(TAG, "handleNotificationAnswer"); + + answerCall(); + } + + public void handleNotificationDecline() { + Log.d(TAG, "handleNotificationDecline"); + + declineCall(); + } + + public void handleNotificationHangup() { + Log.d(TAG, "handleNotificationHangup"); + + hangUp(); + } + + // Cleanup + + @Override + protected void onCleared() { + super.onCleared(); + Log.d(TAG, "CallViewModel cleared"); + + if (answerCallObserver != null) { + callCoordinator.getLocalVideoTrack().removeObserver(answerCallObserver); + answerCallObserver = null; + } + + if (startOutgoingCallObserver != null) { + callCoordinator.getLocalVideoTrack().removeObserver(startOutgoingCallObserver); + startOutgoingCallObserver = null; + } + + callCoordinator.clearActiveCallViewModel(); + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java new file mode 100644 index 000000000..dbc965aa1 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/MediaStreamManager.java @@ -0,0 +1,153 @@ +package org.thoughtcrime.securesms.calls; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.thoughtcrime.securesms.EglUtils; +import org.webrtc.AudioSource; +import org.webrtc.AudioTrack; +import org.webrtc.Camera2Enumerator; +import org.webrtc.CameraVideoCapturer; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStream; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.SurfaceTextureHelper; +import org.webrtc.VideoCapturer; +import org.webrtc.VideoSource; +import org.webrtc.VideoTrack; + + +public class MediaStreamManager { + + private static final String TAG = CallUtil.class.getSimpleName(); + private static final String STREAM_ID = "local_stream"; + private static final String AUDIO_TRACK_ID = "audio_track"; + private static final String VIDEO_TRACK_ID = "video_track"; + + private final Context context; + private final PeerConnectionFactory peerConnectionFactory; + + private VideoCapturer videoCapturer; + private VideoSource videoSource; + private AudioSource audioSource; + private SurfaceTextureHelper surfaceTextureHelper; + + public interface Callback { + void onMediaStreamReady(MediaStream stream); + void onError(String error); + } + + public MediaStreamManager(@NonNull Context context, PeerConnectionFactory peerConnectionFactory) { + this.context = context.getApplicationContext(); + + this.peerConnectionFactory = peerConnectionFactory; + } + + /** + * Create media stream with audio and optionally video + */ + public void createMediaStream(Callback callback) { + try { + MediaStream mediaStream = peerConnectionFactory.createLocalMediaStream(STREAM_ID); + + // Create audio track + MediaConstraints audioConstraints = new MediaConstraints(); + audioSource = peerConnectionFactory.createAudioSource(audioConstraints); + AudioTrack audioTrack = peerConnectionFactory.createAudioTrack(AUDIO_TRACK_ID, audioSource); + mediaStream.addTrack(audioTrack); + + // Create video track + videoCapturer = createVideoCapturer(); + if (videoCapturer == null) { + callback.onError("No camera available"); + callback.onMediaStreamReady(mediaStream); + return; + } + + videoSource = peerConnectionFactory.createVideoSource(videoCapturer.isScreencast()); + VideoTrack videoTrack = peerConnectionFactory.createVideoTrack(VIDEO_TRACK_ID, videoSource); + mediaStream.addTrack(videoTrack); + + // Start capturing + surfaceTextureHelper = SurfaceTextureHelper.create( + "CaptureThread", + EglUtils.getEglBase().getEglBaseContext() + ); + videoCapturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver()); + videoCapturer.startCapture(1280, 720, 30); + + callback.onMediaStreamReady(mediaStream); + + } catch (Exception e) { + Log.e(TAG, "Failed to create media stream", e); + callback.onError("Failed to access camera/microphone: " + e.getMessage()); + } + } + + @Nullable + private VideoCapturer createVideoCapturer() { + Camera2Enumerator enumerator = new Camera2Enumerator(context); + + // Try front camera first + String[] deviceNames = enumerator.getDeviceNames(); + for (String deviceName : deviceNames) { + if (enumerator.isFrontFacing(deviceName)) { + VideoCapturer capturer = enumerator.createCapturer(deviceName, null); + if (capturer != null) { + return capturer; + } + } + } + + // Fall back to any camera + for (String deviceName : deviceNames) { + VideoCapturer capturer = enumerator.createCapturer(deviceName, null); + if (capturer != null) { + return capturer; + } + } + + return null; + } + + public void switchCamera() { + if (videoCapturer instanceof CameraVideoCapturer) { + CameraVideoCapturer cameraVideoCapturer = (CameraVideoCapturer) videoCapturer; + cameraVideoCapturer.switchCamera(null); + Log.d(TAG, "Camera switched"); + } + } + + /** + * Cleanup resources + */ + public void dispose() { + if (videoCapturer != null) { + try { + videoCapturer.stopCapture(); + } catch (InterruptedException e) { + Log.e(TAG, "Error stopping capture", e); + } + videoCapturer.dispose(); + videoCapturer = null; + } + + if (surfaceTextureHelper != null) { + surfaceTextureHelper.dispose(); + surfaceTextureHelper = null; + } + + if (videoSource != null) { + videoSource.dispose(); + videoSource = null; + } + + if (audioSource != null) { + audioSource.dispose(); + audioSource = null; + } + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/connect/DcEventCenter.java b/src/main/java/org/thoughtcrime/securesms/connect/DcEventCenter.java index 9e194bf49..b02adf2ad 100644 --- a/src/main/java/org/thoughtcrime/securesms/connect/DcEventCenter.java +++ b/src/main/java/org/thoughtcrime/securesms/connect/DcEventCenter.java @@ -200,15 +200,6 @@ public class DcEventCenter { DcHelper.getNotificationCenter(context).notifyWebxdc(accountId, event.getData1Int(), event.getData2Int(), event.getData2Str()); break; - case DcContext.DC_EVENT_INCOMING_CALL: - DcHelper.getNotificationCenter(context).notifyCall(accountId, event.getData1Int(), event.getData2Str()); - break; - - case DcContext.DC_EVENT_INCOMING_CALL_ACCEPTED: - case DcContext.DC_EVENT_CALL_ENDED: - DcHelper.getNotificationCenter(context).removeCallNotification(accountId, event.getData1Int()); - break; - case DcContext.DC_EVENT_MSGS_NOTICED: DcHelper.getNotificationCenter(context).removeNotifications(accountId, event.getData1Int()); break; diff --git a/src/main/java/org/thoughtcrime/securesms/notifications/DeclineCallReceiver.java b/src/main/java/org/thoughtcrime/securesms/notifications/DeclineCallReceiver.java deleted file mode 100644 index d0ef72426..000000000 --- a/src/main/java/org/thoughtcrime/securesms/notifications/DeclineCallReceiver.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.thoughtcrime.securesms.notifications; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; -import android.util.Log; - -import org.thoughtcrime.securesms.connect.DcHelper; -import org.thoughtcrime.securesms.util.Util; - -import chat.delta.rpc.RpcException; - -public class DeclineCallReceiver extends BroadcastReceiver { - private static final String TAG = DeclineCallReceiver.class.getSimpleName(); - - public static final String DECLINE_ACTION = "org.thoughtcrime.securesms.notifications.DECLINE_CALL"; - public static final String ACCOUNT_ID_EXTRA = "account_id"; - public static final String CALL_ID_EXTRA = "call_id"; - - @Override - public void onReceive(final Context context, Intent intent) { - if (!DECLINE_ACTION.equals(intent.getAction())) { - return; - } - - final int accountId = intent.getIntExtra(ACCOUNT_ID_EXTRA, 0); - final int callId = intent.getIntExtra(CALL_ID_EXTRA, 0); - if (accountId == 0) { - return; - } - - Util.runOnAnyBackgroundThread(() -> { - DcHelper.getNotificationCenter(context).removeCallNotification(accountId, callId); - try { - DcHelper.getRpc(context).endCall(accountId, callId); - } catch (RpcException e) { - Log.e(TAG, "Error", e); - } - }); - } -} diff --git a/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCenter.java b/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCenter.java index 67f69c877..179474b8b 100644 --- a/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCenter.java +++ b/src/main/java/org/thoughtcrime/securesms/notifications/NotificationCenter.java @@ -167,43 +167,6 @@ public class NotificationCenter { return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); } - public PendingIntent getOpenCallIntent(ChatData chatData, int callId, String payload, boolean autoAccept, boolean hasVideo) { - final Intent chatIntent = new Intent(context, ConversationActivity.class) - .putExtra(ConversationActivity.ACCOUNT_ID_EXTRA, chatData.accountId) - .putExtra(ConversationActivity.CHAT_ID_EXTRA, chatData.chatId) - .setAction(Intent.ACTION_VIEW); - - String base64 = Base64.encodeToString(payload.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); - String hash = ""; - try { - hash = (autoAccept? "#acceptCall=" : "#offerIncomingCall=") + URLEncoder.encode(base64, "UTF-8"); - } catch (UnsupportedEncodingException e) { - Log.e(TAG, "Error", e); - } - - Intent intent = new Intent(context, CallActivity.class); - intent.setAction(autoAccept? Intent.ACTION_ANSWER : Intent.ACTION_VIEW); - intent.putExtra(CallActivity.EXTRA_ACCOUNT_ID, chatData.accountId); - intent.putExtra(CallActivity.EXTRA_CHAT_ID, chatData.chatId); - intent.putExtra(CallActivity.EXTRA_CALL_ID, callId); - intent.putExtra(CallActivity.EXTRA_HASH, hash); - intent.putExtra(CallActivity.EXTRA_HAS_VIDEO, hasVideo); - intent.setPackage(context.getPackageName()); - return TaskStackBuilder.create(context) - .addNextIntentWithParentStack(chatIntent) - .addNextIntent(intent) - .getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); - } - - public PendingIntent getDeclineCallIntent(ChatData chatData, int callId) { - Intent intent = new Intent(DeclineCallReceiver.DECLINE_ACTION); - intent.setClass(context, DeclineCallReceiver.class); - intent.putExtra(DeclineCallReceiver.ACCOUNT_ID_EXTRA, chatData.accountId); - intent.putExtra(DeclineCallReceiver.CALL_ID_EXTRA, callId); - intent.setPackage(context.getPackageName()); - return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | IntentUtils.FLAG_MUTABLE()); - } - // Groups and Notification channel groups // -------------------------------------------------------------------------------------------- @@ -402,7 +365,7 @@ public class NotificationCenter { } } - // create a the channel + // create the channel if(!channelExists) { NotificationChannel channel = new NotificationChannel(channelId, name, NotificationManager.IMPORTANCE_MAX); channel.setDescription("Informs about incoming calls."); @@ -427,66 +390,6 @@ public class NotificationCenter { // add notifications & co. // -------------------------------------------------------------------------------------------- - public void notifyCall(int accId, int callId, String payload) { - Util.runOnAnyBackgroundThread(() -> { - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - DcContext dcContext = context.getDcAccounts().getAccount(accId); - boolean hasVideo; - try { - hasVideo = context.getRpc().callInfo(accId, callId).hasVideo; - } catch (RpcException e) { - Log.e(TAG, "Rpc.callInfo() failed", e); - hasVideo = false; - } - int chatId = dcContext.getMsg(callId).getChatId(); - DcChat dcChat = dcContext.getChat(chatId); - String name = dcChat.getName(); - ChatData chatData = new ChatData(accId, chatId); - String notificationChannel = getCallNotificationChannel(notificationManager, chatData, name); - - PendingIntent declineCallIntent = getDeclineCallIntent(chatData, callId); - PendingIntent openCallIntent = getOpenCallIntent(chatData, callId, payload, false, hasVideo); - - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, notificationChannel) - .setSmallIcon(R.drawable.icon_notification) - .setColor(context.getResources().getColor(R.color.delta_primary)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setOngoing(true) - .setOnlyAlertOnce(false) - .setTicker(name) - .setContentTitle(name) - .setFullScreenIntent(openCallIntent, true) - .setContentIntent(openCallIntent) - .setContentText("Incoming Call"); - - builder.addAction( - new NotificationCompat.Action.Builder( - R.drawable.baseline_call_end_24, - context.getString(R.string.end_call), - declineCallIntent).build()); - - builder.addAction( - new NotificationCompat.Action.Builder( - R.drawable.baseline_call_24, - context.getString(R.string.answer_call), - getOpenCallIntent(chatData, callId, payload, true, hasVideo)).build()); - - Bitmap bitmap = getAvatar(dcChat); - if (bitmap != null) { - builder.setLargeIcon(bitmap); - } - - Notification notif = builder.build(); - notif.flags = notif.flags | Notification.FLAG_INSISTENT; - try { - notificationManager.notify("call-" + accId, callId, notif); - } catch (Exception e) { - Log.e(TAG, "cannot add notification", e); - } - }); - } - public void notifyMessage(int accountId, int chatId, int msgId) { Util.runOnAnyBackgroundThread(() -> { DcContext dcContext = context.getDcAccounts().getAccount(accountId); @@ -842,13 +745,13 @@ public class NotificationCenter { return null; } - public void removeCallNotification(int accountId, int callId) { - try { - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - String tag = "call-" + accountId; - notificationManager.cancel(tag, callId); - } catch (Exception e) { Log.w(TAG, e); } - } +// public void removeCallNotification(int accountId, int callId) { +// try { +// NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); +// String tag = "call-" + accountId; +// notificationManager.cancel(tag, callId); +// } catch (Exception e) { Log.w(TAG, e); } +// } public void removeNotification(int accountId, int chatId, int msgId) { boolean shouldCancelNotification = false; diff --git a/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java b/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java new file mode 100644 index 000000000..782546d1b --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java @@ -0,0 +1,1011 @@ +package org.thoughtcrime.securesms.webrtc; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.thoughtcrime.securesms.EglUtils; +import org.webrtc.AudioTrack; +import org.webrtc.DataChannel; +import org.webrtc.DefaultVideoDecoderFactory; +import org.webrtc.DefaultVideoEncoderFactory; +import org.webrtc.EglBase; +import org.webrtc.IceCandidate; +import org.webrtc.MediaConstraints; +import org.webrtc.MediaStream; +import org.webrtc.MediaStreamTrack; +import org.webrtc.PeerConnection; +import org.webrtc.PeerConnectionFactory; +import org.webrtc.RTCStats; +import org.webrtc.RtpReceiver; +import org.webrtc.RtpSender; +import org.webrtc.SdpObserver; +import org.webrtc.SessionDescription; +import org.webrtc.VideoDecoderFactory; +import org.webrtc.VideoEncoderFactory; +import org.webrtc.VideoTrack; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * Manages WebRTC PeerConnection. + * Mirrors TypeScript CallsManager. + */ +public class WebRTCClient { + + private static final String TAG = WebRTCClient.class.getSimpleName(); + + private static final int DC_ID_ICE_TRICKLING = 1; + private static final int DC_ID_MUTED_STATE = 3; + + // ICE gathering timeouts + private static final int RELAY_WAIT_MS = 10000; + private static final int SRFLX_WAIT_MS = 5000; + private static final int SRFLX_BURST_DELAY_MS = 150; + private static final int HOST_WAIT_MS = 3000; + + private final Context context; + private final Handler mainHandler; + private PeerConnectionFactory peerConnectionFactory; + private PeerConnection peerConnection; + private List iceServers; + + private DataChannel iceTricklingDataChannel; + private DataChannel mutedStateDataChannel; + + // ICE candidate + private final List iceCandidateBuffer; + private boolean iceTricklingChannelOpen; + private boolean mutedStateChannelOpen; + private boolean enableIceTrickling; + private boolean isEnded = false; + + // ICE gathering + private volatile boolean isIceGatheringComplete; + private volatile boolean hasRelayCandidate; + private volatile boolean hasSrflxCandidate; + private volatile boolean hasHostCandidate; + private CountDownLatch iceGatheringLatch; + private CountDownLatch relayCandidateLatch; + private CountDownLatch srflxCandidateLatch; + + // Media + private MediaStream localStream; + private VideoTrack localVideoTrack; + private AudioTrack localAudioTrack; + private VideoTrack remoteVideoTrack; + private AudioTrack remoteAudioTrack; + + // Callbacks to ViewModel + private final Callbacks callbacks; + + /** + * Callbacks to ViewModel of connection events + */ + public interface Callbacks { + void onOfferReady(String offerSdp); + void onAnswerReady(String answerSdp); + void onRemoteVideoTrack(VideoTrack videoTrack); + void onRemoteAudioTrack(AudioTrack audioTrack); + void onConnectionStateChanged(PeerConnection.PeerConnectionState state); + void onRemoteMutedStateChanged(boolean audioEnabled, boolean videoEnabled); + void onRelayUsageChanged(Boolean isRelayUsed); + void onError(String error); + } + + public WebRTCClient(@NonNull Context context, Callbacks callbacks) { + this.context = context.getApplicationContext(); + this.callbacks = callbacks; + this.mainHandler = new Handler(Looper.getMainLooper()); + this.iceCandidateBuffer = new ArrayList<>(); + this.iceServers = new ArrayList<>(); + this.iceTricklingChannelOpen = false; + this.mutedStateChannelOpen = false; + this.enableIceTrickling = false; + + initializePeerConnectionFactory(); + } + + private void initializePeerConnectionFactory() { + PeerConnectionFactory.InitializationOptions initOptions = + PeerConnectionFactory.InitializationOptions.builder(context) + .setEnableInternalTracer(false) + .setFieldTrials("") + .createInitializationOptions(); + PeerConnectionFactory.initialize(initOptions); + + EglBase.Context eglBaseContext = EglUtils.getEglBase().getEglBaseContext(); + + // Create video encoder/decoder factories + VideoEncoderFactory encoderFactory = new DefaultVideoEncoderFactory( + eglBaseContext, + true, // enableIntelVp8Encoder + true // enableH264HighProfile + ); + + VideoDecoderFactory decoderFactory = new DefaultVideoDecoderFactory(eglBaseContext); + + PeerConnectionFactory.Options options = new PeerConnectionFactory.Options(); + options.disableEncryption = false; + options.disableNetworkMonitor = false; + + peerConnectionFactory = PeerConnectionFactory.builder() + .setOptions(options) + .setVideoEncoderFactory(encoderFactory) + .setVideoDecoderFactory(decoderFactory) + .createPeerConnectionFactory(); + + Log.d(TAG, "PeerConnectionFactory initialized"); + } + + /** + * Expected JSON format: + * [ + * {"urls": "stun:stun.example.com:3478"}, + * {"urls": "turn:turn.example.com", "username": "user", "credential": "pass"} + * ] + */ + public void configure(String iceServersJson) { + this.iceServers = parseIceServers(iceServersJson); + + if (this.iceServers.isEmpty()) { + Log.w(TAG, "No ICE servers configured, will use host candidates only"); + } else { + Log.d(TAG, "Configured " + iceServers.size() + " ICE servers"); + } + } + + private List parseIceServers(String json) { + List servers = new ArrayList<>(); + + if (json == null || json.trim().isEmpty()) { + return servers; + } + + try { + JSONArray array = new JSONArray(json); + for (int i = 0; i < array.length(); i++) { + JSONObject serverObj = array.getJSONObject(i); + + // Parse URLs string or array + List urls = new ArrayList<>(); + Object urlsObj = serverObj.get("urls"); + if (urlsObj instanceof String) { + urls.add((String) urlsObj); + } else if (urlsObj instanceof JSONArray) { + JSONArray urlsArray = (JSONArray) urlsObj; + for (int j = 0; j < urlsArray.length(); j++) { + urls.add(urlsArray.getString(j)); + } + } + + PeerConnection.IceServer.Builder builder = + PeerConnection.IceServer.builder(urls); + + if (serverObj.has("username")) { + builder.setUsername(serverObj.getString("username")); + } + if (serverObj.has("credential")) { + builder.setPassword(serverObj.getString("credential")); + } + + servers.add(builder.createIceServer()); + } + } catch (JSONException e) { + Log.e(TAG, "Failed to parse ICE servers JSON", e); + } + + return servers; + } + + /** + * Set local media stream + */ + public void setLocalMediaStream(MediaStream stream) { + this.localStream = stream; + + if (!stream.audioTracks.isEmpty()) { + this.localAudioTrack = stream.audioTracks.get(0); + } + if (!stream.videoTracks.isEmpty()) { + this.localVideoTrack = stream.videoTracks.get(0); + } + + Log.d(TAG, "Local media stream set: audio: " + (localAudioTrack != null) + + ", video: " + (localVideoTrack != null)); + } + + public boolean hasLocalMediaStream() { + return localStream != null; + } + + private void createPeerConnection() { + PeerConnection.RTCConfiguration rtcConfig = + new PeerConnection.RTCConfiguration(iceServers); + rtcConfig.iceTransportsType = PeerConnection.IceTransportsType.ALL; + rtcConfig.bundlePolicy = PeerConnection.BundlePolicy.MAXBUNDLE; + rtcConfig.rtcpMuxPolicy = PeerConnection.RtcpMuxPolicy.REQUIRE; + rtcConfig.iceCandidatePoolSize = 1; + rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN; + + peerConnection = peerConnectionFactory.createPeerConnection( + rtcConfig, + new PeerConnectionObserver() + ); + + if (peerConnection == null) { + callbacks.onError("Failed to create PeerConnection"); + return; + } + + // Reset ICE gathering state + this.isIceGatheringComplete = false; + this.hasRelayCandidate = false; + this.hasSrflxCandidate = false; + this.hasHostCandidate = false; + this.iceGatheringLatch = new CountDownLatch(1); + this.relayCandidateLatch = new CountDownLatch(1); + this.srflxCandidateLatch = new CountDownLatch(1); + this.enableIceTrickling = false; + + // Create data channels + setupIceTricklingDataChannel(); + setupMutedStateDataChannel(); + + Log.d(TAG, "PeerConnection created"); + } + + private class PeerConnectionObserver implements PeerConnection.Observer { + @Override + public void onSignalingChange(PeerConnection.SignalingState state) { + Log.d(TAG, "Signaling state: " + state); + } + + @Override + public void onIceConnectionChange(PeerConnection.IceConnectionState state) { + Log.d(TAG, "ICE connection state: " + state); + } + + @Override + public void onConnectionChange(PeerConnection.PeerConnectionState state) { + Log.d(TAG, "Connection state: " + state); + + mainHandler.post(() -> callbacks.onConnectionStateChanged(state)); + + if (state == PeerConnection.PeerConnectionState.CONNECTED) { + detectRelayUsage(); + } + } + + @Override + public void onIceConnectionReceivingChange(boolean receiving) { + Log.d(TAG, "ICE connection receiving: " + receiving); + } + + @Override + public void onIceGatheringChange(PeerConnection.IceGatheringState state) { + Log.d(TAG, "ICE gathering state: " + state); + if (state == PeerConnection.IceGatheringState.COMPLETE) { + isIceGatheringComplete = true; + iceGatheringLatch.countDown(); + } + } + + @Override + public void onIceCandidate(IceCandidate candidate) { + onIceCandidateGathered(candidate); + } + + @Override + public void onIceCandidatesRemoved(IceCandidate[] candidates) { + Log.d(TAG, "ICE candidates removed, current length: " + candidates.length); + } + + @Override + public void onAddStream(MediaStream stream) { + Log.d(TAG, "onAddStream: " + stream.getId()); + } + + @Override + public void onRemoveStream(MediaStream stream) { + Log.d(TAG, "onRemoveStream: " + stream.getId()); + } + + @Override + public void onDataChannel(DataChannel dataChannel) { + Log.d(TAG, "onDataChannel: " + dataChannel.label()); + } + + @Override + public void onRenegotiationNeeded() { + Log.d(TAG, "Renegotiation needed"); + } + + @Override + public void onAddTrack(RtpReceiver receiver, MediaStream[] mediaStreams) { + Log.d(TAG, "onAddTrack: " + receiver.track().kind()); + + MediaStreamTrack track = receiver.track(); + if (track instanceof VideoTrack) { + remoteVideoTrack = (VideoTrack) track; + Log.d(TAG, "Remote video track received"); + mainHandler.post(() -> callbacks.onRemoteVideoTrack(remoteVideoTrack)); + } else if (track instanceof AudioTrack) { + remoteAudioTrack = (AudioTrack) track; + Log.d(TAG, "Remote audio track received"); + mainHandler.post(() -> callbacks.onRemoteAudioTrack(remoteAudioTrack)); + } + } + } + + // Data Channels + private void setupIceTricklingDataChannel() { + DataChannel.Init init = new DataChannel.Init(); + init.negotiated = true; + init.id = DC_ID_ICE_TRICKLING; + + iceTricklingDataChannel = peerConnection.createDataChannel("iceTrickling", init); + + iceTricklingDataChannel.registerObserver(new DataChannel.Observer() { + @Override + public void onBufferedAmountChange(long amount) {} + + @Override + public void onStateChange() { + DataChannel.State state = iceTricklingDataChannel.state(); + Log.d(TAG, "ICE Trickling channel state: " + state); + + if (state == DataChannel.State.OPEN) { + iceTricklingChannelOpen = true; + flushIceCandidateBuffer(); + } else if (state == DataChannel.State.CLOSED) { + iceTricklingChannelOpen = false; + } + } + + @Override + public void onMessage(DataChannel.Buffer buffer) { + String json = extractString(buffer); + Log.d(TAG, "Received ICE candidate: " + json); + + IceCandidate candidate = parseIceCandidate(json); + + if (candidate != null) { + peerConnection.addIceCandidate(candidate); + } else { + Log.d(TAG, "Received end-of-candidates signal"); + } + } + }); + } + + private void setupMutedStateDataChannel() { + DataChannel.Init init = new DataChannel.Init(); + init.negotiated = true; + init.id = DC_ID_MUTED_STATE; + + mutedStateDataChannel = peerConnection.createDataChannel("mutedState", init); + + mutedStateDataChannel.registerObserver(new DataChannel.Observer() { + @Override + public void onBufferedAmountChange(long amount) {} + + @Override + public void onStateChange() { + DataChannel.State state = mutedStateDataChannel.state(); + Log.d(TAG, "mutedState channel state: " + state); + + if (state == DataChannel.State.OPEN) { + mutedStateChannelOpen = true; + + boolean audioEnabled = localAudioTrack != null && localAudioTrack.enabled(); + boolean videoEnabled = localVideoTrack != null && localVideoTrack.enabled(); + sendMutedState(audioEnabled, videoEnabled); + + } else if (state == DataChannel.State.CLOSED) { + mutedStateChannelOpen = false; + } + } + + @Override + public void onMessage(DataChannel.Buffer buffer) { + String json = extractString(buffer); + Log.d(TAG, "Received muted state: " + json); + + try { + JSONObject obj = new JSONObject(json); + boolean audioEnabled = obj.getBoolean("audioEnabled"); + boolean videoEnabled = obj.getBoolean("videoEnabled"); + + mainHandler.post(() -> + callbacks.onRemoteMutedStateChanged(audioEnabled, videoEnabled)); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse muted state JSON", e); + } + } + }); + } + + // Flush buffered ICE candidates when data channel opens + private void flushIceCandidateBuffer() { + Log.d(TAG, "Flushing " + iceCandidateBuffer.size() + " buffered ICE candidates"); + for (IceCandidate candidate : iceCandidateBuffer) { + sendIceCandidateByDataChannel(candidate); + } + iceCandidateBuffer.clear(); + } + + private void sendIceCandidateByDataChannel(IceCandidate candidate) { + if (!iceTricklingChannelOpen) { + Log.d(TAG, "Data channel not open, buffering candidate"); + iceCandidateBuffer.add(candidate); + return; + } + + String json = serializeIceCandidate(candidate); + ByteBuffer buffer = ByteBuffer.wrap(json.getBytes(StandardCharsets.UTF_8)); + iceTricklingDataChannel.send(new DataChannel.Buffer(buffer, false)); + + Log.d(TAG, "Sent ICE candidate using data channel: " + candidate.sdpMid); + } + + /** + * Send current muted state to remote peer + */ + public void sendMutedState(boolean audioEnabled, boolean videoEnabled) { + if (!mutedStateChannelOpen) { + Log.w(TAG, "Muted state channel not open"); + return; + } + + try { + JSONObject json = new JSONObject(); + json.put("audioEnabled", audioEnabled); + json.put("videoEnabled", videoEnabled); + + ByteBuffer buffer = ByteBuffer.wrap( + json.toString().getBytes(StandardCharsets.UTF_8)); + mutedStateDataChannel.send(new DataChannel.Buffer(buffer, false)); + + Log.d(TAG, "Sent muted state: audio: " + audioEnabled + ", video: " + videoEnabled); + } catch (JSONException e) { + Log.e(TAG, "Failed to create muted state JSON", e); + } + } + + // ICE Gathering + private void onIceCandidateGathered(IceCandidate candidate) { + if (candidate == null) { + Log.d(TAG, "ICE gathering complete, getting null candidate"); + isIceGatheringComplete = true; + iceGatheringLatch.countDown(); + return; + } + + // Determine candidate type from SDP + String sdp = candidate.sdp; + if (sdp.contains("typ relay")) { + hasRelayCandidate = true; + Log.d(TAG, "Got TURN/relay candidate"); + relayCandidateLatch.countDown(); + } else if (sdp.contains("typ srflx")) { + hasSrflxCandidate = true; + Log.d(TAG, "Got STUN/srflx candidate"); + srflxCandidateLatch.countDown(); + } else if (sdp.contains("typ host")) { + hasHostCandidate = true; + Log.d(TAG, "Got host candidate"); + } + + // If trickling enabled, send immediately or buffer + if (enableIceTrickling) { + sendIceCandidateByDataChannel(candidate); + } + } + + /** + * Wait for enough ICE before sending offer/answer + * Mirrors TypeScript gatheredEnoughIce() + */ + private boolean waitForEnoughIce() { + boolean hasTurnServer = false; + boolean hasStunServer = false; + + searchLoop: + for (PeerConnection.IceServer s : iceServers) { + for (String url : s.urls) { + if (url.startsWith("turn:")) { + hasTurnServer = true; + } + if (url.startsWith("stun:")) { + hasStunServer = true; + } + if (hasTurnServer && hasStunServer) { + break searchLoop; + } + } + } + + try { + if (hasTurnServer) { + // Wait for TURN relay candidate + Log.d(TAG, "Waiting for TURN/relay candidate..."); + boolean gotRelay = relayCandidateLatch.await(RELAY_WAIT_MS, TimeUnit.MILLISECONDS); + if (gotRelay && hasRelayCandidate) { + Log.d(TAG, "Got relay candidate, proceeding"); + return true; + } + Log.w(TAG, "Timeout waiting for relay, proceeding anyway"); + } else if (hasStunServer) { + // Wait for srflx, then delay for burst + Log.d(TAG, "Waiting for STUN/srflx candidate..."); + boolean gotSrflx = srflxCandidateLatch.await(SRFLX_WAIT_MS, TimeUnit.MILLISECONDS); + + if (gotSrflx) { + // Wait 150ms more for burst of additional srflx candidates + Log.d(TAG, "Got srflx, waiting for burst..."); + Thread.sleep(SRFLX_BURST_DELAY_MS); + return true; + } + Log.w(TAG, "Timeout waiting for srflx, proceeding anyway"); + } else if (iceServers.isEmpty()) { + // No servers: Wait for host candidates + Log.d(TAG, "No ICE servers, waiting for host candidates..."); + boolean gotComplete = iceGatheringLatch.await(HOST_WAIT_MS, TimeUnit.MILLISECONDS); + Log.d(TAG, "Proceeding with host candidates only"); + return hasHostCandidate; + } + + // Fallback + return iceGatheringLatch.await(RELAY_WAIT_MS, TimeUnit.MILLISECONDS); + + } catch (InterruptedException e) { + Log.e(TAG, "Interrupted while waiting for ICE", e); + Thread.currentThread().interrupt(); + return false; + } + } + + // Outgoing Call + + /** + * Start outgoing call + */ + public void startOutgoingCall() { + if (localStream == null) { + mainHandler.post(() -> callbacks.onError("Local media stream not set")); + return; + } + + Log.d(TAG, "Starting outgoing call"); + mainHandler.post(() -> + callbacks.onConnectionStateChanged(PeerConnection.PeerConnectionState.CONNECTING)); + + createPeerConnection(); + + for (AudioTrack track : localStream.audioTracks) { + RtpSender sender = peerConnection.addTrack(track, Collections.singletonList(localStream.getId())); + Log.d(TAG, "Added audio track, sender: " + sender); + } + for (VideoTrack track : localStream.videoTracks) { + RtpSender sender = peerConnection.addTrack(track, Collections.singletonList(localStream.getId())); + Log.d(TAG, "Added video track, sender: " + sender); + } + Log.d(TAG, "Total senders: " + peerConnection.getSenders().size()); + + // Create offer + + peerConnection.createOffer(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sdp) { + onOfferCreated(sdp); + } + + @Override + public void onSetSuccess() {} + + @Override + public void onCreateFailure(String error) { + Log.e(TAG, "Failed to create offer: " + error); + mainHandler.post(() -> callbacks.onError("Failed to create offer: " + error)); + } + + @Override + public void onSetFailure(String error) {} + }, new MediaConstraints()); + } + + private void onOfferCreated(SessionDescription offerSdp) { + peerConnection.setLocalDescription(new SdpObserver() { + @Override + public void onSetSuccess() { + Log.d(TAG, "Local description set, waiting for ICE..."); + + // Wait for ICE in background + new Thread(() -> { + boolean gotIce = waitForEnoughIce(); + + if (!gotIce) { + Log.w(TAG, "Proceeding without optimal ICE candidates"); + } + + // Get final SDP + SessionDescription localDesc = peerConnection.getLocalDescription(); + if (localDesc != null) { + String finalSdp = localDesc.description; + + // Enable trickling for additional candidates + enableIceTrickling = true; + + // Notify ViewModel + mainHandler.post(() -> callbacks.onOfferReady(finalSdp)); + } else { + mainHandler.post(() -> + callbacks.onError("Local description is null after ICE gathering")); + } + }).start(); + } + + @Override + public void onCreateSuccess(SessionDescription sdp) {} + + @Override + public void onCreateFailure(String error) {} + + @Override + public void onSetFailure(String error) { + Log.e(TAG, "Failed to set local description: " + error); + mainHandler.post(() -> + callbacks.onError("Failed to set local description: " + error)); + } + }, offerSdp); + } + + /** + * Handle answer SDP from remote peer for outgoing call + */ + public void handleAnswerSdp(String answerSdp) { + Log.d(TAG, "Handling answer SDP"); + + SessionDescription remoteSdp = new SessionDescription( + SessionDescription.Type.ANSWER, + answerSdp + ); + + peerConnection.setRemoteDescription(new SdpObserver() { + @Override + public void onSetSuccess() { + Log.d(TAG, "Remote answer set, connection should establish"); + } + + @Override + public void onCreateSuccess(SessionDescription sdp) {} + + @Override + public void onCreateFailure(String error) {} + + @Override + public void onSetFailure(String error) { + Log.e(TAG, "Failed to set remote description: " + error); + mainHandler.post(() -> + callbacks.onError("Failed to set remote description: " + error)); + } + }, remoteSdp); + } + + // Incoming Call + + /** + * Accept incoming call + */ + public void acceptIncomingCall(String offerSdp) { + if (localStream == null) { + mainHandler.post(() -> callbacks.onError("Local media stream not set")); + return; + } + + Log.d(TAG, "Accepting incoming call"); + mainHandler.post(() -> + callbacks.onConnectionStateChanged(PeerConnection.PeerConnectionState.CONNECTING)); + + createPeerConnection(); + + SessionDescription remoteSdp = new SessionDescription( + SessionDescription.Type.OFFER, + offerSdp + ); + + peerConnection.setRemoteDescription(new SdpObserver() { + @Override + public void onSetSuccess() { + Log.d(TAG, "Remote offer set, adding local tracks..."); + addLocalTracksAndCreateAnswer(); + } + + @Override + public void onCreateSuccess(SessionDescription sdp) {} + + @Override + public void onCreateFailure(String error) {} + + @Override + public void onSetFailure(String error) { + Log.e(TAG, "Failed to set remote description: " + error); + mainHandler.post(() -> + callbacks.onError("Failed to set remote description: " + error)); + } + }, remoteSdp); + } + + private void addLocalTracksAndCreateAnswer() { + // Add local tracks + for (AudioTrack track : localStream.audioTracks) { + peerConnection.addTrack(track, Collections.singletonList(localStream.getId())); + } + for (VideoTrack track : localStream.videoTracks) { + peerConnection.addTrack(track, Collections.singletonList(localStream.getId())); + } + + // Create answer + peerConnection.createAnswer(new SdpObserver() { + @Override + public void onCreateSuccess(SessionDescription sdp) { + onAnswerCreated(sdp); + } + + @Override + public void onSetSuccess() {} + + @Override + public void onCreateFailure(String error) { + Log.e(TAG, "Failed to create answer: " + error); + mainHandler.post(() -> callbacks.onError("Failed to create answer: " + error)); + } + + @Override + public void onSetFailure(String error) {} + }, new MediaConstraints()); + } + + private void onAnswerCreated(SessionDescription answerSdp) { + peerConnection.setLocalDescription(new SdpObserver() { + @Override + public void onSetSuccess() { + Log.d(TAG, "Local answer set, waiting for ICE..."); + + // Wait for ICE on background thread + new Thread(() -> { + boolean gotIce = waitForEnoughIce(); + + if (!gotIce) { + Log.w(TAG, "Proceeding without optimal ICE candidates"); + } + + // Get final SDP + SessionDescription localDesc = peerConnection.getLocalDescription(); + if (localDesc != null) { + String finalSdp = localDesc.description; + + // Enable trickling for additional candidates + enableIceTrickling = true; + + // Notify ViewModel + mainHandler.post(() -> callbacks.onAnswerReady(finalSdp)); + } else { + mainHandler.post(() -> + callbacks.onError("Local description is null after ICE gathering")); + } + }).start(); + } + + @Override + public void onCreateSuccess(SessionDescription sdp) {} + + @Override + public void onCreateFailure(String error) {} + + @Override + public void onSetFailure(String error) { + Log.e(TAG, "Failed to set local description: " + error); + mainHandler.post(() -> + callbacks.onError("Failed to set local description: " + error)); + } + }, answerSdp); + } + + // Media Control + public void setAudioEnabled(boolean enabled) { + if (localAudioTrack != null) { + localAudioTrack.setEnabled(enabled); + Log.d(TAG, "Local audio " + (enabled ? "enabled" : "disabled")); + } + } + + public void setVideoEnabled(boolean enabled) { + if (localVideoTrack != null) { + localVideoTrack.setEnabled(enabled); + Log.d(TAG, "Local video " + (enabled ? "enabled" : "disabled")); + } + } + + public VideoTrack getLocalVideoTrack() { + return localVideoTrack; + } + + public VideoTrack getRemoteVideoTrack() { + return remoteVideoTrack; + } + + private void detectRelayUsage() { + // Schedule detection after connection settles + mainHandler.postDelayed(() -> { + if (peerConnection == null) return; + + peerConnection.getStats(report -> { + boolean relayUsed = false; + + Map statsMap = report.getStatsMap(); + + // First, find the selected candidate pair + String selectedLocalCandidateId = null; + String selectedRemoteCandidateId = null; + + for (Map.Entry entry : statsMap.entrySet()) { + RTCStats stats = entry.getValue(); + + if ("candidate-pair".equals(stats.getType())) { + Map members = stats.getMembers(); + + // Check if this pair is selected/nominated + Object state = members.get("state"); + if ("succeeded".equals(state) || Boolean.TRUE.equals(members.get("nominated"))) { + selectedLocalCandidateId = (String) members.get("localCandidateId"); + selectedRemoteCandidateId = (String) members.get("remoteCandidateId"); + break; + } + } + } + + // Now check if either candidate is relay type + if (selectedLocalCandidateId != null || selectedRemoteCandidateId != null) { + for (Map.Entry entry : statsMap.entrySet()) { + RTCStats stats = entry.getValue(); + String id = stats.getId(); + + if (id.equals(selectedLocalCandidateId) || id.equals(selectedRemoteCandidateId)) { + if ("local-candidate".equals(stats.getType()) || + "remote-candidate".equals(stats.getType())) { + + Map members = stats.getMembers(); + String candidateType = (String) members.get("candidateType"); + + if ("relay".equals(candidateType)) { + relayUsed = true; + break; + } + } + } + } + } + + boolean finalRelayUsed = relayUsed; + mainHandler.post(() -> callbacks.onRelayUsageChanged(finalRelayUsed)); + + Log.d(TAG, "Relay usage detected: " + finalRelayUsed); + }); + }, 500); + } + + // Cleanup + + public void endCall() { + if (isEnded) { + Log.d(TAG, "endCall() already called, skipping"); + return; + } + + isEnded = true; + Log.d(TAG, "Ending call"); + + if (peerConnection != null) { + peerConnection.close(); + peerConnection.dispose(); + peerConnection = null; + } + + if (iceTricklingDataChannel != null) { + iceTricklingDataChannel.close(); + iceTricklingDataChannel.dispose(); + iceTricklingDataChannel = null; + } + + if (mutedStateDataChannel != null) { + mutedStateDataChannel.close(); + mutedStateDataChannel.dispose(); + mutedStateDataChannel = null; + } + + if (localStream != null) { + for (AudioTrack track : localStream.audioTracks) { + track.setEnabled(false); + } + for (VideoTrack track : localStream.videoTracks) { + track.setEnabled(false); + } + } + + iceCandidateBuffer.clear(); + iceTricklingChannelOpen = false; + mutedStateChannelOpen = false; + enableIceTrickling = false; + localStream = null; + localVideoTrack = null; + localAudioTrack = null; + remoteVideoTrack = null; + remoteAudioTrack = null; + } + + public void dispose() { + endCall(); + + if (peerConnectionFactory != null) { + peerConnectionFactory.dispose(); + peerConnectionFactory = null; + } + } + + public PeerConnectionFactory getPeerConnectionFactory() { + return peerConnectionFactory; + } + + private String extractString(@NonNull DataChannel.Buffer buffer) { + byte[] bytes = new byte[buffer.data.remaining()]; + buffer.data.get(bytes); + return new String(bytes, StandardCharsets.UTF_8); + } + + private IceCandidate parseIceCandidate(String json) { + if (json == null || json.equals("null")) { + return null; // End of candidates + } + + try { + JSONObject obj = new JSONObject(json); + String sdpMid = obj.getString("sdpMid"); + int sdpMLineIndex = obj.getInt("sdpMLineIndex"); + String sdp = obj.getString("candidate"); + + return new IceCandidate(sdpMid, sdpMLineIndex, sdp); + } catch (JSONException e) { + Log.e(TAG, "Failed to parse ICE candidate JSON", e); + return null; + } + } + + @NonNull + private String serializeIceCandidate(@NonNull IceCandidate candidate) { + try { + JSONObject obj = new JSONObject(); + obj.put("sdpMid", candidate.sdpMid); + obj.put("sdpMLineIndex", candidate.sdpMLineIndex); + obj.put("candidate", candidate.sdp); + return obj.toString(); + } catch (JSONException e) { + Log.e(TAG, "Failed to serialize ICE candidate", e); + return "null"; + } + } +} diff --git a/src/main/res/drawable/bottom_sheet_handle.xml b/src/main/res/drawable/bottom_sheet_handle.xml new file mode 100644 index 000000000..e790db9cb --- /dev/null +++ b/src/main/res/drawable/bottom_sheet_handle.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/src/main/res/drawable/ic_bluetooth_audio.xml b/src/main/res/drawable/ic_bluetooth_audio.xml new file mode 100644 index 000000000..784198e2e --- /dev/null +++ b/src/main/res/drawable/ic_bluetooth_audio.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_call.xml b/src/main/res/drawable/ic_call.xml new file mode 100644 index 000000000..cdfabff84 --- /dev/null +++ b/src/main/res/drawable/ic_call.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_call_end.xml b/src/main/res/drawable/ic_call_end.xml new file mode 100644 index 000000000..d1952da31 --- /dev/null +++ b/src/main/res/drawable/ic_call_end.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_cast.xml b/src/main/res/drawable/ic_cast.xml new file mode 100644 index 000000000..8ac046129 --- /dev/null +++ b/src/main/res/drawable/ic_cast.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_check.xml b/src/main/res/drawable/ic_check.xml new file mode 100644 index 000000000..8d84fb7ed --- /dev/null +++ b/src/main/res/drawable/ic_check.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_headset.xml b/src/main/res/drawable/ic_headset.xml new file mode 100644 index 000000000..bef9a86c8 --- /dev/null +++ b/src/main/res/drawable/ic_headset.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_mic_off.xml b/src/main/res/drawable/ic_mic_off.xml new file mode 100644 index 000000000..eb8b20892 --- /dev/null +++ b/src/main/res/drawable/ic_mic_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_mic_on.xml b/src/main/res/drawable/ic_mic_on.xml new file mode 100644 index 000000000..7d2e09902 --- /dev/null +++ b/src/main/res/drawable/ic_mic_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_person.xml b/src/main/res/drawable/ic_person.xml new file mode 100644 index 000000000..4ac431fb0 --- /dev/null +++ b/src/main/res/drawable/ic_person.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_phone_in_talk.xml b/src/main/res/drawable/ic_phone_in_talk.xml new file mode 100644 index 000000000..0609963b1 --- /dev/null +++ b/src/main/res/drawable/ic_phone_in_talk.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_switch_camera.xml b/src/main/res/drawable/ic_switch_camera.xml new file mode 100644 index 000000000..62f2207a1 --- /dev/null +++ b/src/main/res/drawable/ic_switch_camera.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_videocam_off.xml b/src/main/res/drawable/ic_videocam_off.xml new file mode 100644 index 000000000..f8fad1f90 --- /dev/null +++ b/src/main/res/drawable/ic_videocam_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_videocam_on.xml b/src/main/res/drawable/ic_videocam_on.xml new file mode 100644 index 000000000..8c92b49d6 --- /dev/null +++ b/src/main/res/drawable/ic_videocam_on.xml @@ -0,0 +1,10 @@ + + + diff --git a/src/main/res/drawable/ic_volume_up.xml b/src/main/res/drawable/ic_volume_up.xml new file mode 100644 index 000000000..bc9c5c827 --- /dev/null +++ b/src/main/res/drawable/ic_volume_up.xml @@ -0,0 +1,11 @@ + + + diff --git a/src/main/res/layout/activity_call.xml b/src/main/res/layout/activity_call.xml new file mode 100644 index 000000000..176a99614 --- /dev/null +++ b/src/main/res/layout/activity_call.xml @@ -0,0 +1,364 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +