Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c0a7259
Implement persistent foreground service to keep calls active in backg…
Dec 11, 2025
d5a0451
Make logging of warnings and errors more consistent with repository s…
Dec 21, 2025
b5ffae9
Remove .vscode and add to .gitignore
Mar 26, 2026
104e07d
Remove unnecessary logging about icon
Mar 26, 2026
e4af985
Clean up microphone permission language to be more clear
Mar 26, 2026
ef12857
Move endCallFromNotificationReceiver receiver up above companion object
Mar 26, 2026
06b7eea
Fix typo to include whole directory
Mar 26, 2026
e97c810
Incorporate refactor from PR #5957 by @rapterjet2004
Mar 26, 2026
a20b46c
Fix problem where call does not correctly get switched to PIP if you …
Mar 26, 2026
30b59c3
Fix conversation state race condition when navigating away from chat
Apr 1, 2026
b3875da
Fix call stability when backgrounding and add PIP/lifecycle diagnosti…
Apr 2, 2026
da77d27
Rewrite PIP entry to follow Android docs: auto-enter + onTopResumedAc…
Apr 2, 2026
b83c03f
Keep call alive when task-switching away from CallActivity
Apr 3, 2026
a8cdf97
feat(call): use Notification.CallStyle for ongoing call notification
Apr 11, 2026
90a903c
Fix call notification not appearing immediately and missing on subseq…
Apr 16, 2026
b3911a2
style: fix Codacy issues (line length, unused imports, trailing white…
Apr 16, 2026
9c3f4ea
Remove trailing space
Apr 16, 2026
d9b7247
Remove trailing spaces
Apr 16, 2026
63f3c00
Correctly hang up from notification in Android 12+
Apr 16, 2026
f6d1d0f
fix: remove stray closing brace in ChatActivityLeaveRoomLifecycleTest
Apr 16, 2026
c4ad285
style: remove trailing whitespace in CallActivity and CallForegroundS…
Apr 16, 2026
7ba8855
style: remove trailing whitespace in CallActivity and CallForegroundS…
Apr 16, 2026
25c1357
style: remove trailing whitespace in CallActivity
Apr 16, 2026
3b1986c
chore: restore verification-metadata.xml from master
Apr 17, 2026
8856454
fix(build): add missing dependency verification checksums and trusted…
Jun 5, 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
2 changes: 1 addition & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,6 @@
<activity
android:name=".activities.CallActivity"
android:configChanges="screenSize|smallestScreenSize|screenLayout|orientation"
android:excludeFromRecents="true"
android:launchMode="singleTask"
android:showOnLockScreen="true"
android:supportsPictureInPicture="true"
Expand Down Expand Up @@ -313,6 +312,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
305 changes: 243 additions & 62 deletions app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,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 +47,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 +95,89 @@ 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.
if (!isInPipMode
&& isPipModePossible()
&& Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
enterPipMode();
return;
}
// On API 31+: if auto-enter didn't handle it (task switch), move the
// task to back so the activity survives instead of being destroyed
// (excludeFromRecents + separate taskAffinity causes task death).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
&& !isInPictureInPictureMode()
&& isPipModePossible()) {
Log.d(TAG, "onUserLeaveHint: not PIP, moving task to back to survive task switch");
moveTaskToBack(true);
}
}

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 Down
75 changes: 46 additions & 29 deletions app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,8 @@ class ChatActivity :
private lateinit var path: String

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

private var lastHandledHighlightNonce: Long? = null
private var pendingHighlightedMessageId: Long? = null
Expand Down Expand Up @@ -416,6 +418,42 @@ class ChatActivity :

val typingParticipants = HashMap<String, TypingParticipant>()

var callStarted = false

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

isLeavingRoom = false

checkingLobbyStatus = false

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

ApplicationWideCurrentRoomHolder.getInstance().clear()

if (webSocketInstance != null && currentConversation != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
"",
sessionIdAfterRoomJoined
)
}

sessionIdAfterRoomJoined = "0"

if (state.funToCallWhenLeaveSuccessful != null) {
Log.d(TAG, "a callback action was set and is now executed because room was left successfully")
state.funToCallWhenLeaveSuccessful.invoke()
}
}

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 @@ -1471,33 +1509,7 @@ class ChatActivity :
}
}

chatViewModel.leaveRoomViewState.observe(this) { state ->
when (state) {
is ChatViewModel.LeaveRoomSuccessState -> {
logConversationInfos("leaveRoom#onNext")

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

if (webSocketInstance != null && currentConversation != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
"",
sessionIdAfterRoomJoined
)
}

sessionIdAfterRoomJoined = "0"

if (state.funToCallWhenLeaveSuccessful != null) {
Log.d(TAG, "a callback action was set and is now executed because room was left successfully")
state.funToCallWhenLeaveSuccessful.invoke()
}
}

else -> {}
}
}
chatViewModel.leaveRoomViewState.observeForever(leaveRoomObserver)

messageInputViewModel.sendChatMessageViewState.observe(this) { state ->
when (state) {
Expand Down Expand Up @@ -2750,11 +2762,13 @@ class ChatActivity :
}

if (conversationUser != null && 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 @@ -2809,6 +2823,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 @@ -2844,6 +2860,7 @@ class ChatActivity :

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

var apiVersion = 1
// FIXME Fix API checking with guests?
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
Loading