Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7e42ba5
Implement persistent foreground service to keep calls active in backg…
Dec 11, 2025
9e80d76
Make logging of warnings and errors more consistent with repository s…
Dec 21, 2025
bf8b6ac
Remove .vscode and add to .gitignore
Mar 26, 2026
9c9e5a0
Remove unnecessary logging about icon
Mar 26, 2026
c2052c5
Clean up microphone permission language to be more clear
Mar 26, 2026
3be4ee9
Move endCallFromNotificationReceiver receiver up above companion object
Mar 26, 2026
f6ddc6c
Fix typo to include whole directory
Mar 26, 2026
3b7ceaa
Incorporate refactor from PR #5957 by @rapterjet2004
Mar 26, 2026
67590fb
Fix problem where call does not correctly get switched to PIP if you …
Mar 26, 2026
a839df8
Fix conversation state race condition when navigating away from chat
Apr 1, 2026
bbac42e
Fix call stability when backgrounding and add PIP/lifecycle diagnosti…
Apr 2, 2026
81c0874
Rewrite PIP entry to follow Android docs: auto-enter + onTopResumedAc…
Apr 2, 2026
3cb58e3
Keep call alive when task-switching away from CallActivity
Apr 3, 2026
f148848
feat(call): use Notification.CallStyle for ongoing call notification
Apr 11, 2026
0ac2b62
Fix call notification not appearing immediately and missing on subseq…
Apr 16, 2026
b2128c3
style: fix Codacy issues (line length, unused imports, trailing white…
Apr 16, 2026
4a72c11
Remove trailing space
Apr 16, 2026
8e4ac98
Remove trailing spaces
Apr 16, 2026
b852451
Correctly hang up from notification in Android 12+
Apr 16, 2026
02f46fc
fix: remove stray closing brace in ChatActivityLeaveRoomLifecycleTest
Apr 16, 2026
f605aaa
style: remove trailing whitespace in CallActivity and CallForegroundS…
Apr 16, 2026
81c6e05
style: remove trailing whitespace in CallActivity and CallForegroundS…
Apr 16, 2026
bc71ccd
style: remove trailing whitespace in CallActivity
Apr 16, 2026
8e8bdfe
fix "funToCallWhenLeaveSuccess" (leftover after solving merge conflicts)
mahibi Aug 7, 2026
f560345
fix(call): prevent duplicate app instance and stale call state on cal…
Aug 20, 2026
236dd32
fix(call): don't move task to back on onUserLeaveHint
Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ target/
# Local configuration files (sdk path, etc)
local.properties
tests/local.properties
.vscode/

# Mac .DS_Store files
.DS_Store
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@
<receiver android:name=".receivers.DeclineCallReceiver" android:exported="false" />
<receiver android:name=".receivers.DismissRecordingAvailableReceiver" />
<receiver android:name=".receivers.ShareRecordingToChatReceiver" />
<receiver android:name=".receivers.EndCallReceiver" />

<service
android:name=".utils.SyncService"
Expand Down
253 changes: 201 additions & 52 deletions app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt

Large diffs are not rendered by default.

103 changes: 68 additions & 35 deletions app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.PowerManager;
import android.util.Log;
import android.util.Rational;
import android.view.View;
Expand All @@ -30,14 +29,14 @@ public abstract class CallBaseActivity extends BaseActivity {

public PictureInPictureParams.Builder mPictureInPictureParamsBuilder;
public Boolean isInPipMode = Boolean.FALSE;
long onCreateTime;


private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (isPipModePossible()) {
enterPipMode();
} else {
moveTaskToBack(true);
}
}
};
Expand All @@ -47,21 +46,25 @@ public void handleOnBackPressed() {
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

onCreateTime = System.currentTimeMillis();

requestWindowFeature(Window.FEATURE_NO_TITLE);
dismissKeyguard();
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

if (isPipModePossible()) {
mPictureInPictureParamsBuilder = new PictureInPictureParams.Builder();
Rational pipRatio = new Rational(300, 500);
mPictureInPictureParamsBuilder.setAspectRatio(pipRatio);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
mPictureInPictureParamsBuilder.setAutoEnterEnabled(true);
}
setPictureInPictureParams(mPictureInPictureParamsBuilder.build());
}

