diff --git a/.gitignore b/.gitignore
index 8a43e8d13c1..300eb9de975 100644
--- a/.gitignore
+++ b/.gitignore
@@ -86,5 +86,6 @@ freeline_project_description.json
# python
**/__pycache__/
+.vscode/
/gradle/verification-keyring.gpg
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5da4d1fc8da..c799511d012 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -295,6 +295,7 @@
+
= HashMap()
private val selfPeerConnectionObserver: PeerConnectionObserver = CallActivitySelfPeerConnectionObserver()
+ private val endCallFromNotificationReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == END_CALL_FROM_NOTIFICATION) {
+ isIntentionallyLeavingCall = true
+ powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE)
+ hangup(shutDownView = true, endCallForAll = false)
+ }
+ }
+ }
+
private val callParticipantListObserver: CallParticipantList.Observer = object : CallParticipantList.Observer {
override fun onCallParticipantsChanged(
joined: Collection,
@@ -312,12 +325,16 @@ class CallActivity : CallBaseActivity() {
private var requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissionMap: Map ->
+ // Log permission results
+ Log.d(TAG, "Permission request completed with results: $permissionMap")
+
val rationaleList: MutableList = ArrayList()
val audioPermission = permissionMap[Manifest.permission.RECORD_AUDIO]
if (audioPermission != null) {
if (java.lang.Boolean.TRUE == audioPermission) {
Log.d(TAG, "Microphone permission was granted")
} else {
+ Log.d(TAG, "Microphone permission was denied")
rationaleList.add(resources.getString(R.string.nc_microphone_permission_hint))
}
}
@@ -326,6 +343,7 @@ class CallActivity : CallBaseActivity() {
if (java.lang.Boolean.TRUE == cameraPermission) {
Log.d(TAG, "Camera permission was granted")
} else {
+ Log.d(TAG, "Camera permission was denied")
rationaleList.add(resources.getString(R.string.nc_camera_permission_hint))
}
}
@@ -335,6 +353,7 @@ class CallActivity : CallBaseActivity() {
if (java.lang.Boolean.TRUE == bluetoothPermission) {
enableBluetoothManager()
} else {
+ Log.d(TAG, "Bluetooth permission was denied")
// Only ask for bluetooth when already asking to grant microphone or camera access. Asking
// for bluetooth solely is not important enough here and would most likely annoy the user.
if (rationaleList.isNotEmpty()) {
@@ -343,11 +362,30 @@ class CallActivity : CallBaseActivity() {
}
}
}
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ val notificationPermission = permissionMap[Manifest.permission.POST_NOTIFICATIONS]
+ if (notificationPermission != null) {
+ if (java.lang.Boolean.TRUE == notificationPermission) {
+ Log.d(TAG, "Notification permission was granted")
+ } else {
+ Log.w(TAG, "Notification permission was denied - this may cause call hang")
+ rationaleList.add(resources.getString(R.string.nc_notification_permission_hint))
+ }
+ }
+ }
if (rationaleList.isNotEmpty()) {
showRationaleDialogForSettings(rationaleList)
}
+ // Check if we should proceed with call despite notification permission
+ val notificationPermissionGranted = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ permissionMap[Manifest.permission.POST_NOTIFICATIONS] == true
+ } else {
+ true // Older Android versions have permission by default
+ }
+
if (!isConnectionEstablished) {
+ Log.d(TAG, "Proceeding with prepareCall() despite notification permission status")
prepareCall()
}
}
@@ -377,6 +415,21 @@ class CallActivity : CallBaseActivity() {
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
+ // Register broadcast receiver for ending call from notification
+ val endCallFilter = IntentFilter(END_CALL_FROM_NOTIFICATION)
+
+ // Use the proper utility function with ReceiverFlag for Android 14+ compatibility
+ // This receiver is for internal app use only (notification actions), so it should NOT be exported
+ registerPermissionHandlerBroadcastReceiver(
+ endCallFromNotificationReceiver,
+ endCallFilter,
+ permissionUtil!!.privateBroadcastPermission,
+ null,
+ ReceiverFlag.NotExported
+ )
+
+ Log.d(TAG, "Broadcast receiver registered successfully")
+
callViewModel = ViewModelProvider(this, viewModelFactory)[CallViewModel::class.java]
rootEglBase = EglBase.create()
@@ -762,6 +815,7 @@ class CallActivity : CallBaseActivity() {
true
}
binding!!.hangupButton.setOnClickListener {
+ isIntentionallyLeavingCall = true
hangup(shutDownView = true, endCallForAll = true)
}
binding!!.endCallPopupMenu.setOnClickListener {
@@ -776,6 +830,7 @@ class CallActivity : CallBaseActivity() {
}
}
binding!!.hangupButton.setOnClickListener {
+ isIntentionallyLeavingCall = true
hangup(shutDownView = true, endCallForAll = false)
}
binding!!.endCallPopupMenu.setOnClickListener {
@@ -1003,6 +1058,18 @@ class CallActivity : CallBaseActivity() {
}
}
+ // Check notification permission for Android 13+ (API 33+)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ if (permissionUtil!!.isPostNotificationsPermissionGranted()) {
+ Log.d(TAG, "Notification permission already granted")
+ } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) {
+ permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS)
+ rationaleList.add(resources.getString(R.string.nc_notification_permission_hint))
+ } else {
+ permissionsToRequest.add(Manifest.permission.POST_NOTIFICATIONS)
+ }
+ }
+
if (permissionsToRequest.isNotEmpty()) {
if (rationaleList.isNotEmpty()) {
showRationaleDialog(permissionsToRequest, rationaleList)
@@ -1011,30 +1078,68 @@ class CallActivity : CallBaseActivity() {
}
} else if (!isConnectionEstablished) {
prepareCall()
+ } else {
+ // All permissions granted but connection not established
+ Log.d(TAG, "All permissions granted but connection not established, proceeding with prepareCall()")
+ prepareCall()
}
}
private fun prepareCall() {
+ Log.d(TAG, "prepareCall() started")
basicInitialization()
initViews()
// updateSelfVideoViewPosition(true)
checkRecordingConsentAndInitiateCall()
+ // Start foreground service only if we have notification permission (for Android 13+)
+ // or if we're on older Android versions where permission is automatically granted
if (permissionUtil!!.isMicrophonePermissionGranted()) {
- CallForegroundService.start(applicationContext, conversationName, intent.extras)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ // Android 13+ requires explicit notification permission
+ if (permissionUtil!!.isPostNotificationsPermissionGranted()) {
+ Log.d(TAG, "Starting foreground service with notification permission")
+ CallForegroundService.start(applicationContext, conversationName, intent.extras)
+ } else {
+ Log.w(
+ TAG,
+ "Notification permission not granted - call will work but without persistent notification"
+ )
+ // Show warning to user that notification permission is missing (10 seconds)
+ Snackbar.make(
+ binding!!.root,
+ resources.getString(R.string.nc_notification_permission_hint),
+ SEC_10
+ ).show()
+ }
+ } else {
+ // Android 12 and below - notification permission is automatically granted
+ Log.d(TAG, "Starting foreground service (Android 12-)")
+ CallForegroundService.start(applicationContext, conversationName, intent.extras)
+ }
+
if (!microphoneOn) {
onMicrophoneClick()
}
+ } else {
+ Log.w(TAG, "Microphone permission not granted - skipping foreground service start")
}
+ // The call should not hang just because notification permission was denied
+ // Always proceed with call setup regardless of notification permission
+ Log.d(TAG, "Ensuring call proceeds even without notification permission")
+
if (isVoiceOnlyCall) {
binding!!.selfVideoViewWrapper.visibility = View.GONE
} else if (permissionUtil!!.isCameraPermissionGranted()) {
+ Log.d(TAG, "Camera permission granted, showing video")
binding!!.selfVideoViewWrapper.visibility = View.VISIBLE
onCameraClick()
if (cameraEnumerator!!.deviceNames.isEmpty()) {
binding!!.cameraButton.visibility = View.GONE
}
+ } else {
+ Log.w(TAG, "Camera permission not granted, hiding video")
}
}
@@ -1051,13 +1156,27 @@ class CallActivity : CallBaseActivity() {
for (rationale in rationaleList) {
rationalesWithLineBreaks.append(rationale).append("\n\n")
}
+
val dialogBuilder = MaterialAlertDialogBuilder(this)
.setTitle(R.string.nc_permissions_rationale_dialog_title)
.setMessage(rationalesWithLineBreaks)
.setPositiveButton(R.string.nc_permissions_ask) { _, _ ->
+ Log.d(TAG, "User clicked 'Ask' for permissions")
requestPermissionLauncher.launch(permissionsToRequest.toTypedArray())
}
- .setNegativeButton(R.string.nc_common_dismiss, null)
+ .setNegativeButton(R.string.nc_common_dismiss) { _, _ ->
+ // Log when user dismisses permission request
+ Log.w(TAG, "User dismissed permission request for: $permissionsToRequest")
+ if (permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)) {
+ Log.w(TAG, "Notification permission specifically dismissed - proceeding with call anyway")
+ }
+
+ // Proceed with call even when notification permission is dismissed
+ if (!isConnectionEstablished) {
+ Log.d(TAG, "Proceeding with prepareCall() after dismissing notification permission")
+ prepareCall()
+ }
+ }
viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder)
dialogBuilder.show()
}
@@ -1327,6 +1446,10 @@ class CallActivity : CallBaseActivity() {
}
public override fun onDestroy() {
+ Log.d(TAG, "onDestroy called")
+ Log.d(TAG, "onDestroy: isIntentionallyLeavingCall=$isIntentionallyLeavingCall")
+ Log.d(TAG, "onDestroy: currentCallStatus=$currentCallStatus")
+
if (signalingMessageReceiver != null) {
signalingMessageReceiver!!.removeListener(localParticipantMessageListener)
signalingMessageReceiver!!.removeListener(offerMessageListener)
@@ -1339,10 +1462,29 @@ class CallActivity : CallBaseActivity() {
Log.d(TAG, "localStream is null")
}
if (currentCallStatus !== CallStatus.LEAVING) {
- hangup(true, false)
+ // Only hangup if we're intentionally leaving
+ if (isIntentionallyLeavingCall) {
+ hangup(true, false)
+ }
+ }
+ // Only stop the foreground service if we're actually leaving the call
+ if (isIntentionallyLeavingCall || currentCallStatus === CallStatus.LEAVING) {
+ CallForegroundService.stop(applicationContext)
}
- CallForegroundService.stop(applicationContext)
+
+ Log.d(TAG, "onDestroy: Releasing proximity sensor - updating to IDLE state")
powerManagerUtils!!.updatePhoneState(PowerManagerUtils.PhoneState.IDLE)
+ Log.d(TAG, "onDestroy: Proximity sensor released")
+
+ // Unregister receiver
+ try {
+ Log.d(TAG, "Unregistering endCallFromNotificationReceiver...")
+ unregisterReceiver(endCallFromNotificationReceiver)
+ Log.d(TAG, "endCallFromNotificationReceiver unregistered successfully")
+ } catch (e: IllegalArgumentException) {
+ Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e)
+ }
+
super.onDestroy()
}
@@ -1916,7 +2058,10 @@ class CallActivity : CallBaseActivity() {
}
private fun hangup(shutDownView: Boolean, endCallForAll: Boolean) {
- Log.d(TAG, "hangup! shutDownView=$shutDownView")
+ Log.d(TAG, "hangup! shutDownView=$shutDownView, endCallForAll=$endCallForAll")
+ Log.d(TAG, "hangup! isIntentionallyLeavingCall=$isIntentionallyLeavingCall")
+ Log.d(TAG, "hangup! powerManagerUtils state before cleanup: ${powerManagerUtils != null}")
+
if (shutDownView) {
setCallState(CallStatus.LEAVING)
}
@@ -3080,6 +3225,7 @@ class CallActivity : CallBaseActivity() {
private const val CALLING_TIMEOUT: Long = 45000
private const val PULSE_ANIMATION_DURATION: Int = 310
+ private const val SEC_10 = 10000
private const val DELAY_ON_ERROR_STOP_THRESHOLD: Int = 16
diff --git a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java
index 1063c6c844d..e895fe409a9 100644
--- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java
+++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java
@@ -34,11 +34,14 @@ public abstract class CallBaseActivity extends BaseActivity {
long onCreateTime;
- private OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
+ private final OnBackPressedCallback onBackPressedCallback = new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
if (isPipModePossible()) {
enterPipMode();
+ } else {
+ // Move the task to background instead of finishing
+ moveTaskToBack(true);
}
}
};
@@ -62,7 +65,7 @@ public void onCreate(Bundle savedInstanceState) {
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 |
@@ -98,7 +101,10 @@ void enableKeyguard() {
@Override
public void onStop() {
super.onStop();
- if (shouldFinishOnStop()) {
+ // Don't automatically finish when going to background
+ // Only finish if explicitly leaving the call
+ if (shouldFinishOnStop() && !isChangingConfigurations() && isFinishing()) {
+ // Check if we're really leaving the call or just backgrounding
finish();
}
}
@@ -124,22 +130,21 @@ void enterPipMode() {
mPictureInPictureParamsBuilder.setAspectRatio(pipRatio);
enterPictureInPictureMode(mPictureInPictureParamsBuilder.build());
} 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);
}
}
boolean isPipModePossible() {
- boolean deviceHasPipFeature = getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
-
- AppOpsManager appOpsManager = (AppOpsManager) getSystemService(Context.APP_OPS_SERVICE);
- boolean isPipFeatureGranted = appOpsManager.checkOpNoThrow(
- AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
- android.os.Process.myUid(),
- BuildConfig.APPLICATION_ID) == AppOpsManager.MODE_ALLOWED;
- return deviceHasPipFeature && isPipFeatureGranted;
+ boolean deviceHasPipFeature = getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE);
+
+ AppOpsManager appOpsManager = (AppOpsManager) getSystemService(Context.APP_OPS_SERVICE);
+ boolean isPipFeatureGranted = appOpsManager.checkOpNoThrow(
+ AppOpsManager.OPSTR_PICTURE_IN_PICTURE,
+ android.os.Process.myUid(),
+ BuildConfig.APPLICATION_ID) == AppOpsManager.MODE_ALLOWED;
+ return deviceHasPipFeature && isPipFeatureGranted;
}
private boolean shouldFinishOnStop() {
diff --git a/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt
new file mode 100644
index 00000000000..896d35ece00
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt
@@ -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)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
index f1dd6e7016e..7e522b73215 100644
--- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
+++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
@@ -22,6 +22,8 @@ import androidx.core.content.ContextCompat
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.CallActivity
import com.nextcloud.talk.application.NextcloudTalkApplication
+import com.nextcloud.talk.receivers.EndCallReceiver
+import com.nextcloud.talk.receivers.EndCallReceiver.Companion.END_CALL_ACTION
import com.nextcloud.talk.utils.NotificationUtils
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO
@@ -60,6 +62,20 @@ class CallForegroundService : Service() {
?: getString(R.string.nc_call_ongoing_notification_default_title)
val pendingIntent = createContentIntent(callExtras)
+ val returnToCallAction = NotificationCompat.Action.Builder(
+ R.drawable.ic_call_white_24dp,
+ getString(R.string.nc_call_ongoing_notification_return_action),
+ pendingIntent
+ ).build()
+
+ val endCallPendingIntent = createEndCallIntent(callExtras)
+
+ val endCallAction = NotificationCompat.Action.Builder(
+ R.drawable.ic_baseline_close_24,
+ getString(R.string.nc_call_ongoing_notification_end_action),
+ endCallPendingIntent
+ ).build()
+
return NotificationCompat.Builder(this, channelId)
.setContentTitle(contentTitle)
.setContentText(getString(R.string.nc_call_ongoing_notification_content))
@@ -71,6 +87,9 @@ class CallForegroundService : Service() {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pendingIntent)
.setShowWhen(false)
+ .addAction(returnToCallAction)
+ .addAction(endCallAction)
+ .setAutoCancel(false)
.build()
}
@@ -81,7 +100,10 @@ class CallForegroundService : Service() {
private fun createContentIntent(callExtras: Bundle?): PendingIntent {
val intent = Intent(this, CallActivity::class.java).apply {
- flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ flags =
+ Intent.FLAG_ACTIVITY_SINGLE_TOP or
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
+ Intent.FLAG_ACTIVITY_REORDER_TO_FRONT
callExtras?.let { putExtras(Bundle(it)) }
}
@@ -89,6 +111,16 @@ class CallForegroundService : Service() {
return PendingIntent.getActivity(this, 0, intent, flags)
}
+ private fun createEndCallIntent(callExtras: Bundle?): PendingIntent {
+ val intent = Intent(this, EndCallReceiver::class.java).apply {
+ action = END_CALL_ACTION
+ callExtras?.let { putExtras(Bundle(it)) }
+ }
+
+ val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ return PendingIntent.getBroadcast(this, 1, intent, flags)
+ }
+
private fun resolveForegroundServiceType(callExtras: Bundle?): Int {
var serviceType = 0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 869ba9ee80c..967df10b3d8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -320,6 +320,7 @@ How to translate with transifex:
To enable video communication please grant \"Camera\" permission.
To enable voice communication please grant \"Microphone\" permission.
To enable bluetooth speakers please grant \"Nearby devices\" permission.
+ To show call notifications and keep calls active in the background, please grant \"Notifications\" permission.
Microphone is enabled and audio is recording
@@ -341,6 +342,8 @@ How to translate with transifex:
You missed a call from %s
Call in progress
Tap to return to your call.
+ Return to call
+ End call
Open picture-in-picture mode
Change audio output
Toggle camera