From ecf471a2808d934cf13f5f873b703f8c2c0f5260 Mon Sep 17 00:00:00 2001 From: Tarek Loubani Date: Thu, 11 Dec 2025 02:10:07 -0500 Subject: [PATCH 1/2] Implement persistent foreground service to keep calls active in background, with notification controls for managing the call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CallForegroundService with persistent notification - Support calls in background without requiring picture-in-picture mode - Add "Return to call" and "End call" action buttons to CallForegroundService notification with corresponding PendingIntent - Handle proper foreground service types for microphone/camera permissions - Add notification permission and fallback messaging. - Add EndCallReceiver to handle end call broadcasts from notification action - Use existing ic_baseline_close_24 drawable for end call action icon - Register broadcast receiver in CallActivity to handle end call requests from notification using ReceiverFlag.NotExported for Android 14+ compatibility - Add proper cleanup flow: notification action → EndCallReceiver → CallActivity → proper hangup sequence - Track intentional call leaving to prevent unwanted service restarts - Release proximity sensor lock properly during notification-triggered hangup - Add diagnostic logging throughout the end call flow for debugging Signed-off-by: Tarek Loubani - refactoring - linter Signed-off-by: rapterjet2004 --- .gitignore | 1 + app/src/main/AndroidManifest.xml | 1 + .../nextcloud/talk/activities/CallActivity.kt | 158 +++++++++++++++++- .../talk/activities/CallBaseActivity.java | 39 +++-- .../talk/receivers/EndCallReceiver.kt | 37 ++++ .../talk/services/CallForegroundService.kt | 34 +++- app/src/main/res/values/strings.xml | 3 + 7 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/receivers/EndCallReceiver.kt 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..b1f90624be2 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,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,22 +132,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 From 860c9c65443fd0260f69f5321645c46296425c71 Mon Sep 17 00:00:00 2001 From: rapterjet2004 Date: Wed, 18 Mar 2026 09:35:11 -0500 Subject: [PATCH 2/2] linter Signed-off-by: rapterjet2004 --- .../com/nextcloud/talk/activities/CallBaseActivity.java | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) 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 b1f90624be2..e895fe409a9 100644 --- a/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java +++ b/app/src/main/java/com/nextcloud/talk/activities/CallBaseActivity.java @@ -103,11 +103,9 @@ public void onStop() { super.onStop(); // Don't automatically finish when going to background // Only finish if explicitly leaving the call - if (shouldFinishOnStop() && !isChangingConfigurations()) { + if (shouldFinishOnStop() && !isChangingConfigurations() && isFinishing()) { // Check if we're really leaving the call or just backgrounding - if (isFinishing()) { - finish(); - } + finish(); } }