getOnBackPressedDispatcher().addCallback(this, onBackPressedCallback);
}

public void hideNavigationIfNoPipAvailable(){
public void hideNavigationIfNoPipAvailable() {
if (!isPipModePossible()) {
getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
Expand Down Expand Up @@ -91,39 +94,82 @@ void enableKeyguard() {
}
}

/**
* On API 29+, fires BEFORE onPause while the window is still fully visible.
*
* On API 29-30: enter PIP immediately (no auto-enter available).
*
* On API 31+: auto-enter handles swipe-up/home gestures. Task switching
* (left/right swipe) does NOT trigger auto-enter — we accept no PIP for
* task switch since the call stays alive in the background via the ICE
* failure guard in CallActivity.
*/
@Override
public void onTopResumedActivityChanged(boolean isTopResumedActivity) {
super.onTopResumedActivityChanged(isTopResumedActivity);
Log.d(TAG, "onTopResumedActivityChanged: isTopResumedActivity=" + isTopResumedActivity
+ " isInPictureInPictureMode=" + isInPictureInPictureMode());
if (isTopResumedActivity || isInPictureInPictureMode()
|| !isPipModePossible()
|| isChangingConfigurations()
|| isFinishing()) {
return;
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
enterPipMode();
}
}

@Override
public void onPause() {
super.onPause();
Log.d(TAG, "onPause: isInPipMode=" + isInPipMode
+ " isInPictureInPictureMode=" + isInPictureInPictureMode());
// Fallback for API 26-28 where onTopResumedActivityChanged doesn't exist.
// On API 29+, onTopResumedActivityChanged already handled this.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
&& !isInPictureInPictureMode()
&& isPipModePossible()
&& !isChangingConfigurations()
&& !isFinishing()) {
enterPipMode();
}
}

@Override
public void onStop() {
super.onStop();
if (shouldFinishOnStop()) {
finish();
}
Log.d(TAG, "onStop: isInPipMode=" + isInPipMode + " isFinishing=" + isFinishing());
}

@Override
protected void onUserLeaveHint() {
super.onUserLeaveHint();
long onUserLeaveHintTime = System.currentTimeMillis();
long diff = onUserLeaveHintTime - onCreateTime;
Log.d(TAG, "onUserLeaveHintTime - onCreateTime: " + diff);

if (diff < 3000) {
Log.d(TAG, "enterPipMode skipped");
} else {
Log.d(TAG, "onUserLeaveHint: isInPipMode=" + isInPipMode
+ " isInPictureInPictureMode=" + isInPictureInPictureMode());
// On API 26-30, enter PIP manually. On API 31+ auto-enter handles swipe-up/home, and plain
// backgrounding (e.g. task switch) keeps the activity alive on its own. Deliberately no
// moveTaskToBack here: onUserLeaveHint also fires when a transient overlay like the
// permission dialog appears at call start, which would throw the call to the background.
if (!isInPipMode
&& isPipModePossible()
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
enterPipMode();
}
}

void enterPipMode() {
Log.d(TAG, "enterPipMode: isPipModePossible=" + isPipModePossible() + " isInPipMode=" + isInPipMode);
enableKeyguard();
if (isPipModePossible()) {
Rational pipRatio = new Rational(300, 500);
mPictureInPictureParamsBuilder.setAspectRatio(pipRatio);
enterPictureInPictureMode(mPictureInPictureParamsBuilder.build());
boolean entered = enterPictureInPictureMode(mPictureInPictureParamsBuilder.build());
Log.d(TAG, "enterPictureInPictureMode returned: " + entered);
} else {
// we don't support other solutions than PIP to have a call in the background.
// If PIP is not available the call is ended when user presses the home button.
Log.d(TAG, "Activity was finished because PIP is not available.");
finish();
// If PIP is not available, move to background instead of finishing
Log.d(TAG, "PIP is not available, moving call to background.");
moveTaskToBack(true);
}
}

Expand All @@ -138,19 +184,6 @@ boolean isPipModePossible() {
return deviceHasPipFeature && isPipFeatureGranted;
}

private boolean shouldFinishOnStop() {
if (!isInPipMode) {
return false;
}

PowerManager powerManager = (PowerManager) getSystemService(Context.POWER_SERVICE);
if (powerManager == null) {
return true;
}

return powerManager.isInteractive();
}

public abstract void updateUiForPipMode();

public abstract void updateUiForNormalMode();
Expand Down
33 changes: 31 additions & 2 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ class ChatActivity :
private lateinit var path: String

var myFirstMessage: CharSequence? = null
private var isLeavingRoom: Boolean = false

private var lastHandledHighlightNonce: Long? = null
private var pendingHighlightedMessageId: Long? = null
Expand Down Expand Up @@ -422,6 +423,24 @@ class ChatActivity :

val typingParticipants = HashMap<String, TypingParticipant>()

private val leaveRoomObserver = androidx.lifecycle.Observer<ChatViewModel.ViewState> { state ->
when (state) {
is ChatViewModel.LeaveRoomSuccessState -> {
logConversationInfos("leaveRoom#onNext")

isLeavingRoom = false

if (getRoomInfoTimerHandler != null) {
getRoomInfoTimerHandler?.removeCallbacksAndMessages(null)
}

ApplicationWideCurrentRoomHolder.getInstance().clear()
}

else -> {}
}
}

private val localParticipantMessageListener = SignalingMessageReceiver.LocalParticipantMessageListener { token ->
if (CallActivity.active) {
Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...")
Expand Down Expand Up @@ -1468,6 +1487,8 @@ class ChatActivity :
}
}

chatViewModel.leaveRoomViewState.observeForever(leaveRoomObserver)

messageInputViewModel.sendChatMessageViewState.observe(this) { state ->
when (state) {
is MessageInputViewModel.SendChatMessageSuccessState -> {
Expand Down Expand Up @@ -1821,6 +1842,9 @@ class ChatActivity :

pullChatMessagesPending = false

// reset in case a previously started leave failed (success already resets this in leaveRoomObserver)
isLeavingRoom = false

webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener)
webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener)

Expand Down Expand Up @@ -2760,11 +2784,13 @@ class ChatActivity :
}

if (::conversationUser.isInitialized && isActivityNotChangingConfigurations() && isNotInCall()) {
ApplicationWideCurrentRoomHolder.getInstance().clear()
if (validSessionId()) {
if (isLeavingRoom) {
Log.d(TAG, "not leaving room (leave already in progress)")
} else if (validSessionId()) {
leaveRoom(null)
} else {
Log.d(TAG, "not leaving room (validSessionId is false)")
ApplicationWideCurrentRoomHolder.getInstance().clear()
}
} else {
Log.d(TAG, "not leaving room...")
Expand Down Expand Up @@ -2819,6 +2845,8 @@ class ChatActivity :
super.onDestroy()
logConversationInfos("onDestroy")

chatViewModel.leaveRoomViewState.removeObserver(leaveRoomObserver)

findViewById<View>(R.id.toolbar)?.setOnClickListener(null)

if (actionBar != null) {
Expand Down Expand Up @@ -2854,6 +2882,7 @@ class ChatActivity :

fun leaveRoom(functionToCallAfterLeave: (() -> Unit)?) {
logConversationInfos("leaveRoom")
isLeavingRoom = true

// Send the HPB "leave room" immediately, before waiting for the backend DELETE to
// confirm. This minimises the window in which the HPB could still consider the user
Expand Down
37 changes: 37 additions & 0 deletions app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.receivers

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.nextcloud.talk.services.CallForegroundService

class EndCallReceiver : BroadcastReceiver() {
companion object {
private val TAG = EndCallReceiver::class.simpleName
const val END_CALL_ACTION = "com.nextcloud.talk.END_CALL"
const val END_CALL_FROM_NOTIFICATION = "com.nextcloud.talk.END_CALL_FROM_NOTIFICATION"
}

override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == END_CALL_ACTION) {
Log.i(TAG, "Received end call broadcast")

// Stop the foreground service
context?.let {
CallForegroundService.stop(it)

// Send broadcast to CallActivity to end the call
val endCallIntent = Intent(END_CALL_FROM_NOTIFICATION)
endCallIntent.setPackage(context.packageName)
context.sendBroadcast(endCallIntent)
}
}
}
}
Loading