Skip to content

feat(calls): end-to-end encrypted calls - #6793

Open
AndyScherzinger wants to merge 3 commits into
masterfrom
feat/e2ee-calls
Open

AndyScherzinger wants to merge 3 commits into
masterfrom
feat/e2ee-calls

Conversation

@AndyScherzinger

@AndyScherzinger AndyScherzinger commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Android side of end-to-end encrypted calls, compatible with the web client (nextcloud/spreed#14005, nextcloud/spreed#14098) and the iOS client (nextcloud/talk-ios#2735). Port of the web client's e2ee/encryption.js: every participant sends its own frame key to each other session through a pairwise Olm session over signaling messages, ratchets it when someone joins and rotates it when someone leaves. Like web and iOS this only works through the high-performance backend with MCU, forces VP8, and ends the call instead of sending media unencrypted when encryption cannot be set up.

The native pieces come from two other PRs and are not published yet, so CallEncryptionFactory still reports encryption as unavailable. Until then an E2EE-enabled server gets "could not be set up" and the call ends; nothing is sent in plain text.

🖼️ Screenshots

No UI changes beyond two new toasts; the old "calling is not supported because end-to-end encryption is enabled" notice and notification are gone.

🚧 TODO

  • WebRTC bump 132 → 155.8059.0 with the frame encryption (own PR, needs feat: Add Android bindings for the frame encryption nextcloud-releases/talk-clients-webrtc#35 merged and the aar published via nextcloud-deps/android-talk-webrtc; compiles and passes the unit tests locally against the CI-built aar)
  • vodozemac aar published (generated Kotlin bindings, release tag, JitPack)
  • Adapters TalkKeyRing → FrameCrypto and VodozemacAccount → OlmCrypto in CallEncryptionFactory, IS_AVAILABLE = true, dependencies in build.gradle.kts
  • Device tests against the web client: join, camera on mid-call, participant join/leave (ratchet/rotate), screen share, reconnect, recording
  • Third-party notices for vodozemac, JNA and the Jitsi-derived module

🏁 Checklist

  • ⛑️ Tests (unit and/or integration) are included or not needed
  • 🔖 Capability is checked or not needed
  • 🔙 Backport requests are created or not needed: /backport to stable-xx.x
  • 📅 Milestone is set
  • 🌸 PR title is meaningful (if it should be in the changelog: is it meaningful to users?)

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

@github-actions

github-actions Bot commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

📱 QA build

Download app-qa-debug.apk
QR code Open the QR code for this download
Commit 5c4fa8f
Version 6793
Available until 7 days after this build

The QA build installs alongside a released Nextcloud app, so you can keep
using your existing install while testing.

Downloading the file requires a GitHub account, so open this link on the
device you want to test on, or transfer the APK to it.

Port of the web client's e2ee/encryption.js (and the iOS port of it):
every participant generates a random frame key and sends it to each
other session through a pairwise Olm session over signaling messages,
ratchets it when someone joins and rotates it when someone leaves.

The Olm account and the frame key rings are behind two small
interfaces, so the protocol runs and is tested on the JVM; the
vodozemac and WebRTC backed implementations come with their AARs.

Signaling routes "message" payloads of the "encryption.*" types to a
new listener, the payload model gains the key exchange fields, and the
capability check now requires both the signaling feature and the
server config.

Assisted-by: Claude Code:claude-fable-5-1
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
Calls announce the "encryption" feature to the signaling server, the
key exchange lives for one joined room and signaling session, and the
peer connections attach an encryptor to every sender of the publisher
and a decryptor to every receiver of a subscriber, preferring VP8 as
the only codec the frame encryption handles.

When the server requires encrypted calls but there is no
high-performance backend with MCU, or the key exchange could not be set
up, the call ends instead of sending media unencrypted. The old
"calls not supported" checks and notification are removed.

The factory creating the key exchange still reports encryption as
unavailable until the WebRTC build with the frame encryption and the
vodozemac bindings are published.

Assisted-by: Claude Code:claude-fable-5-1
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
The guard clauses mirror the web client's code; detekt's ReturnCount
would otherwise push the project over its issue threshold.

Assisted-by: Claude Code:claude-fable-5-1
Signed-off-by: Andy Scherzinger <info@andy-scherzinger.de>
@github-actions

github-actions Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Codacy

Lint

TypemasterPR
Warnings138138
Errors1815

SpotBugs

CategoryBaseNew
Bad practice77
Correctness1111
Dodgy code4040
Internationalization33
Malicious code vulnerability33
Performance88
Security1111
Total8383

@rapterjet2004
rapterjet2004 marked this pull request as ready for review October 7, 2026 16:10
@coderabbitai

coderabbitai Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

📝 Walkthrough

Walkthrough

The changes add encryption signaling models, Olm and frame-crypto interfaces, and a per-session key exchange. WebSocket signaling conditionally advertises encryption and manages exchange lifecycle. CallActivity connects available key rings to peer connections, which attach frame cryptors and select VP8 when key rings are present. Call-start and incoming-call paths no longer block calls based on the earlier unsupported-encryption check. The factory currently marks frame encryption unavailable.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to 5c4fa

Encrypted calls can, in a narrow race, set up media connections without encryption instead of ending the call. Native key resources also accumulate as participants leave. Close the fail-open path before merging; the other issues are small fixes.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.66% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 158 functions across 21 files. (1 skipped… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely identifies the main change: adding end-to-end encrypted calls.
Description check ✅ Passed The description explains the implementation, compatibility targets, security behavior, pending dependencies, tests, screenshots, TODO items, and checklist status. The unchecked milestone and backport …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 12.66% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 158 functions across 21 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 063971e9-7fa2-48d7-b3da-fdac260b302d
📥 Commits

Reviewing files that changed from the base of the PR and between 91ad981 and 5c4fa8f.

📒 Files selected for processing (25)
  • app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt
  • app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt
  • app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryptionFactory.kt
  • app/src/main/java/com/nextcloud/talk/call/e2ee/EncryptionMessage.kt
  • app/src/main/java/com/nextcloud/talk/call/e2ee/FrameCrypto.kt
  • app/src/main/java/com/nextcloud/talk/call/e2ee/OlmCrypto.kt
  • app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt
  • app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
  • app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
  • app/src/main/java/com/nextcloud/talk/jobs/NotificationWorker.kt
  • app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDto.kt
  • app/src/main/java/com/nextcloud/talk/models/json/signaling/OlmMessageDto.kt
  • app/src/main/java/com/nextcloud/talk/signaling/EncryptionMessageNotifier.kt
  • app/src/main/java/com/nextcloud/talk/signaling/SignalingMessageReceiver.kt
  • app/src/main/java/com/nextcloud/talk/utils/CapabilitiesUtil.kt
  • app/src/main/java/com/nextcloud/talk/webrtc/PeerConnectionWrapper.java
  • app/src/main/java/com/nextcloud/talk/webrtc/WebSocketConnectionHelper.java
  • app/src/main/java/com/nextcloud/talk/webrtc/WebSocketInstance.kt
  • app/src/main/res/values/strings.xml
  • app/src/test/java/com/nextcloud/talk/call/e2ee/CallEncryptionTest.kt
  • app/src/test/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDtoEncryptionTest.kt
  • app/src/test/java/com/nextcloud/talk/signaling/SignalingMessageReceiverEncryptionTest.kt
  • app/src/test/java/com/nextcloud/talk/utils/CapabilitiesUtilCallEncryptionTest.kt
  • app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperEncryptionTest.kt
  • app/src/test/java/com/nextcloud/talk/webrtc/PeerConnectionWrapperTest.kt
💤 Files with no reviewable changes (3)
  • app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt
  • app/src/main/java/com/nextcloud/talk/callnotification/CallNotificationActivity.kt
  • app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt

Included review availability: This review used your included allowance. Your plan provides up to 4 included reviews per hour; 3 remain after this review.

hasMCU: Boolean,
sessionId: String?
): Pair<FrameKeyRing?, FrameKeyRing?> {
val callEncryption = callEncryption() ?: return null to null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Fail closed in keyRingsFor when encryption is required.

When isCallEndToEndEncryptionEnabled is true and callEncryption() returns null, keyRingsFor returns null to null. PeerConnectionWrapper then attaches no frame encryptor and prefers H264. The publisher guard ensureCallEncryption() in handleCallParticipantsChanged runs on a different thread than this creation code.

WebSocketInstance.closeCallEncryption() sets callEncryption to null whenever the hello response carries a new session or the client leaves the room. If that happens between the guard and the wrapper creation, the MCU publisher is created without an encryptor and sends plaintext frames. Subscriber wrappers created from offerMessageListener also skip the guard.

Treat a missing key exchange as a fatal error here, not as an unencrypted call.

🔒️ Proposed fix
-        val callEncryption = callEncryption() ?: return null to null
+        if (!isCallEndToEndEncryptionEnabled) {
+            return null to null
+        }
+        val callEncryption = callEncryption()
+            ?: throw IllegalStateException("Call has to be end-to-end encrypted but has no key exchange")

Throwing is one option. Alternatively, return a nullable result and have getOrCreatePeerConnectionWrapperForSessionIdAndType call failCallEncryption(R.string.nc_call_e2ee_setup_failed) without creating the wrapper.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val callEncryption = callEncryption() ?: return null to null
if (!isCallEndToEndEncryptionEnabled) {
return null to null
}
val callEncryption = callEncryption()
?: throw IllegalStateException("Call has to be end-to-end encrypted but has no key exchange")

Comment on lines +104 to +109
synchronized(remoteKeyRings) {
for (sessionId in sessionIds) {
sessions.remove(sessionId)
remoteKeyRings.remove(sessionId)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Dispose the key rings of sessions that leave.

usersLeft removes each FrameKeyRing from remoteKeyRings but never calls dispose(). Only close() disposes key rings. Each participant that leaves therefore leaks one key ring for the rest of the call. In production that key ring is backed by native WebRTC memory. The FrameKeyRing contract says encryptors and decryptors keep working after dispose(), so disposing here is safe for decryptors that are still attached.

♻️ Proposed fix
--- "a/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt"
+++ "b/app/src/main/java/com/nextcloud/talk/call/e2ee/CallEncryption.kt"
@@ -101,12 +101,12 @@
         scope.launch {
             if (isClosed) return@launch
 
             synchronized(remoteKeyRings) {
                 for (sessionId in sessionIds) {
                     sessions.remove(sessionId)
-                    remoteKeyRings.remove(sessionId)
+                    remoteKeyRings.remove(sessionId)?.dispose()
                 }
             }
 
             // A new key keeps participants that left from decrypting what is sent from now on
             rotateJob = debounce(rotateJob) { rotateKey() }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
synchronized(remoteKeyRings) {
for (sessionId in sessionIds) {
sessions.remove(sessionId)
remoteKeyRings.remove(sessionId)
}
}
synchronized(remoteKeyRings) {
for (sessionId in sessionIds) {
sessions.remove(sessionId)
remoteKeyRings.remove(sessionId)?.dispose()
}
}


@Parcelize
@JsonObject
@TypeParceler<Any?, AnyParceler>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
rg -n -C3 'AnyParceler' --type=kotlin --type=java
rg -n -C3 'NCSignalingMessageDto|NCMessagePayloadDto' --type=kotlin --type=java | rg -n 'putParcelable|putExtra|writeToParcel|Bundle'

Repository: nextcloud/talk-android

Length of output: 10435


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- AnyParceler.kt ---'
nl -ba app/src/main/java/com/nextcloud/talk/models/json/AnyParceler.kt
printf '%s\n' '--- NCMessagePayloadDto.kt ---'
nl -ba app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDto.kt
printf '%s\n' '--- NCSignalingMessageDto references ---'
rg -n -C4 -F -- 'NCSignalingMessageDto' app/src/main || true
printf '%s\n' '--- NCMessagePayloadDto parceling references ---'
rg -n -C4 -F -- 'NCMessagePayloadDto' app/src/main || true
printf '%s\n' '--- Parcelable transport calls for signaling DTOs ---'
rg -n -C3 'putParcelable|putParcelableArrayList|putExtra|writeToParcel|Bundle' app/src/main --glob '*.kt' --glob '*.java' | rg -n 'signaling|Signaling|Payload|Dto|Bundle|putParcelable|putExtra|writeToParcel' || true
printf '%s\n' '--- base-to-head diff for affected files ---'
git diff --no-ext-diff --unified=20 91ad98153998387c41ed8994e97da7a9cf2c7f0b 5c4fa8fab233345cc924a5399f1aa95b6002ff45 -- app/src/main/java/com/nextcloud/talk/models/json/AnyParceler.kt app/src/main/java/com/nextcloud/talk/models/json/signaling/NCMessagePayloadDto.kt app/src/main/java/com/nextcloud/talk/models/json/signaling/NCSignalingMessageDto.kt

Repository: nextcloud/talk-android

Length of output: 41628


Pass the value to Parcel.writeValue.

NCMessagePayloadDto maps key: Any? to AnyParceler. AnyParceler.write ignores the value and passes the Parcel object to writeValue, which can fail when a caller parcels the DTO. The repository does not establish a current Bundle or Intent path for these signaling DTOs, so avoid claiming that an existing app workflow always crashes.

🐛 Suggested fix in AnyParceler.kt
     override fun Any?.write(parcel: Parcel, flags: Int) {
-        parcel.writeValue(parcel)
+        parcel.writeValue(this)
     }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant