Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -86,5 +86,6 @@ freeline_project_description.json

# python
**/__pycache__/
.vscode/

/gradle/verification-keyring.gpg
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@
<receiver android:name=".receivers.MarkAsReadReceiver" />
<receiver android:name=".receivers.DismissRecordingAvailableReceiver" />
<receiver android:name=".receivers.ShareRecordingToChatReceiver" />
<receiver android:name=".receivers.EndCallReceiver" />

<service
android:name=".utils.SyncService"
Expand Down
158 changes: 152 additions & 6 deletions app/src/main/java/com/nextcloud/talk/activities/CallActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOveral
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.LoweredHandState
import com.nextcloud.talk.raisehand.viewmodel.RaiseHandViewModel.RaisedHandState
import com.nextcloud.talk.receivers.EndCallReceiver.Companion.END_CALL_FROM_NOTIFICATION
import com.nextcloud.talk.services.CallForegroundService
import com.nextcloud.talk.signaling.SignalingMessageReceiver
import com.nextcloud.talk.signaling.SignalingMessageReceiver.CallParticipantMessageListener
Expand Down Expand Up @@ -185,7 +186,6 @@ import java.util.Objects
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import kotlin.String
import kotlin.math.abs

@AutoInjector(NextcloudTalkApplication::class)
Expand Down Expand Up @@ -248,6 +248,9 @@ class CallActivity : CallBaseActivity() {

private val callTimeHandler = Handler(Looper.getMainLooper())

// Track if we're intentionally leaving the call
private var isIntentionallyLeavingCall = false

// push to talk
private var isPushToTalkActive = false
private var pulseAnimation: PulseAnimation? = null
Expand All @@ -264,6 +267,16 @@ class CallActivity : CallBaseActivity() {
private val callParticipantMessageListeners: MutableMap<String?, CallParticipantMessageListener> = 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<Participant>,
Expand Down Expand Up @@ -312,12 +325,16 @@ class CallActivity : CallBaseActivity() {
private var requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissionMap: Map<String, Boolean> ->
// Log permission results
Log.d(TAG, "Permission request completed with results: $permissionMap")

val rationaleList: MutableList<String> = 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))
}
}
Expand All @@ -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))
}
}
Expand All @@ -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()) {
Expand All @@ -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()
}
}
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -762,6 +815,7 @@ class CallActivity : CallBaseActivity() {
true
}
binding!!.hangupButton.setOnClickListener {
isIntentionallyLeavingCall = true
hangup(shutDownView = true, endCallForAll = true)
}
binding!!.endCallPopupMenu.setOnClickListener {
Expand All @@ -776,6 +830,7 @@ class CallActivity : CallBaseActivity() {
}
}
binding!!.hangupButton.setOnClickListener {
isIntentionallyLeavingCall = true
hangup(shutDownView = true, endCallForAll = false)
}
binding!!.endCallPopupMenu.setOnClickListener {
Expand Down Expand Up @@ -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)
Expand All @@ -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")
}
}

Expand All @@ -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()
}
Expand Down Expand Up @@ -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)
Expand All @@ -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()
}

Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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

Expand Down
Loading
Loading