diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml new file mode 100644 index 000000000..ab4c42521 --- /dev/null +++ b/.github/workflows/code-format.yml @@ -0,0 +1,35 @@ +name: Code Format + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + spotless: + name: Check code format (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 4b5a780c0..a8be09d53 100644 --- a/.github/workflows/preview-apk.yml +++ b/.github/workflows/preview-apk.yml @@ -14,16 +14,15 @@ jobs: - uses: actions/checkout@v5 with: submodules: recursive + - name: Validate Fastlane Metadata uses: ashutoshgngwr/validate-fastlane-supply-metadata@v2 - - 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: | @@ -32,13 +31,32 @@ 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 + + - 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: 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: diff --git a/CHANGELOG-upstream.md b/CHANGELOG-upstream.md index 784c5bbb3..3fc5b60ba 100644 --- a/CHANGELOG-upstream.md +++ b/CHANGELOG-upstream.md @@ -7,10 +7,14 @@ * 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 +* Fix: avoid "reply privately" not quoting the selected message sometimes +* Some more bug fixes and updated translations +* Update to core 2.45.0 ## v2.43.0 2026-02 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 be7766d79..71a90d524 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 { @@ -72,10 +73,20 @@ android { packagingOptions { jniLibs { doNotStrip '**/*.so' - keepDebugSymbols += ['*/mips/*.so', '*/mips64/*.so'] + keepDebugSymbols += [ + '*/mips/*.so', + '*/mips64/*.so' + ] } resources { - excludes += ['LICENSE.txt', 'LICENSE', 'NOTICE', 'asm-license.txt', 'META-INF/LICENSE', 'META-INF/NOTICE'] + excludes += [ + 'LICENSE.txt', + 'LICENSE', + 'NOTICE', + 'asm-license.txt', + 'META-INF/LICENSE', + 'META-INF/NOTICE' + ] } } @@ -154,14 +165,14 @@ android { } if(!project.hasProperty("ABI_FILTER")) { - splits { - abi { - enable true - reset() - include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" - universalApk true + splits { + abi { + enable true + reset() + include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" + universalApk true + } } - } } project.ext.versionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4] @@ -169,16 +180,16 @@ android { android.applicationVariants.all { variant -> variant.outputs.all { output -> output.outputFileName = output.outputFileName - .replace("android", "ArcaneChat") - .replace("-release", "") - .replace(".apk", "-${variant.versionName}.apk") + .replace("android", "ArcaneChat") + .replace("-release", "") + .replace(".apk", "-${variant.versionName}.apk") if(project.hasProperty("ABI_FILTER")) { - output.versionCodeOverride = - variant.versionCode * 10 + project.ext.versionCodes.get(ABI_FILTER) + output.versionCodeOverride = + variant.versionCode * 10 + project.ext.versionCodes.get(ABI_FILTER) } else { - output.versionCodeOverride = - variant.versionCode * 10 + project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 4) - } + output.versionCodeOverride = + variant.versionCode * 10 + project.ext.versionCodes.get(output.getFilter(com.android.build.OutputFile.ABI), 4) + } } } @@ -199,7 +210,30 @@ android { renderScript true aidl true } +} +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').configFile('spotless/eclipse-wtp-xml.prefs') + trimTrailingWhitespace() + endWithNewline() + } + groovyGradle { + target '*.gradle' + greclipse() + leadingTabsToSpaces(4) + trimTrailingWhitespace() + endWithNewline() + } } final def markwon_version = '4.6.2' @@ -214,6 +248,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.9.4' implementation 'androidx.sharetarget:sharetarget:1.2.0' implementation 'androidx.webkit:webkit:1.14.0' implementation 'androidx.multidex:multidex:2.0.1' @@ -251,7 +290,8 @@ dependencies { implementation 'com.github.amulyakhare:TextDrawable:558677ea31' // number of unread messages, // the one-letter circle for the contacts (when there is not avatar) and a white background. implementation 'com.googlecode.mp4parser:isoparser:1.0.6' // MP4 recoding; upgrading eg. to 1.1.22 breaks recoding, however, i have not investigated further, just reset to 1.0.6 - implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.10.0') { // for the zooming on photos / media + implementation ('com.davemorrissey.labs:subsampling-scale-image-view:3.10.0') { + // for the zooming on photos / media exclude group: 'com.android.support', module: 'support-annotations' } @@ -260,7 +300,8 @@ dependencies { // implementation 'de.cketti.safecontentresolver:safe-content-resolver-v21:1.0.0' - gplayImplementation('com.google.firebase:firebase-messaging:24.1.2') { // for PUSH notifications, don't upgrade: v25.0.0 requires minSdk>=23 + gplayImplementation('com.google.firebase:firebase-messaging:24.1.2') { + // for PUSH notifications, don't upgrade: v25.0.0 requires minSdk>=23 exclude group: 'com.google.firebase', module: 'firebase-core' exclude group: 'com.google.firebase', module: 'firebase-analytics' exclude group: 'com.google.firebase', module: 'firebase-measurement-connector' diff --git a/jni/deltachat-core-rust b/jni/deltachat-core-rust index 27dbe0bb6..45b911aeb 160000 --- a/jni/deltachat-core-rust +++ b/jni/deltachat-core-rust @@ -1 +1 @@ -Subproject commit 27dbe0bb6c9ad74da2ca081a6b67231bd33411ad +Subproject commit 45b911aeb9f66df0825d998bafb48ae2098eac35 diff --git a/proguard-rules.pro b/proguard-rules.pro index 6a29a43bd..ea5c22225 100644 --- a/proguard-rules.pro +++ b/proguard-rules.pro @@ -13,3 +13,8 @@ -keep class org.thoughtcrime.securesms.crypto.KeyStoreHelper* { *; } -dontwarn com.google.firebase.analytics.connector.AnalyticsConnector + +# Keep WebRTC classes +-keep class org.webrtc.** { *; } +-keepclassmembers class org.webrtc.** { *; } +-keepattributes InnerClasses 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() } diff --git a/spotless/eclipse-wtp-xml.prefs b/spotless/eclipse-wtp-xml.prefs new file mode 100644 index 000000000..bc8e20f72 --- /dev/null +++ b/spotless/eclipse-wtp-xml.prefs @@ -0,0 +1,7 @@ +eclipse.preferences.version=1 +lineWidth=100 +splitMultiAttrs=true +indentMultipleAttributes=true +indentationChar=space +indentationSize=4 +spaceBeforeEmptyCloseTag=true \ No newline at end of file diff --git a/src/gplay/AndroidManifest.xml b/src/gplay/AndroidManifest.xml index 904f0fd56..423d55057 100644 --- a/src/gplay/AndroidManifest.xml +++ b/src/gplay/AndroidManifest.xml @@ -3,10 +3,6 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - - - diff --git a/src/main/AndroidManifest.xml b/src/main/AndroidManifest.xml index 7d633f8ad..d55314b2e 100644 --- a/src/main/AndroidManifest.xml +++ b/src/main/AndroidManifest.xml @@ -16,9 +16,12 @@ + + + @@ -39,9 +42,12 @@ + + + - - + + + 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"> + + @@ -410,14 +426,6 @@ - - - - - - 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/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; } diff --git a/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java b/src/main/java/org/thoughtcrime/securesms/ApplicationContext.java index 6e00f1da1..2376d897f 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; @@ -322,6 +323,11 @@ public class ApplicationContext extends MultiDexApplication { initializeJobManager(); InChatSounds.getInstance(this); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + EglUtils.getEglBase(); + CallCoordinator.getInstance(this); + } + DynamicTheme.setDefaultDayNightMode(this); IntentFilter filter = new IntentFilter(Intent.ACTION_LOCALE_CHANGED); @@ -374,6 +380,14 @@ public class ApplicationContext extends MultiDexApplication { Log.i("DeltaChat", "+++++++++++ ApplicationContext.onCreate() finished ++++++++++"); } + @Override + public void onTerminate() { + super.onTerminate(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + 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 9f148bd40..6598a281a 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; @@ -503,6 +504,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity } } + @Override public boolean onPrepareOptionsMenu(Menu menu) { menu.clear(); @@ -521,6 +523,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity menu.findItem(R.id.menu_start_call).setVisible( Prefs.isCallsEnabled(this) + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && dcChat.canSend() && dcChat.isEncrypted() && !dcChat.isSelfTalk() @@ -615,10 +618,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(); @@ -1182,6 +1189,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; @@ -1191,9 +1199,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; } @@ -1204,7 +1212,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) { @@ -1253,7 +1261,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) { @@ -1269,7 +1277,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(); @@ -1280,48 +1288,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(); } @@ -1383,7 +1379,7 @@ public class ConversationActivity extends PassphraseRequiredActionBarActivity future.addListener(new ListenableFuture.Listener>() { @Override public void onSuccess(final @NonNull Pair result) { - AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first, result.second, MediaUtil.AUDIO_AAC, true); + AudioSlide audioSlide = new AudioSlide(ConversationActivity.this, result.first, result.second, MediaUtil.AUDIO_M4A, true); SlideDeck slideDeck = new SlideDeck(); slideDeck.addSlide(audioSlide); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java index 8b2271e2b..d76c84b7c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationItem.java @@ -23,6 +23,8 @@ import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.Rect; import android.text.Spannable; +import android.os.Build; +import android.text.SpannableString; import android.text.TextUtils; import android.util.AttributeSet; import android.util.Log; @@ -41,6 +43,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; @@ -978,14 +981,20 @@ public class ConversationItem extends BaseConversationItem if (shouldInterceptClicks(messageRecord) || !batchSelected.isEmpty()) { performClick(); } else { - int accId = dcContext.getAccountId(); int chatId = messageRecord.getChatId(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (!messageRecord.isOutgoing() && callInfo.state instanceof CallState.Alerting) { - int callId = messageRecord.getId(); - CallUtil.openCall(getContext(), accId, chatId, callId, callInfo.sdpOffer, callInfo.hasVideo); + int callId = messageRecord.getId(); + CallCoordinator coordinator = CallCoordinator.getInstance(context); + coordinator.showIncomingCallScreen(callId); } else { - CallUtil.startCall(getContext(), accId, chatId, callInfo.hasVideo); + 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..d8a881960 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/EglUtils.java @@ -0,0 +1,26 @@ +package org.thoughtcrime.securesms; + +import android.os.Build; + +import androidx.annotation.RequiresApi; + +import org.webrtc.EglBase; + +@RequiresApi(Build.VERSION_CODES.M) +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/audio/AudioCodec.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java index 8397e9336..5cb2c39d6 100644 --- a/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java +++ b/src/main/java/org/thoughtcrime/securesms/audio/AudioCodec.java @@ -6,24 +6,21 @@ import android.media.AudioRecord; import android.media.MediaCodec; import android.media.MediaCodecInfo; import android.media.MediaFormat; +import android.media.MediaMuxer; import android.media.MediaRecorder; import android.util.Log; import java.io.IOException; -import java.io.OutputStream; import java.nio.ByteBuffer; import org.thoughtcrime.securesms.util.Prefs; -import org.thoughtcrime.securesms.util.Util; public class AudioCodec { private static final String TAG = AudioCodec.class.getSimpleName(); private static final int SAMPLE_RATE_BALANCED = 44100; - private static final int SAMPLE_RATE_INDEX_BALANCED = 4; private static final int BIT_RATE_BALANCED = 32000; private static final int SAMPLE_RATE_WORSE = 24000; - private static final int SAMPLE_RATE_INDEX_WORSE = 6; private static final int BIT_RATE_WORSE = 16000; private static final int CHANNELS = 1; @@ -32,12 +29,15 @@ public class AudioCodec { private final MediaCodec mediaCodec; private final AudioRecord audioRecord; private final Context context; + private final String outputPath; - private boolean running = true; - private boolean finished = false; + private volatile boolean running = true; + private volatile boolean finished = false; + private long startTime = 0; - public AudioCodec(Context context) throws IOException { + public AudioCodec(Context context, String outputPath) throws IOException { this.context = context; + this.outputPath = outputPath; this.bufferSize = AudioRecord.getMinBufferSize( getSampleRate(), AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT); @@ -49,7 +49,7 @@ public class AudioCodec { try { audioRecord.startRecording(); } catch (Exception e) { - Log.w(TAG, e); + Log.w(TAG, "Failed to start recording", e); mediaCodec.release(); throw new IOException(e); } @@ -57,41 +57,147 @@ public class AudioCodec { public synchronized void stop() { running = false; - while (!finished) Util.wait(this, 0); + while (!finished) { + try { + wait(5000); + if (!finished) { + Log.w(TAG, "Timeout waiting for recording to finish"); + break; + } + } catch (InterruptedException ie) { + Log.w(TAG, "Interrupted while waiting for recording to finish", ie); + Thread.currentThread().interrupt(); + break; + } + } } - public void start(final OutputStream outputStream) { + public void start() { new Thread( - new Runnable() { - @Override - public void run() { - MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); - byte[] audioRecordData = new byte[bufferSize]; - ByteBuffer[] codecInputBuffers = mediaCodec.getInputBuffers(); - ByteBuffer[] codecOutputBuffers = mediaCodec.getOutputBuffers(); + () -> { + this.startTime = System.nanoTime(); + + MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo(); + byte[] audioRecordData = new byte[bufferSize]; + MediaMuxer muxer = null; + int audioTrackIndex = -1; + boolean muxerStarted = false; + + try { + muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + + while (true) { + boolean running = isRunning(); + + handleCodecInput(audioRecord, audioRecordData, mediaCodec, running); + int codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 2000); + + while (codecOutputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) { + if (codecOutputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + // Get the format to add track to muxer + if (muxerStarted) { + Log.w(TAG, "Output format changed after muxer started, ignoring"); + codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); + continue; + } + MediaFormat newFormat = mediaCodec.getOutputFormat(); + Log.d(TAG, "Output format changed: " + newFormat); + audioTrackIndex = muxer.addTrack(newFormat); + muxer.start(); + muxerStarted = true; + Log.d(TAG, "Muxer started"); + } else if (codecOutputBufferIndex >= 0) { + ByteBuffer encodedData = mediaCodec.getOutputBuffer(codecOutputBufferIndex); + + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { + // Codec config info, not actual data, skip it + Log.d(TAG, "Ignoring CODEC_CONFIG buffer"); + bufferInfo.size = 0; + } + + if (bufferInfo.size != 0 && encodedData != null) { + if (!muxerStarted) { + Log.w(TAG, "Muxer not started, dropping encoded data"); + } else { + encodedData.position(bufferInfo.offset); + encodedData.limit(bufferInfo.offset + bufferInfo.size); + muxer.writeSampleData(audioTrackIndex, encodedData, bufferInfo); + } + + // Adjust ByteBuffer to match BufferInfo + encodedData.position(bufferInfo.offset); + encodedData.limit(bufferInfo.offset + bufferInfo.size); + + // Write sample data to muxer + muxer.writeSampleData(audioTrackIndex, encodedData, bufferInfo); + } + + mediaCodec.releaseOutputBuffer(codecOutputBufferIndex, false); + + // Check for end of stream + if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + Log.d(TAG, "End of stream reached"); + break; + } + } + + codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); + } + + if (!running && (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { + break; + } + } + } catch (Exception e) { + Log.w(TAG, "Error during encoding", e); + } finally { + try { + if (muxerStarted && muxer != null) { + try { + muxer.stop(); + Log.d(TAG, "Muxer stopped"); + } catch (IllegalStateException e) { + Log.w(TAG, "Muxer already stopped", e); + } + } + } catch (Exception e) { + Log.w(TAG, "Error stopping muxer", e); + } + + if (muxer != null) { + try { + muxer.release(); + Log.d(TAG, "Muxer released"); + } catch (Exception e) { + Log.w(TAG, "Error releasing muxer", e); + } + } try { - while (true) { - boolean running = isRunning(); - - handleCodecInput( - audioRecord, audioRecordData, mediaCodec, codecInputBuffers, running); - handleCodecOutput(mediaCodec, codecOutputBuffers, bufferInfo, outputStream); - - if (!running) break; - } - } catch (IOException e) { - Log.w(TAG, e); - } finally { mediaCodec.stop(); - audioRecord.stop(); - - mediaCodec.release(); - audioRecord.release(); - - Util.close(outputStream); - setFinished(); + } catch (Exception e) { + Log.w(TAG, "Error stopping codec", e); } + + try { + audioRecord.stop(); + } catch (Exception e) { + Log.w(TAG, "Error stopping audio record", e); + } + + try { + mediaCodec.release(); + } catch (Exception e) { + Log.w(TAG, "Error releasing codec", e); + } + + try { + audioRecord.release(); + } catch (Exception e) { + Log.w(TAG, "Error releasing audio record", e); + } + + setFinished(); } }, AudioCodec.class.getSimpleName()) @@ -108,75 +214,35 @@ public class AudioCodec { } private void handleCodecInput( - AudioRecord audioRecord, - byte[] audioRecordData, - MediaCodec mediaCodec, - ByteBuffer[] codecInputBuffers, - boolean running) { + AudioRecord audioRecord, byte[] audioRecordData, MediaCodec mediaCodec, boolean running) { int length = audioRecord.read(audioRecordData, 0, audioRecordData.length); + + if (length < 0) { + Log.w(TAG, "Error reading from AudioRecord: " + length); + return; + } + int codecInputBufferIndex = mediaCodec.dequeueInputBuffer(10 * 1000); if (codecInputBufferIndex >= 0) { - ByteBuffer codecBuffer = codecInputBuffers[codecInputBufferIndex]; - codecBuffer.clear(); - codecBuffer.put(audioRecordData); - mediaCodec.queueInputBuffer( - codecInputBufferIndex, 0, length, 0, running ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM); - } - } + ByteBuffer codecBuffer = mediaCodec.getInputBuffer(codecInputBufferIndex); - private void handleCodecOutput( - MediaCodec mediaCodec, - ByteBuffer[] codecOutputBuffers, - MediaCodec.BufferInfo bufferInfo, - OutputStream outputStream) - throws IOException { - int codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); - - while (codecOutputBufferIndex != MediaCodec.INFO_TRY_AGAIN_LATER) { - if (codecOutputBufferIndex >= 0) { - ByteBuffer encoderOutputBuffer = codecOutputBuffers[codecOutputBufferIndex]; - - encoderOutputBuffer.position(bufferInfo.offset); - encoderOutputBuffer.limit(bufferInfo.offset + bufferInfo.size); - - if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) - != MediaCodec.BUFFER_FLAG_CODEC_CONFIG) { - byte[] header = createAdtsHeader(bufferInfo.size - bufferInfo.offset); - - outputStream.write(header); - - byte[] data = new byte[encoderOutputBuffer.remaining()]; - encoderOutputBuffer.get(data); - outputStream.write(data); - } - - encoderOutputBuffer.clear(); - - mediaCodec.releaseOutputBuffer(codecOutputBufferIndex, false); - } else if (codecOutputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { - codecOutputBuffers = mediaCodec.getOutputBuffers(); + if (codecBuffer != null) { + codecBuffer.clear(); + codecBuffer.put(audioRecordData, 0, length); + long presentationTimeUs = getPresentationTimeUs(); + mediaCodec.queueInputBuffer( + codecInputBufferIndex, + 0, + length, + presentationTimeUs, + running ? 0 : MediaCodec.BUFFER_FLAG_END_OF_STREAM); } - - codecOutputBufferIndex = mediaCodec.dequeueOutputBuffer(bufferInfo, 0); } } - private byte[] createAdtsHeader(int length) { - int frameLength = length + 7; - byte[] adtsHeader = new byte[7]; - - adtsHeader[0] = (byte) 0xFF; // Sync Word - adtsHeader[1] = (byte) 0xF1; // MPEG-4, Layer (0), No CRC - adtsHeader[2] = (byte) ((MediaCodecInfo.CodecProfileLevel.AACObjectLC - 1) << 6); - adtsHeader[2] |= (((byte) getSampleRateIndex()) << 2); - adtsHeader[2] |= (((byte) CHANNELS) >> 2); - adtsHeader[3] = (byte) (((CHANNELS & 3) << 6) | ((frameLength >> 11) & 0x03)); - adtsHeader[4] = (byte) ((frameLength >> 3) & 0xFF); - adtsHeader[5] = (byte) (((frameLength & 0x07) << 5) | 0x1f); - adtsHeader[6] = (byte) 0xFC; - - return adtsHeader; + private long getPresentationTimeUs() { + return (System.nanoTime() - startTime) / 1000; } private AudioRecord createAudioRecord(int bufferSize) { @@ -190,11 +256,9 @@ public class AudioCodec { private MediaCodec createMediaCodec(int bufferSize) throws IOException { MediaCodec mediaCodec = MediaCodec.createEncoderByType("audio/mp4a-latm"); - MediaFormat mediaFormat = new MediaFormat(); - mediaFormat.setString(MediaFormat.KEY_MIME, "audio/mp4a-latm"); - mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, getSampleRate()); - mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS); + MediaFormat mediaFormat = + MediaFormat.createAudioFormat("audio/mp4a-latm", getSampleRate(), CHANNELS); mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, bufferSize); mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, getBitRate()); mediaFormat.setInteger( @@ -215,12 +279,6 @@ public class AudioCodec { return Prefs.isHardCompressionEnabled(context) ? SAMPLE_RATE_WORSE : SAMPLE_RATE_BALANCED; } - private int getSampleRateIndex() { - return Prefs.isHardCompressionEnabled(context) - ? SAMPLE_RATE_INDEX_WORSE - : SAMPLE_RATE_INDEX_BALANCED; - } - private int getBitRate() { return Prefs.isHardCompressionEnabled(context) ? BIT_RATE_WORSE : BIT_RATE_BALANCED; } diff --git a/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java b/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java index 01386543a..3c1258a65 100644 --- a/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java +++ b/src/main/java/org/thoughtcrime/securesms/audio/AudioRecorder.java @@ -2,14 +2,15 @@ package org.thoughtcrime.securesms.audio; import android.content.Context; import android.net.Uri; -import android.os.ParcelFileDescriptor; import android.util.Log; import android.util.Pair; import androidx.annotation.NonNull; import chat.delta.util.ListenableFuture; import chat.delta.util.SettableFuture; +import java.io.File; import java.io.IOException; import java.util.concurrent.ExecutorService; +import java.util.concurrent.atomic.AtomicReference; import org.thoughtcrime.securesms.providers.PersistentBlobProvider; import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.ThreadUtil; @@ -24,8 +25,17 @@ public class AudioRecorder { private final Context context; private final PersistentBlobProvider blobProvider; - private AudioCodec audioCodec; - private Uri captureUri; + private final AtomicReference recordingState = new AtomicReference<>(null); + + private static class RecordingState { + final AudioCodec audioCodec; + final String outputFilePath; + + RecordingState(AudioCodec audioCodec, String outputFilePath) { + this.audioCodec = audioCodec; + this.outputFilePath = outputFilePath; + } + } public AudioRecorder(@NonNull Context context) { this.context = context; @@ -39,24 +49,22 @@ public class AudioRecorder { () -> { Log.w(TAG, "Running startRecording() + " + Thread.currentThread().getId()); try { - if (audioCodec != null) { + RecordingState currentState = recordingState.get(); + if (currentState != null) { throw new AssertionError("We can only record once at a time."); } - ParcelFileDescriptor[] fds = ParcelFileDescriptor.createPipe(); + // Create temporary file for M4A output + File outputFile = File.createTempFile("voice", ".m4a", context.getCacheDir()); + String outputFilePath = outputFile.getAbsolutePath(); - captureUri = - blobProvider.create( - context, - new ParcelFileDescriptor.AutoCloseInputStream(fds[0]), - MediaUtil.AUDIO_AAC, - "voice.aac", - null); - audioCodec = new AudioCodec(context); + AudioCodec audioCodec = new AudioCodec(context, outputFilePath); + recordingState.set(new RecordingState(audioCodec, outputFilePath)); - audioCodec.start(new ParcelFileDescriptor.AutoCloseOutputStream(fds[1])); + audioCodec.start(); } catch (IOException e) { Log.w(TAG, e); + recordingState.set(null); } }); } @@ -68,24 +76,39 @@ public class AudioRecorder { executor.execute( () -> { - if (audioCodec == null) { + RecordingState state = recordingState.getAndSet(null); + + if (state == null || state.audioCodec == null) { sendToFuture( future, new IOException("MediaRecorder was never initialized successfully!")); return; } - audioCodec.stop(); + state.audioCodec.stop(); try { - long size = MediaUtil.getMediaSize(context, captureUri); + File outputFile = new File(state.outputFilePath); + + if (!outputFile.exists()) { + sendToFuture(future, new IOException("Output file does not exist")); + return; + } + + long size = outputFile.length(); + + // Create blob using synchronous file-based method + Uri captureUri = + blobProvider.create(context, outputFile, MediaUtil.AUDIO_M4A, "voice.m4a", size); + + if (!outputFile.delete()) { + Log.d(TAG, "Temp file already moved or couldn't be deleted"); + } + sendToFuture(future, new Pair<>(captureUri, size)); } catch (IOException ioe) { - Log.w(TAG, ioe); + Log.w(TAG, "Failed to create blob from recording", ioe); sendToFuture(future, ioe); } - - audioCodec = null; - captureUri = null; }); return future; 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..51cf4c284 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallActivity.java @@ -1,207 +1,871 @@ 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.os.PowerManager; 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.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; +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 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 LinearLayout bottomLayoutContainer; + 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<>(); + + // Misc + + private PowerManager.WakeLock proximityWakeLock; - @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); + 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(); + + initializeProximityWakeLock(); + + if (!hasRequiredPermissions()) { + requestRequiredPermissions(); + return; + } + + // PiP listener + addOnPictureInPictureModeChangedListener(pipModeInfo -> { + boolean isInPipMode = pipModeInfo.isInPictureInPictureMode(); + + if (isInPipMode) { + topBar.setVisibility(View.GONE); + bottomLayoutContainer.setVisibility(View.GONE); + localVideoContainer.setVisibility(View.GONE); + incomingCallPrompt.setVisibility(View.GONE); + } else { + topBar.setVisibility(View.VISIBLE); + bottomLayoutContainer.setVisibility(View.VISIBLE); + if (viewModel != null) { + CallViewModel.CallState state = viewModel.getCallState().getValue(); + if (state != null) { + updateUIForState(state); + } + } + layoutVideos(); + } + + updateProximityWakeLock(); + }); + + initializeViewModel(); + + 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 initializeProximityWakeLock() { + PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE); + + if (powerManager == null) { + Log.w(TAG, "PowerManager not available"); + return; + } + + if (powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { + proximityWakeLock = powerManager.newWakeLock( + PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, + "DeltaChat:ProximityLock" + ); + proximityWakeLock.setReferenceCounted(false); + Log.d(TAG, "Proximity wake lock initialized"); + } else { + Log.w(TAG, "Proximity wake lock not supported on this device"); + } + } + + private void initializeViews() { + topBar = findViewById(R.id.top_bar); + bottomLayoutContainer = findViewById(R.id.call_controls_layout); + 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(); } }); - DcEventCenter eventCenter = DcHelper.getEventCenter(getApplicationContext()); - eventCenter.addObserver(DcContext.DC_EVENT_OUTGOING_CALL_ACCEPTED, this); - eventCenter.addObserver(DcContext.DC_EVENT_CALL_ENDED, this); - - Util.runOnAnyBackgroundThread(() -> { - final DcChat chat = dcContext.getChat(chatId); - Util.runOnMain(() -> Objects.requireNonNull(getSupportActionBar()).setTitle(chat.getName())); + declineButton.setOnClickListener(v -> { + if (viewModel != null) { + viewModel.declineCall(); + } + finish(); }); - 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(); + 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, endpoint -> { + updateSpeakerButton(endpoint); + updateProximityWakeLock(); + }); + + 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) { + 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); + bottomLayoutContainer.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); + bottomLayoutContainer.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); + bottomLayoutContainer.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); + bottomLayoutContainer.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); + bottomLayoutContainer.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); + bottomLayoutContainer.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; + } + + updateProximityWakeLock(); + } + + 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 updateProximityWakeLock() { + if (proximityWakeLock == null) { + return; + } + + boolean shouldHoldLock = shouldHoldLock(); + + // Acquire or release based on conditions + if (shouldHoldLock && !proximityWakeLock.isHeld()) { + proximityWakeLock.acquire(10 * 60 * 1000L); + Log.d(TAG, "Proximity wake lock acquired"); + } else if (!shouldHoldLock && proximityWakeLock.isHeld()) { + // Prevent screen from turning on immediately if phone is still near face + proximityWakeLock.release(PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY); + Log.d(TAG, "Proximity wake lock released with wait flag"); + } + } + + private boolean shouldHoldLock() { + CallViewModel.CallState state = viewModel.getCallState().getValue(); + CallEndpointCompat endpoint = viewModel.getCurrentAudioEndpoint().getValue(); + + return (state == CallViewModel.CallState.CONNECTED || + state == CallViewModel.CallState.RINGING || + state == CallViewModel.CallState.CONNECTING || + state == CallViewModel.CallState.RECONNECTING) && + endpoint != null && + endpoint.getType() == CallEndpointCompat.TYPE_EARPIECE && + !isInPictureInPictureMode(); + } + + 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); - Permissions.onRequestPermissionsResult(this, 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 onPause() { + super.onPause(); + + if (proximityWakeLock != null && proximityWakeLock.isHeld()) { + proximityWakeLock.release(); + Log.d(TAG, "Proximity wake lock released in onDestroy"); + } + } @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..504df1b05 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallCoordinator.java @@ -0,0 +1,1520 @@ +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.BuildersKt; +import kotlinx.coroutines.CoroutineScope; +import kotlinx.coroutines.CoroutineScopeKt; +import kotlinx.coroutines.Dispatchers; +import kotlinx.coroutines.flow.Flow; +import kotlinx.coroutines.flow.FlowKt; + +@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; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // 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 CallEndpointCompat preferredStartingEndpoint; + + private final Runnable outgoingRingtoneRunnable = () -> { + synchronized (CallCoordinator.this) { + if (callService != null && activeCallId != null) { + callService.startOutgoingRingtone(); + } + } + }; + + 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) { + synchronized (CallCoordinator.this) { + CallService.LocalBinder binder = (CallService.LocalBinder) service; + callService = binder.getService(); + Log.d(TAG, "Bound to CallService"); + + if (activeAccId == null || activeCallId == null) { + Log.d(TAG, "Call already ended, not initializing service"); + stopAndUnbindService(); + notificationManager.cancel(NOTIFICATION_ID_CALL); + return; + } + + 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 { + mainHandler.removeCallbacks(outgoingRingtoneRunnable); + mainHandler.postDelayed(outgoingRingtoneRunnable, 1500); + } + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + synchronized (CallCoordinator.this) { + callService = null; + isServiceBound = false; + } + Log.d(TAG, "Unbound from CallService"); + } + }; + } + + private synchronized 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 synchronized 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 synchronized String getPendingOfferSdp() { + return pendingOfferSdp; + } + + public synchronized void clearPendingOfferSdp() { + pendingOfferSdp = null; + } + + // RPC Signaling (CallService) + + public synchronized void handleOfferReady(String offerSdp) { + Log.d(TAG, "Offer SDP ready, sending via RPC"); + + if (activeAccId == null || activeCallId == null) { + Log.d(TAG, "Call ended, not handling offer"); + return; + } + + 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 synchronized 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 synchronized 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 synchronized 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 CallEndpointCompat getPreferredStartingEndpoint(boolean startsWithVideo) { + try { + Flow> endpointsFlow = + callsManager.getAvailableStartingCallEndpoints(); + + // Block and get first emission from Flow + List endpoints = + BuildersKt.runBlocking( + EmptyCoroutineContext.INSTANCE, + (scope, continuation) -> FlowKt.first(endpointsFlow, continuation) + ); + + // For audio-only calls, prefer earpiece + if (!startsWithVideo && !endpoints.isEmpty()) { + for (CallEndpointCompat endpoint : endpoints) { + if (endpoint.getType() == CallEndpointCompat.TYPE_EARPIECE) { + Log.d(TAG, "Pre-selected earpiece for audio-only call"); + return endpoint; + } + } + } + + return null; + + } catch (Exception e) { + Log.e(TAG, "Failed to get preferred starting endpoint", e); + return null; + } + } + + private synchronized 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); + + // Turns out the system Bluetooth/AudioService will trigger a second audio change + // And Telecom tries to prevent it but doesn't always success, so a delayed + // switch is still needed as a backup + 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; + } + } + + final CallEndpointCompat finalEarpieceEndpoint = earpieceEndpoint; + + mainHandler.postDelayed(() -> { + if (finalEarpieceEndpoint != null) { + Log.d(TAG, "Auto-selecting earpiece for audio-only outgoing call"); + requestAudioEndpointChange(finalEarpieceEndpoint); + } 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 + startCurrentEndpointCollection(scope); + }, 1000); + } + } else { + mainHandler.postDelayed(() -> { + startCurrentEndpointCollection(scope); + }, 500); + } + }); + } + + private synchronized 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 synchronized 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 synchronized String fetchIceServers() throws RpcException { + 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 synchronized 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); + + this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo); + + // Add to CallsManager + CallAttributesCompat callAttributes = createCallAttributes( + callerName, callId, true); + addCallToTelecom(callAttributes, callerName, callerIcon); + + // Show CallStyle notification + showIncomingCallNotification(callerName, callerIcon); + + startAndBindService(); + } + + private synchronized 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 synchronized 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 synchronized 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 synchronized 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.preferredStartingEndpoint = null; + this.isIncomingCall = false; + this.startsWithVideo = false; + this.pendingOfferSdp = null; + this.hasNotifiedBackend = false; + this.hasAutoSelectedEarpiece = false; + + mainHandler.removeCallbacks(outgoingRingtoneRunnable); + + mainHandler.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 synchronized void notifyBackendCallEnded() { + if (hasNotifiedBackend) { + Log.d(TAG, "Backend already notified of call end"); + return; + } + + if (activeAccId == null || 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 synchronized 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); + displayName.postValue(calleeName); + + // This is fine because icon is UI only and not used elsewhere + new Thread(() -> { + Icon calleeIcon = getIconFromChat(this.appContext, dcChat); + displayIcon.postValue(calleeIcon); + }).start(); + + this.preferredStartingEndpoint = getPreferredStartingEndpoint(startsWithVideo); + + startAndBindService(); + + launchCallActivity(); + } + + public synchronized 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; + + mainHandler.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, + this.preferredStartingEndpoint + ); + } + + 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 synchronized void setActiveCallViewModel(CallViewModel viewModel) { + this.activeCallViewModel = viewModel; + } + + public synchronized void clearActiveCallViewModel() { + this.activeCallViewModel = null; + } + + public synchronized boolean hasActiveCall() { + return activeCallId != null; + } + + public synchronized boolean hasOngoingCall() { + if (activeCallId == null) return false; + + PeerConnection.PeerConnectionState state = connectionState.getValue(); + return state != null && state != PeerConnection.PeerConnectionState.NEW; + } + + public synchronized boolean isIncomingCall() { + return isIncomingCall; + } + + public synchronized boolean isStartsWithVideo() { + return startsWithVideo; + } + + public synchronized 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..68b61e55d 100644 --- a/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java +++ b/src/main/java/org/thoughtcrime/securesms/calls/CallUtil.java @@ -1,62 +1,124 @@ 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 + @RequiresApi(api = Build.VERSION_CODES.M) + protected static Icon getIconFromChat(Context context, DcChat dcChat) { + Log.d(TAG, "getIconFromChat: thread=" + Thread.currentThread().getName()); + + 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) { + Log.d(TAG, "Bitmap loaded: " + bitmap.getWidth() + "x" + bitmap.getHeight() + + ", config=" + bitmap.getConfig() + ", recycled=" + bitmap.isRecycled()); + Icon icon = Icon.createWithBitmap(bitmap); + Log.d(TAG, "Icon created successfully"); + return icon; + } + + } catch (Exception e) { + Log.e(TAG, "Failed to load caller icon", e); + } + + Log.w(TAG, "Returning null icon"); + 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/connect/DcHelper.java b/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java index 281cf41a1..3df843681 100644 --- a/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java +++ b/src/main/java/org/thoughtcrime/securesms/connect/DcHelper.java @@ -12,17 +12,19 @@ import android.provider.Settings; import android.util.Log; import android.webkit.MimeTypeMap; import android.widget.Toast; - import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.core.content.FileProvider; - +import chat.delta.rpc.Rpc; +import chat.delta.rpc.RpcException; import com.b44t.messenger.DcAccounts; import com.b44t.messenger.DcChat; import com.b44t.messenger.DcContext; import com.b44t.messenger.DcLot; import com.b44t.messenger.DcMsg; - +import java.io.File; +import java.util.Date; +import java.util.HashMap; import org.thoughtcrime.securesms.ApplicationContext; import org.thoughtcrime.securesms.BuildConfig; import org.thoughtcrime.securesms.LocalHelpActivity; @@ -38,83 +40,79 @@ import org.thoughtcrime.securesms.util.MediaUtil; import org.thoughtcrime.securesms.util.ShareUtil; import org.thoughtcrime.securesms.util.Util; -import java.io.File; -import java.util.Date; -import java.util.HashMap; - -import chat.delta.rpc.Rpc; -import chat.delta.rpc.RpcException; - public class DcHelper { - private static final String TAG = DcHelper.class.getSimpleName(); + private static final String TAG = DcHelper.class.getSimpleName(); - public static final String CONFIG_CONFIGURED_ADDRESS = "configured_addr"; - public static final String CONFIG_DISPLAY_NAME = "displayname"; - public static final String CONFIG_SELF_STATUS = "selfstatus"; - public static final String CONFIG_SELF_AVATAR = "selfavatar"; - public static final String CONFIG_MVBOX_MOVE = "mvbox_move"; - public static final String CONFIG_ONLY_FETCH_MVBOX = "only_fetch_mvbox"; - public static final String CONFIG_BCC_SELF = "bcc_self"; - public static final String CONFIG_SHOW_EMAILS = "show_emails"; - public static final String CONFIG_MEDIA_QUALITY = "media_quality"; - public static final String CONFIG_PROXY_ENABLED = "proxy_enabled"; - public static final String CONFIG_PROXY_URL = "proxy_url"; - public static final String CONFIG_PRIVATE_TAG = "private_tag"; - public static final String CONFIG_STATS_SENDING = "stats_sending"; - public static final String CONFIG_STATS_ID = "stats_id"; + public static final String CONFIG_CONFIGURED_ADDRESS = "configured_addr"; + public static final String CONFIG_DISPLAY_NAME = "displayname"; + public static final String CONFIG_SELF_STATUS = "selfstatus"; + public static final String CONFIG_SELF_AVATAR = "selfavatar"; + public static final String CONFIG_MVBOX_MOVE = "mvbox_move"; + public static final String CONFIG_ONLY_FETCH_MVBOX = "only_fetch_mvbox"; + public static final String CONFIG_BCC_SELF = "bcc_self"; + public static final String CONFIG_SHOW_EMAILS = "show_emails"; + public static final String CONFIG_MEDIA_QUALITY = "media_quality"; + public static final String CONFIG_PROXY_ENABLED = "proxy_enabled"; + public static final String CONFIG_PROXY_URL = "proxy_url"; + public static final String CONFIG_PRIVATE_TAG = "private_tag"; + public static final String CONFIG_STATS_SENDING = "stats_sending"; + public static final String CONFIG_STATS_ID = "stats_id"; - public static DcContext getContext(@NonNull Context context) { - return ApplicationContext.getInstance(context).getDcContext(); - } + public static DcContext getContext(@NonNull Context context) { + return ApplicationContext.getInstance(context).getDcContext(); + } - public static Rpc getRpc(@NonNull Context context) { - return ApplicationContext.getInstance(context).getRpc(); - } + public static Rpc getRpc(@NonNull Context context) { + return ApplicationContext.getInstance(context).getRpc(); + } - public static DcAccounts getAccounts(@NonNull Context context) { - return ApplicationContext.getInstance(context).getDcAccounts(); - } + public static DcAccounts getAccounts(@NonNull Context context) { + return ApplicationContext.getInstance(context).getDcAccounts(); + } - public static DcEventCenter getEventCenter(@NonNull Context context) { - return ApplicationContext.getInstance(context).getEventCenter(); - } + public static DcEventCenter getEventCenter(@NonNull Context context) { + return ApplicationContext.getInstance(context).getEventCenter(); + } - public static NotificationCenter getNotificationCenter(@NonNull Context context) { - return ApplicationContext.getInstance(context).getNotificationCenter(); - } + public static NotificationCenter getNotificationCenter(@NonNull Context context) { + return ApplicationContext.getInstance(context).getNotificationCenter(); + } - public static boolean isConfigured(Context context) { - DcContext dcContext = getContext(context); - return dcContext.isConfigured() == 1; - } + public static boolean isConfigured(Context context) { + DcContext dcContext = getContext(context); + return dcContext.isConfigured() == 1; + } - public static int getInt(Context context, String key) { - DcContext dcContext = getContext(context); - return dcContext.getConfigInt(key); - } + public static int getInt(Context context, String key) { + DcContext dcContext = getContext(context); + return dcContext.getConfigInt(key); + } - public static String get(Context context, String key) { - DcContext dcContext = getContext(context); - return dcContext.getConfig(key); - } + public static String get(Context context, String key) { + DcContext dcContext = getContext(context); + return dcContext.getConfig(key); + } - @Deprecated public static int getInt(Context context, String key, int defaultValue) { - return getInt(context, key); - } + @Deprecated + public static int getInt(Context context, String key, int defaultValue) { + return getInt(context, key); + } - @Deprecated public static String get(Context context, String key, String defaultValue) { - return get(context, key); - } + @Deprecated + public static String get(Context context, String key, String defaultValue) { + return get(context, key); + } - public static void set(Context context, String key, String value) { - DcContext dcContext = getContext(context); - dcContext.setConfig(key, value); - } + public static void set(Context context, String key, String value) { + DcContext dcContext = getContext(context); + dcContext.setConfig(key, value); + } public static void setStockTranslations(Context context) { DcContext dcContext = getContext(context); - // the integers are defined in the core and used only here, an enum or sth. like that won't have a big benefit + // the integers are defined in the core and used only here, an enum or sth. like that won't have + // a big benefit dcContext.setStockTranslation(1, context.getString(R.string.chat_no_messages)); dcContext.setStockTranslation(2, context.getString(R.string.self)); dcContext.setStockTranslation(3, context.getString(R.string.draft)); @@ -132,8 +130,13 @@ public class DcHelper { dcContext.setStockTranslation(68, context.getString(R.string.device_talk)); dcContext.setStockTranslation(69, context.getString(R.string.saved_messages)); dcContext.setStockTranslation(70, context.getString(R.string.device_talk_explain)); - dcContext.setStockTranslation(71, context.getString(R.string.device_welcome_message, "https://i.delta.chat/#0A45953086F0C166D3BAF1D4BB2025496E4C2704&x=3KvvQZfzU4t-9u5s0PF3USGp&i=X-QDZ681F6Plz_uBu47CKdg4&s=IwE4LraLDcdPBW597wB7DBnI&a=arcanechat%40arcanechat.me&n=ArcaneChat&g=ArcaneChat+Community")); - dcContext.setStockTranslation(73, context.getString(R.string.systemmsg_subject_for_new_contact)); + dcContext.setStockTranslation( + 71, + context.getString( + R.string.device_welcome_message, + "https://i.delta.chat/#0A45953086F0C166D3BAF1D4BB2025496E4C2704&x=MVPi07rQBEmHO4FRb3brpwDe&j=n8mkKqu42WAKKUCx1bQOVh23&s=AM1YCd_2hKuiSiTEb-2177On&a=arcanechat%40arcanechat.me&n=ArcaneChat&b=ArcaneChat+Channel")); + dcContext.setStockTranslation( + 73, context.getString(R.string.systemmsg_subject_for_new_contact)); dcContext.setStockTranslation(74, context.getString(R.string.systemmsg_failed_sending_to)); dcContext.setStockTranslation(84, context.getString(R.string.configuration_failed_with_error)); dcContext.setStockTranslation(85, context.getString(R.string.devicemsg_bad_time)); @@ -160,9 +163,11 @@ public class DcHelper { dcContext.setStockTranslation(119, context.getString(R.string.qrshow_join_contact_hint)); // HACK: svg does not handle entities correctly and shows `"` as the text `quot;`. - // until that is fixed, we fix the most obvious errors (core uses encode_minimal, so this does not affect so many characters) + // until that is fixed, we fix the most obvious errors (core uses encode_minimal, so this does + // not affect so many characters) // cmp. https://github.com/deltachat/deltachat-android/issues/2187 - dcContext.setStockTranslation(120, context.getString(R.string.qrshow_join_group_hint).replace("\"", "")); + dcContext.setStockTranslation( + 120, context.getString(R.string.qrshow_join_group_hint).replace("\"", "")); dcContext.setStockTranslation(121, context.getString(R.string.connectivity_not_connected)); dcContext.setStockTranslation(124, context.getString(R.string.group_name_changed_by_you)); dcContext.setStockTranslation(125, context.getString(R.string.group_name_changed_by_other)); @@ -179,9 +184,11 @@ public class DcHelper { dcContext.setStockTranslation(136, context.getString(R.string.location_enabled_by_you)); dcContext.setStockTranslation(137, context.getString(R.string.location_enabled_by_other)); dcContext.setStockTranslation(138, context.getString(R.string.ephemeral_timer_disabled_by_you)); - dcContext.setStockTranslation(139, context.getString(R.string.ephemeral_timer_disabled_by_other)); + dcContext.setStockTranslation( + 139, context.getString(R.string.ephemeral_timer_disabled_by_other)); dcContext.setStockTranslation(140, context.getString(R.string.ephemeral_timer_seconds_by_you)); - dcContext.setStockTranslation(141, context.getString(R.string.ephemeral_timer_seconds_by_other)); + dcContext.setStockTranslation( + 141, context.getString(R.string.ephemeral_timer_seconds_by_other)); dcContext.setStockTranslation(144, context.getString(R.string.ephemeral_timer_1_hour_by_you)); dcContext.setStockTranslation(145, context.getString(R.string.ephemeral_timer_1_hour_by_other)); dcContext.setStockTranslation(146, context.getString(R.string.ephemeral_timer_1_day_by_you)); @@ -189,7 +196,8 @@ public class DcHelper { dcContext.setStockTranslation(148, context.getString(R.string.ephemeral_timer_1_week_by_you)); dcContext.setStockTranslation(149, context.getString(R.string.ephemeral_timer_1_week_by_other)); dcContext.setStockTranslation(150, context.getString(R.string.ephemeral_timer_minutes_by_you)); - dcContext.setStockTranslation(151, context.getString(R.string.ephemeral_timer_minutes_by_other)); + dcContext.setStockTranslation( + 151, context.getString(R.string.ephemeral_timer_minutes_by_other)); dcContext.setStockTranslation(152, context.getString(R.string.ephemeral_timer_hours_by_you)); dcContext.setStockTranslation(153, context.getString(R.string.ephemeral_timer_hours_by_other)); dcContext.setStockTranslation(154, context.getString(R.string.ephemeral_timer_days_by_you)); @@ -199,11 +207,14 @@ public class DcHelper { dcContext.setStockTranslation(158, context.getString(R.string.ephemeral_timer_1_year_by_you)); dcContext.setStockTranslation(159, context.getString(R.string.ephemeral_timer_1_year_by_other)); dcContext.setStockTranslation(162, context.getString(R.string.multidevice_qr_subtitle)); - dcContext.setStockTranslation(163, context.getString(R.string.multidevice_transfer_done_devicemsg)); - dcContext.setStockTranslation(170, context.getString(R.string.chat_protection_enabled_tap_to_learn_more)); + dcContext.setStockTranslation( + 163, context.getString(R.string.multidevice_transfer_done_devicemsg)); + dcContext.setStockTranslation( + 170, context.getString(R.string.chat_protection_enabled_tap_to_learn_more)); dcContext.setStockTranslation(172, context.getString(R.string.chat_new_group_hint)); dcContext.setStockTranslation(173, context.getString(R.string.member_x_added)); - dcContext.setStockTranslation(174, context.getString(R.string.invalid_unencrypted_tap_to_learn_more)); + dcContext.setStockTranslation( + 174, context.getString(R.string.invalid_unencrypted_tap_to_learn_more)); dcContext.setStockTranslation(176, context.getString(R.string.reaction_by_you)); dcContext.setStockTranslation(177, context.getString(R.string.reaction_by_other)); dcContext.setStockTranslation(178, context.getString(R.string.member_x_removed)); @@ -225,28 +236,34 @@ public class DcHelper { dcContext.setStockTranslation(234, context.getString(R.string.audio_call)); dcContext.setStockTranslation(235, context.getString(R.string.video_call)); dcContext.setStockTranslation(240, context.getString(R.string.chat_description_changed_by_you)); - dcContext.setStockTranslation(241, context.getString(R.string.chat_description_changed_by_other)); + dcContext.setStockTranslation( + 241, context.getString(R.string.chat_description_changed_by_other)); } public static File getImexDir() { - // DIRECTORY_DOCUMENTS could be used but DIRECTORY_DOWNLOADS seems to be easier accessible by the user, + // DIRECTORY_DOCUMENTS could be used but DIRECTORY_DOWNLOADS seems to be easier accessible by + // the user, // eg. "Download Managers" are nearly always installed. // CAVE: do not use DownloadManager to add the file as it is deleted on uninstall then ... return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); } // When the user shares a file to another app or opens a file in another app, it is added here. - // `HashMap` where `file` is the name of the file in the blobdir (not the user-visible filename). + // `HashMap` where `file` is the name of the file in the blobdir (not the + // user-visible filename). public static final HashMap sharedFiles = new HashMap<>(); public static void openForViewOrShare(Context activity, int msg_id, String cmd) { DcContext dcContext = getContext(activity); - if(!(activity instanceof Activity)) { + if (!(activity instanceof Activity)) { // would be nicer to accepting only Activity objects, - // however, typically in Android just Context objects are passed around (as this normally does not make a difference). - // Accepting only Activity here would force callers to cast, which would easily result in even more ugliness. - Toast.makeText(activity, "openForViewOrShare() expects an Activity object", Toast.LENGTH_LONG).show(); + // however, typically in Android just Context objects are passed around (as this normally does + // not make a difference). + // Accepting only Activity here would force callers to cast, which would easily result in even + // more ugliness. + Toast.makeText(activity, "openForViewOrShare() expects an Activity object", Toast.LENGTH_LONG) + .show(); return; } @@ -257,7 +274,9 @@ public class DcHelper { try { File file = new File(path); if (!file.exists()) { - Toast.makeText(activity, activity.getString(R.string.file_not_found, path), Toast.LENGTH_LONG).show(); + Toast.makeText( + activity, activity.getString(R.string.file_not_found, path), Toast.LENGTH_LONG) + .show(); return; } @@ -267,7 +286,14 @@ public class DcHelper { // Build a Uri that will later be passed to AttachmentsContentProvider.openFile(). // The last part needs to be `filename`, i.e. the original, user-visible name of the file, // so that the external apps show the name of the file correctly. - uri = Uri.parse("content://" + BuildConfig.APPLICATION_ID + ".attachments/" + Uri.encode(file.getName()) + "/" + Uri.encode(filename)); + uri = + Uri.parse( + "content://" + + BuildConfig.APPLICATION_ID + + ".attachments/" + + Uri.encode(file.getName()) + + "/" + + Uri.encode(filename)); // As different Android version handle uris in putExtra differently, // we also check on our own that the file was actually shared. @@ -275,7 +301,9 @@ public class DcHelper { sharedFiles.put(file.getName(), mimeType); } else { if (Build.VERSION.SDK_INT >= 24) { - uri = FileProvider.getUriForFile(activity, BuildConfig.APPLICATION_ID + ".fileprovider", file); + uri = + FileProvider.getUriForFile( + activity, BuildConfig.APPLICATION_ID + ".fileprovider", file); } else { uri = Uri.fromFile(file); } @@ -292,87 +320,108 @@ public class DcHelper { intent.putExtra(Intent.EXTRA_STREAM, uri); intent.putExtra(Intent.EXTRA_TEXT, msg.getText()); intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - activity.startActivity(Intent.createChooser(intent, activity.getString(R.string.chat_share_with_title))); + activity.startActivity( + Intent.createChooser(intent, activity.getString(R.string.chat_share_with_title))); } } catch (RuntimeException e) { - Toast.makeText(activity, String.format("%s (%s)", activity.getString(R.string.no_app_to_handle_data), mimeType), Toast.LENGTH_LONG).show(); + Toast.makeText( + activity, + String.format( + "%s (%s)", activity.getString(R.string.no_app_to_handle_data), mimeType), + Toast.LENGTH_LONG) + .show(); Log.i(TAG, "opening of external activity failed.", e); } } - public static void sendToChat(Context activity, byte[] data, String type, String fileName, String html, String subject, String text) { - Intent intent = new Intent(activity, ShareActivity.class); - intent.setAction(Intent.ACTION_SEND); - ShareUtil.setIsFromWebxdc(intent, true); + public static void sendToChat( + Context activity, + byte[] data, + String type, + String fileName, + String html, + String subject, + String text) { + Intent intent = new Intent(activity, ShareActivity.class); + intent.setAction(Intent.ACTION_SEND); + ShareUtil.setIsFromWebxdc(intent, true); - if (data != null) { - String mimeType = "application/octet-stream"; - Uri uri = PersistentBlobProvider.getInstance().create(activity, data, mimeType, fileName); - intent.setType(mimeType); - intent.putExtra(Intent.EXTRA_STREAM, uri); - ShareUtil.setSharedType(intent, type); - ShareUtil.setSharedTitle(intent, activity.getString(R.string.send_file_to, fileName)); - } else { - ShareUtil.setSharedTitle(intent, activity.getString(R.string.send_message_to)); - } + if (data != null) { + String mimeType = "application/octet-stream"; + Uri uri = PersistentBlobProvider.getInstance().create(activity, data, mimeType, fileName); + intent.setType(mimeType); + intent.putExtra(Intent.EXTRA_STREAM, uri); + ShareUtil.setSharedType(intent, type); + ShareUtil.setSharedTitle(intent, activity.getString(R.string.send_file_to, fileName)); + } else { + ShareUtil.setSharedTitle(intent, activity.getString(R.string.send_message_to)); + } - if (text != null) { - intent.putExtra(Intent.EXTRA_TEXT, text); - } + if (text != null) { + intent.putExtra(Intent.EXTRA_TEXT, text); + } - if (subject != null) { - ShareUtil.setSharedSubject(intent, subject); - } + if (subject != null) { + ShareUtil.setSharedSubject(intent, subject); + } - if (html != null) { - String mimeType = "application/octet-stream"; - Uri uri = PersistentBlobProvider.getInstance().create(activity, html.getBytes(), mimeType, "index.html"); - ShareUtil.setSharedHtml(intent, uri); - } + if (html != null) { + String mimeType = "application/octet-stream"; + Uri uri = + PersistentBlobProvider.getInstance() + .create(activity, html.getBytes(), mimeType, "index.html"); + ShareUtil.setSharedHtml(intent, uri); + } - activity.startActivity(intent); + activity.startActivity(intent); } private static void startActivity(Activity activity, Intent intent) { // request for permission to install apks on API 26+ if intent mimetype is an apk - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - "application/vnd.android.package-archive".equals(intent.getType()) && - !activity.getPackageManager().canRequestPackageInstalls()) { - activity.startActivity(new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).setData(Uri.parse(String.format("package:%s", activity.getPackageName())))); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + && "application/vnd.android.package-archive".equals(intent.getType()) + && !activity.getPackageManager().canRequestPackageInstalls()) { + activity.startActivity( + new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) + .setData(Uri.parse(String.format("package:%s", activity.getPackageName())))); return; } activity.startActivity(intent); } public static String checkMime(String path, String mimeType) { - if(mimeType == null || mimeType.equals("application/octet-stream")) { + if (mimeType == null || mimeType.equals("application/octet-stream")) { path = path.replaceAll(" ", ""); String extension = MediaUtil.getFileExtensionFromUrl(path); String newType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension); - if(newType != null) return newType; + if (newType != null) return newType; } return mimeType; } /** - * Return the path of a not-yet-existing file in the blobdir with roughly the given filename - * and the given extension. - * In many cases, since we're using setFileAndDeduplicate now, this wouldn't be necessary anymore - * and we could just create a file with a random filename, - * but there are a few usages that still need the current behavior (like `openMaps()`). + * Return the path of a not-yet-existing file in the blobdir with roughly the given filename and + * the given extension. In many cases, since we're using setFileAndDeduplicate now, this wouldn't + * be necessary anymore and we could just create a file with a random filename, but there are a + * few usages that still need the current behavior (like `openMaps()`). */ public static String getBlobdirFile(DcContext dcContext, String filename, String ext) { filename = FileUtils.sanitizeFilename(filename); ext = FileUtils.sanitizeFilename(ext); String outPath = null; for (int i = 0; i < 1000; i++) { - String test = dcContext.getBlobdir() + "/" + filename + (i == 0 ? "" : i < 100 ? "-" + i : "-" + (new Date().getTime() + i)) + ext; + String test = + dcContext.getBlobdir() + + "/" + + filename + + (i == 0 ? "" : i < 100 ? "-" + i : "-" + (new Date().getTime() + i)) + + ext; if (!new File(test).exists()) { outPath = test; break; } } - if(outPath==null) { + if (outPath == null) { // should not happen outPath = dcContext.getBlobdir() + "/" + Math.random(); } @@ -380,19 +429,22 @@ public class DcHelper { } public static String getBlobdirFile(DcContext dcContext, String path) { - String filename = path.substring(path.lastIndexOf('/')+1); // is the whole path if '/' is not found (lastIndexOf() returns -1 then) + String filename = + path.substring( + path.lastIndexOf('/') + + 1); // is the whole path if '/' is not found (lastIndexOf() returns -1 then) String ext = ""; int point = filename.indexOf('.'); - if(point!=-1) { + if (point != -1) { ext = filename.substring(point); filename = filename.substring(0, point); } return getBlobdirFile(dcContext, filename, ext); - } @NonNull - public static ThreadRecord getThreadRecord(Context context, DcLot summary, DcChat chat) { // adapted from ThreadDatabase.getCurrent() + public static ThreadRecord getThreadRecord( + Context context, DcLot summary, DcChat chat) { // adapted from ThreadDatabase.getCurrent() int chatId = chat.getId(); String body = summary.getText1(); @@ -405,14 +457,23 @@ public class DcHelper { long date = summary.getTimestamp(); int unreadCount = getContext(context).getFreshMsgCount(chatId); - return new ThreadRecord(body, recipient, date, - unreadCount, chatId, - chat.getVisibility(), chat.isSendingLocations(), chat.isMuted(), chat.isContactRequest(), summary); + return new ThreadRecord( + body, + recipient, + date, + unreadCount, + chatId, + chat.getVisibility(), + chat.isSendingLocations(), + chat.isMuted(), + chat.isContactRequest(), + summary); } public static boolean isNetworkConnected(Context context) { try { - ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + ConnectivityManager cm = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); NetworkInfo netInfo = cm.getActiveNetworkInfo(); if (netInfo != null && netInfo.isConnected()) { return true; @@ -425,105 +486,110 @@ public class DcHelper { /** * Gets a string you can show to the user with basic information about connectivity. + * * @param context * @param connectedString Usually "Connected", but when using this as the title in - * ConversationListActivity, we want to write "ArcaneChat" - * or the user's display name there instead. + * ConversationListActivity, we want to write "Delta Chat" or the user's display name there + * instead. * @return */ public static String getConnectivitySummary(Context context, String connectedString) { - int connectivity = getContext(context).getConnectivity(); - if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTED) { - return connectedString; - } else if (connectivity >= DcContext.DC_CONNECTIVITY_WORKING) { - return context.getString(R.string.connectivity_updating); - } else if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTING) { - return context.getString(R.string.connectivity_connecting); - } else { - return context.getString(R.string.connectivity_not_connected); - } + int connectivity = getContext(context).getConnectivity(); + if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTED) { + return connectedString; + } else if (connectivity >= DcContext.DC_CONNECTIVITY_WORKING) { + return context.getString(R.string.connectivity_updating); + } else if (connectivity >= DcContext.DC_CONNECTIVITY_CONNECTING) { + return context.getString(R.string.connectivity_connecting); + } else { + return context.getString(R.string.connectivity_not_connected); + } } public static void showProtectionEnabledDialog(Context context) { new AlertDialog.Builder(context) - .setMessage(context.getString(R.string.chat_protection_enabled_explanation)) - .setNeutralButton(R.string.learn_more, (d, w) -> openHelp(context, "#e2ee")) - .setPositiveButton(R.string.ok, null) - .setCancelable(true) - .show(); + .setMessage(context.getString(R.string.chat_protection_enabled_explanation)) + .setNeutralButton(R.string.learn_more, (d, w) -> openHelp(context, "#e2ee")) + .setPositiveButton(R.string.ok, null) + .setCancelable(true) + .show(); } public static void showInvalidUnencryptedDialog(Context context) { new AlertDialog.Builder(context) - .setMessage(context.getString(R.string.invalid_unencrypted_explanation)) - .setNeutralButton(R.string.learn_more, (d, w) -> openHelp(context, "#howtoe2ee")) - .setNegativeButton(R.string.qrscan_title, (d, w) -> context.startActivity(new Intent(context, QrActivity.class))) - .setPositiveButton(R.string.ok, null) - .setCancelable(true) - .show(); + .setMessage(context.getString(R.string.invalid_unencrypted_explanation)) + .setNeutralButton(R.string.learn_more, (d, w) -> openHelp(context, "#howtoe2ee")) + .setNegativeButton( + R.string.qrscan_title, + (d, w) -> context.startActivity(new Intent(context, QrActivity.class))) + .setPositiveButton(R.string.ok, null) + .setCancelable(true) + .show(); } public static void openHelp(Context context, String section) { Intent intent = new Intent(context, LocalHelpActivity.class); - if (section != null) { intent.putExtra(LocalHelpActivity.SECTION_EXTRA, section); } + if (section != null) { + intent.putExtra(LocalHelpActivity.SECTION_EXTRA, section); + } context.startActivity(intent); } - /** - * For the PGP-Contacts migration, things can go wrong. - * The migration happens when the account is setup, at which point no events can be sent yet. - * So, instead, if something goes wrong, it's returned by getLastError(). - * This function shows the error message to the user. - *

      - * A few releases after the PGP-contacts migration (which happened in 2025-05), - * we can remove this function again. - */ - public static void maybeShowMigrationError(Context context) { - try { - String lastError = DcHelper.getRpc(context).getMigrationError(DcHelper.getContext(context).getAccountId()); + /** + * For the PGP-Contacts migration, things can go wrong. The migration happens when the account is + * setup, at which point no events can be sent yet. So, instead, if something goes wrong, it's + * returned by getLastError(). This function shows the error message to the user. + * + *

      A few releases after the PGP-contacts migration (which happened in 2025-05), we can remove + * this function again. + */ + public static void maybeShowMigrationError(Context context) { + try { + String lastError = + DcHelper.getRpc(context).getMigrationError(DcHelper.getContext(context).getAccountId()); - if (lastError != null && !lastError.isEmpty()) { - Log.w(TAG, "Opening account failed, trying to share error: " + lastError); + if (lastError != null && !lastError.isEmpty()) { + Log.w(TAG, "Opening account failed, trying to share error: " + lastError); - String subject = "Delta Chat failed to update"; - String email = "delta@merlinux.eu"; + String subject = "Delta Chat failed to update"; + String email = "delta@merlinux.eu"; - new AlertDialog.Builder(context) - .setMessage(context.getString(R.string.error_x, lastError)) - .setNeutralButton(R.string.global_menu_edit_copy_desktop, (d, which) -> { - Util.writeTextToClipboard(context, lastError); - }) - .setPositiveButton(R.string.menu_send, (d, which) -> { - Intent sharingIntent = new Intent( - Intent.ACTION_SENDTO, Uri.fromParts( - "mailto", email, null - ) - ); - sharingIntent.putExtra(Intent.EXTRA_EMAIL, new String[]{email}); - sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - sharingIntent.putExtra(Intent.EXTRA_TEXT, lastError); + new AlertDialog.Builder(context) + .setMessage(context.getString(R.string.error_x, lastError)) + .setNeutralButton( + R.string.global_menu_edit_copy_desktop, + (d, which) -> { + Util.writeTextToClipboard(context, lastError); + }) + .setPositiveButton( + R.string.menu_send, + (d, which) -> { + Intent sharingIntent = + new Intent(Intent.ACTION_SENDTO, Uri.fromParts("mailto", email, null)); + sharingIntent.putExtra(Intent.EXTRA_EMAIL, new String[] {email}); + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + sharingIntent.putExtra(Intent.EXTRA_TEXT, lastError); - if (sharingIntent.resolveActivity(context.getPackageManager()) == null) { - Log.w(TAG, "No email client found to send crash report"); - sharingIntent = new Intent(Intent.ACTION_SEND); - sharingIntent.setType("text/plain"); - sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - sharingIntent.putExtra(Intent.EXTRA_TEXT, lastError); - sharingIntent.putExtra(Intent.EXTRA_EMAIL, email); - } + if (sharingIntent.resolveActivity(context.getPackageManager()) == null) { + Log.w(TAG, "No email client found to send crash report"); + sharingIntent = new Intent(Intent.ACTION_SEND); + sharingIntent.setType("text/plain"); + sharingIntent.putExtra(Intent.EXTRA_SUBJECT, subject); + sharingIntent.putExtra(Intent.EXTRA_TEXT, lastError); + sharingIntent.putExtra(Intent.EXTRA_EMAIL, email); + } - Intent chooser = - Intent.createChooser(sharingIntent, "Send using..."); - chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - chooser.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); + Intent chooser = Intent.createChooser(sharingIntent, "Send using..."); + chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + chooser.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK); - context.startActivity(chooser); - }) - .setCancelable(false) - .show(); - } - } catch (RpcException e) { - e.printStackTrace(); - } + context.startActivity(chooser); + }) + .setCancelable(false) + .show(); + } + } catch (RpcException e) { + e.printStackTrace(); } + } } 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 95fea7d16..c62b3e22b 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.def_accent)) - .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.ic_videocam_white_24dp, - 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,14 +745,6 @@ 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 removeNotification(int accountId, int chatId, int msgId) { boolean shouldCancelNotification = false; boolean removeSummary = false; diff --git a/src/main/java/org/thoughtcrime/securesms/providers/PersistentBlobProvider.java b/src/main/java/org/thoughtcrime/securesms/providers/PersistentBlobProvider.java index f15250270..7da633627 100644 --- a/src/main/java/org/thoughtcrime/securesms/providers/PersistentBlobProvider.java +++ b/src/main/java/org/thoughtcrime/securesms/providers/PersistentBlobProvider.java @@ -66,6 +66,71 @@ public class PersistentBlobProvider { private PersistentBlobProvider() {} + public Uri create( + @NonNull Context context, + @NonNull File inputFile, + @NonNull String mimeType, + @Nullable String fileName, + @Nullable Long fileSize) + throws IOException { + + if (!inputFile.exists()) { + throw new IOException("Input file does not exist: " + inputFile.getAbsolutePath()); + } + + final long id = System.currentTimeMillis(); + + if (fileName == null) { + fileName = "file." + getExtensionFromMimeType(mimeType); + } + + if (fileSize == null) { + fileSize = inputFile.length(); + } + + File destFile = getModernCacheFile(context, id); + + // Try move first + boolean moved = inputFile.renameTo(destFile); + + if (!moved) { + // Move failed, copy instead + InputStream input = null; + OutputStream output = null; + try { + input = new FileInputStream(inputFile); + output = new FileOutputStream(destFile); + Util.copy(input, output); + } finally { + if (output != null) { + try { + output.close(); + } catch (IOException e) { + Log.w(TAG, e); + } + } + if (input != null) { + try { + input.close(); + } catch (IOException e) { + Log.w(TAG, e); + } + } + } + } + + final Uri uniqueUri = + CONTENT_URI + .buildUpon() + .appendPath(mimeType) + .appendPath(fileName) + .appendEncodedPath(String.valueOf(fileSize)) + .appendEncodedPath(String.valueOf(System.currentTimeMillis())) + .build(); + + return ContentUris.withAppendedId(uniqueUri, id); + } + public Uri create( @NonNull Context context, @NonNull byte[] blobBytes, diff --git a/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java b/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java index 7bdd1d7ca..429698a57 100644 --- a/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java +++ b/src/main/java/org/thoughtcrime/securesms/util/MediaUtil.java @@ -41,6 +41,7 @@ public class MediaUtil { public static final String IMAGE_JPEG = "image/jpeg"; public static final String IMAGE_GIF = "image/gif"; public static final String AUDIO_AAC = "audio/aac"; + public static final String AUDIO_M4A = "audio/mp4"; public static final String AUDIO_UNSPECIFIED = "audio/*"; public static final String VIDEO_UNSPECIFIED = "video/*"; public static final String OCTET = "application/octet-stream"; @@ -294,6 +295,8 @@ public class MediaUtil { switch (contentType) { case AUDIO_AAC: return "aac"; + case AUDIO_M4A: + return "m4a"; case IMAGE_WEBP: return "webp"; case WEBXDC: 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..64ca88397 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/webrtc/WebRTCClient.java @@ -0,0 +1,1014 @@ +package org.thoughtcrime.securesms.webrtc; + +import android.content.Context; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; + +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. + */ +@RequiresApi(Build.VERSION_CODES.M) +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/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..beff4db6f --- /dev/null +++ b/src/main/res/layout/activity_call.xml @@ -0,0 +1,342 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +