diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 00000000000..c5f3f6b9c75
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,3 @@
+{
+ "java.configuration.updateBuildConfiguration": "interactive"
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index ca34e3f2921..bc96ae54ea7 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -280,6 +280,7 @@
+
->
+ // 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))
}
}
@@ -333,6 +340,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))
}
}
@@ -342,6 +350,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()) {
@@ -350,11 +359,32 @@ 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
+ }
+
+ Log.d(TAG, "DEBUGNotification permission granted: $notificationPermissionGranted, isConnectionEstablished: $isConnectionEstablished")
+
if (!isConnectionEstablished) {
+ Log.d(TAG, "Proceeding with prepareCall() despite notification permission status")
prepareCall()
}
}
@@ -383,6 +413,21 @@ class CallActivity : CallBaseActivity() {
Log.d(TAG, "onCreate")
super.onCreate(savedInstanceState)
sharedApplication!!.componentApplication.inject(this)
+
+ // Register broadcast receiver for ending call from notification
+ val endCallFilter = IntentFilter("com.nextcloud.talk.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]
@@ -782,6 +827,7 @@ class CallActivity : CallBaseActivity() {
true
}
binding!!.hangupButton.setOnClickListener {
+ isIntentionallyLeavingCall = true
hangup(shutDownView = true, endCallForAll = true)
}
binding!!.endCallPopupMenu.setOnClickListener {
@@ -796,6 +842,7 @@ class CallActivity : CallBaseActivity() {
}
}
binding!!.hangupButton.setOnClickListener {
+ isIntentionallyLeavingCall = true
hangup(shutDownView = true, endCallForAll = false)
}
binding!!.endCallPopupMenu.setOnClickListener {
@@ -1022,6 +1069,18 @@ class CallActivity : CallBaseActivity() {
permissionsToRequest.add(Manifest.permission.BLUETOOTH_CONNECT)
}
}
+
+ // 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()) {
@@ -1031,30 +1090,65 @@ 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),
+ 10000
+ ).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")
}
}
@@ -1071,13 +1165,31 @@ class CallActivity : CallBaseActivity() {
for (rationale in rationaleList) {
rationalesWithLineBreaks.append(rationale).append("\n\n")
}
+
+ // Log when permission rationale dialog is shown
+ Log.d(TAG, "Showing permission rationale dialog for permissions: $permissionsToRequest")
+ Log.d(TAG, "Rationale includes notification permission: ${permissionsToRequest.contains(Manifest.permission.POST_NOTIFICATIONS)}")
+
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()
}
@@ -1401,6 +1513,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)
@@ -1413,10 +1529,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: Exception) {
+ Log.w(TAG, "Failed to unregister endCallFromNotificationReceiver", e)
+ }
+
super.onDestroy()
}
@@ -1989,7 +2124,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)
}
@@ -3163,4 +3301,18 @@ class CallActivity : CallBaseActivity() {
private const val SESSION_ID_PREFFIX_END: Int = 4
}
+
+ // Broadcast receiver to handle end call from notification
+ private val endCallFromNotificationReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ if (intent.action == "com.nextcloud.talk.END_CALL_FROM_NOTIFICATION") {
+ Log.d(TAG, "Received end call from notification broadcast")
+ Log.d(TAG, "endCallFromNotificationReceiver: Setting isIntentionallyLeavingCall=true")
+ isIntentionallyLeavingCall = true
+ Log.d(TAG, "endCallFromNotificationReceiver: Releasing proximity sensor before hangup")
+ powerManagerUtils?.updatePhoneState(PowerManagerUtils.PhoneState.IDLE)
+ hangup(shutDownView = true, endCallForAll = false)
+ }
+ }
+ }
}
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 45fd67a5872..28f20ba6d8e 100644
--- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java
+++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java
@@ -39,6 +39,9 @@ public abstract class CallBaseActivity extends BaseActivity {
public void handleOnBackPressed() {
if (isPipModePossible()) {
enterPipMode();
+ } else {
+ // Move the task to background instead of finishing
+ moveTaskToBack(true);
}
}
};
@@ -98,8 +101,13 @@ void enableKeyguard() {
@Override
public void onStop() {
super.onStop();
- if (shouldFinishOnStop()) {
- finish();
+ // Don't automatically finish when going to background
+ // Only finish if explicitly leaving the call
+ if (shouldFinishOnStop() && !isChangingConfigurations()) {
+ // Check if we're really leaving the call or just backgrounding
+ if (isFinishing()) {
+ finish();
+ }
}
}
@@ -124,10 +132,9 @@ 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);
}
}
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..4d6f23945b2
--- /dev/null
+++ b/app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt
@@ -0,0 +1,36 @@
+/*
+ * 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.activities.CallActivity
+import com.nextcloud.talk.services.CallForegroundService
+
+class EndCallReceiver : BroadcastReceiver() {
+ companion object {
+ private const val TAG = "EndCallReceiver"
+ }
+
+ override fun onReceive(context: Context?, intent: Intent?) {
+ if (intent?.action == "com.nextcloud.talk.END_CALL") {
+ Log.d(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("com.nextcloud.talk.END_CALL_FROM_NOTIFICATION")
+ endCallIntent.setPackage(context.packageName)
+ context.sendBroadcast(endCallIntent)
+ }
+ }
+ }
+}
\ No newline at end of file
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 f6a53d84871..c9a288f9b65 100644
--- a/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
+++ b/app/src/main/java/com/nextcloud/talk/services/CallForegroundService.kt
@@ -15,12 +15,14 @@ import android.content.pm.ServiceInfo
import android.os.Build
import android.os.Bundle
import android.os.IBinder
+import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE
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.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
@@ -57,6 +59,26 @@ class CallForegroundService : Service() {
val contentTitle = conversationName?.takeIf { it.isNotBlank() }
?: getString(R.string.nc_call_ongoing_notification_default_title)
val pendingIntent = createContentIntent(callExtras)
+
+ // Create action to return to call
+ val returnToCallAction = NotificationCompat.Action.Builder(
+ R.drawable.ic_call_white_24dp,
+ getString(R.string.nc_call_ongoing_notification_return_action),
+ pendingIntent
+ ).build()
+
+ // Create action to end call
+ val endCallPendingIntent = createEndCallIntent(callExtras)
+
+ // DIAGNOSTIC: Logging icon resource availability
+ Log.d("CallForegroundService", "Creating end call action - checking icon resources")
+ Log.d("CallForegroundService", "Using ic_baseline_close_24 instead of non-existent ic_close_white_24px")
+
+ val endCallAction = NotificationCompat.Action.Builder(
+ R.drawable.ic_baseline_close_24, // DIAGNOSTIC: Fixed - using existing icon
+ getString(R.string.nc_call_ongoing_notification_end_action),
+ endCallPendingIntent
+ ).build() // Already has parentheses, good!
return NotificationCompat.Builder(this, channelId)
.setContentTitle(contentTitle)
@@ -69,6 +91,9 @@ class CallForegroundService : Service() {
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(pendingIntent)
.setShowWhen(false)
+ .addAction(returnToCallAction)
+ .addAction(endCallAction)
+ .setAutoCancel(false)
.build()
}
@@ -79,13 +104,28 @@ 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)) }
}
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(this, 0, intent, flags)
}
+
+ private fun createEndCallIntent(callExtras: Bundle?): PendingIntent {
+ // DIAGNOSTIC: Logging intent creation
+ Log.d("CallForegroundService", "Creating EndCallIntent with EndCallReceiver class")
+
+ val intent = Intent(this, EndCallReceiver::class.java).apply {
+ action = "com.nextcloud.talk.END_CALL"
+ callExtras?.let { putExtras(Bundle(it)) }
+ }
+
+ Log.d("CallForegroundService", "EndCallIntent created successfully with action: ${intent.action}")
+
+ 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
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 5c15d936afc..306fb0ad0d4 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -299,6 +299,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
@@ -320,6 +321,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