diff --git a/.idea/migrations.xml b/.idea/migrations.xml new file mode 100644 index 00000000..f8051a6f --- /dev/null +++ b/.idea/migrations.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62f02841..733897c0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,8 @@ dependencies { kaptAndroidTest(libs.hilt.compiler) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.androidx.navigation.testing) + androidTestImplementation(libs.compose.ui.test.manifest) + debugImplementation(libs.compose.ui.test.manifest) androidTestImplementation(libs.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.core) androidTestImplementation(libs.androidx.test.espresso.core) diff --git a/app/src/main/java/com/example/platform/app/MainActivity.kt b/app/src/main/java/com/example/platform/app/MainActivity.kt index 7ba15605..a84b56d1 100644 --- a/app/src/main/java/com/example/platform/app/MainActivity.kt +++ b/app/src/main/java/com/example/platform/app/MainActivity.kt @@ -46,4 +46,4 @@ class MainApp : Application(), ImageLoaderFactory { * Entry point for the platform samples catalog using the [CatalogActivity]. */ @AndroidEntryPoint -class MainActivity : CatalogActivity() \ No newline at end of file +class MainActivity : CatalogActivity() diff --git a/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt b/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt index 9e0bce30..f649ee77 100644 --- a/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt +++ b/build-logic/src/main/kotlin/com.example.platform.plugin/SamplePlugin.kt @@ -44,6 +44,7 @@ class SamplePlugin : Plugin { apply("org.jetbrains.kotlin.kapt") apply("com.google.devtools.ksp") apply("dagger.hilt.android.plugin") + apply("kotlin-parcelize") apply() } @@ -113,6 +114,8 @@ class SamplePlugin : Plugin { "implementation"(libs.findLibrary("androidx.lifecycle.viewmodel.compose").get()) "implementation"(libs.findLibrary("compose.ui.ui").get()) "implementation"(libs.findLibrary("compose.material3").get()) + "implementation"(libs.findLibrary("compose.material.iconsext").get()) + "implementation"(libs.findLibrary("coil.compose").get()) "implementation"(libs.findLibrary("coil.video").get()) diff --git a/samples/README.md b/samples/README.md index ae7c478c..2ab70af9 100644 --- a/samples/README.md +++ b/samples/README.md @@ -98,6 +98,8 @@ Send texts and images to other apps using the Android Sharesheet. Shows the recommended flow to request single runtime permissions - [Speakable Text](accessibility/src/main/java/com/example/platform/accessibility/SpeakableText.kt): The sample demonstrates the importance of having proper labels for +- [Telecom Call Sample](connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt): +A sample showcasing how to handle calls with the Jetpack Telecom API - [TextSpan](user-interface/text/src/main/java/com/example/platform/ui/text/TextSpan.kt): buildSpannedString is useful for quickly building a rich text. - [UltraHDR Image Capture](camera/camera2/src/main/java/com/example/platform/camera/imagecapture/Camera2UltraHDRCapture.kt): diff --git a/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt b/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt index 1c604d58..fb22a38a 100644 --- a/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt +++ b/samples/connectivity/callnotification/src/main/java/com/example/platform/connectivity/callnotification/NotificationSource.kt @@ -47,7 +47,10 @@ class NotificationSource( const val ChannelId = "1234" const val ChannelIntID = 1234 + const val ChannelOnGoingIntID = 1233 + const val ChannelOnGoingID = "1233" /* + Notification state sent via in intent to inform receiving broadcast what action the user wants to take */ enum class NotificationState(val value: Int) { @@ -68,7 +71,7 @@ class NotificationSource( //Channel for on going call, this has low importance so that the notification is not always shown @SuppressLint("NewApi") private val notificationChannelOngoing = NotificationChannel( - ChannelId, "Call Notifications", + ChannelOnGoingID, "Call Notifications", NotificationManager.IMPORTANCE_LOW, ) @@ -140,6 +143,13 @@ class NotificationSource( notificationManager.notify(ChannelIntID, getIncallNotification()) } + /** + * Will cancel notification and dismiss from sysUI + */ + fun cancelNotification() { + notificationManager.cancel(ChannelIntID) + } + /** * Creates a notification for incoming calls. * This notification plays a ringtone and is not dismissible @@ -192,7 +202,8 @@ class NotificationSource( cancelCallIntent, PendingIntent.FLAG_IMMUTABLE, ) - return NotificationCompat.Builder(context, ChannelId) + + return NotificationCompat.Builder(context, ChannelOnGoingID) .setSmallIcon(R.drawable.ic_dialog_dialer) .setFullScreenIntent(pendingIntent, false) .setOngoing(true) diff --git a/samples/connectivity/telecom/README.md b/samples/connectivity/telecom/README.md new file mode 100644 index 00000000..171643d0 --- /dev/null +++ b/samples/connectivity/telecom/README.md @@ -0,0 +1,19 @@ +# Telecom Sample + +This module contains the sample for integrating Jetpack Telecom SDK to do audio and/or video calls +using the Android Telecom stack. + +The sample simulates a caller app that can make ongoing calls and receive incoming calls. There is +no actual call being made, the sample uses [AudioLoopSource](https://github.com/android/platform-samples/blob/14d464a7c2613e808024f12b2d1c23be15368f4e/samples/connectivity/audio/src/main/java/com/example/platform/connectivity/audio/datasource/AudioLoopSource.kt) +to capture the audio in the device and loop it back to the active endpoint (e.g speaker) + +The structure of the sample is the following: + +- [TelecomCallSample](src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt): +The entry point of the sample with the options to perform a call or to fake an incoming call +- [TelecomCallActivity](src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt): +The activity to be launch when there is an active call. It handles the UI logic based on the current call. +- [TelecomCallService](src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt): +A service that manage the logic of the call and communicates with the Telecom SDK +- [TelecomCallRepository](src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt): +The actual logic to communicate with the Telecom SDK diff --git a/samples/connectivity/telecom/build.gradle.kts b/samples/connectivity/telecom/build.gradle.kts new file mode 100644 index 00000000..7529febe --- /dev/null +++ b/samples/connectivity/telecom/build.gradle.kts @@ -0,0 +1,43 @@ + +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@Suppress("DSL_SCOPE_VIOLATION") +plugins { + id("com.example.platform.sample") + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.example.platform.connectivity.telecom" +} + +dependencies { + implementation("androidx.core:core-telecom:1.0.0-alpha02") + implementation(project(mapOf("path" to ":samples:connectivity:audio"))) + + androidTestImplementation(platform(libs.compose.bom)) + androidTestImplementation(libs.androidx.navigation.testing) + androidTestImplementation(libs.compose.ui.test.manifest) + debugImplementation(libs.compose.ui.test.manifest) + androidTestImplementation(libs.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.espresso.core) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.hilt.testing) + androidTestImplementation(libs.junit4) +} diff --git a/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt new file mode 100644 index 00000000..ba86a425 --- /dev/null +++ b/samples/connectivity/telecom/src/androidTest/java/com/example/platform/connectivity/telecom/TelecomSampleTest.kt @@ -0,0 +1,107 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isOff +import androidx.compose.ui.test.isOn +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.rule.GrantPermissionRule +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * Tests all the samples are present and open. + * + * Note: consider changing the test to use the TestNavController to control navigation instead + */ +class TelecomSampleTest { + + /** + * Use the primary activity to initialize the app normally. + */ + @get:Rule(order = 1) + val composeTestRule = createAndroidComposeRule() + + private val permissionArray = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + listOf(Manifest.permission.RECORD_AUDIO, Manifest.permission.POST_NOTIFICATIONS) + } else { + listOf(Manifest.permission.RECORD_AUDIO) + } + + /** + * Avoids showing permission dialog when running certain samples + */ + @get:Rule(order = 2) + val grantPermissionRule: GrantPermissionRule = + GrantPermissionRule.grant(*permissionArray.toTypedArray()) + + @Before + fun setUp() { + composeTestRule.setContent { + TelecomCallSample() + } + } + + @OptIn(ExperimentalTestApi::class) + @Test + fun testOngoingCall() { + composeTestRule.onNodeWithText("Make fake call").performClick() + composeTestRule.apply { + val isTablet = activity.isDeviceTablet() + Log.d("TelecomSampleTest", "Testing on a tablet? $isTablet") + + // Wait till the call is connected + waitUntilExactlyOneExists(hasText("Connected"), 5000) + onNode(hasText("Bob")).assertIsDisplayed() + + val onHold = "Pause or resume call" + onNodeWithContentDescription(onHold).apply { + assertIsEnabled() + assert(isOff()) + performClick() + } + waitUntilExactlyOneExists(hasContentDescription(onHold) and isOn(), 5000) + + // Disconnect call and check + onNodeWithContentDescription("Disconnect call").performClick() + waitUntil { + onAllNodesWithText("Call ended").fetchSemanticsNodes().isNotEmpty() + } + } + } + + private fun Activity.isDeviceTablet() = + !packageManager.hasSystemFeature(PackageManager.FEATURE_SENSOR_HINGE_ANGLE) && + resources.configuration.smallestScreenWidthDp >= 600 +} diff --git a/samples/connectivity/telecom/src/main/AndroidManifest.xml b/samples/connectivity/telecom/src/main/AndroidManifest.xml new file mode 100644 index 00000000..bc7d46df --- /dev/null +++ b/samples/connectivity/telecom/src/main/AndroidManifest.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt new file mode 100644 index 00000000..52aeeaf3 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/TelecomCallSample.kt @@ -0,0 +1,151 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import com.example.platform.base.PermissionBox +import com.example.platform.connectivity.telecom.call.TelecomCallActivity +import com.example.platform.connectivity.telecom.call.TelecomCallService +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallRepository +import com.google.android.catalog.framework.annotations.Sample + +@Sample( + name = "Telecom Call Sample", + description = "A sample showcasing how to handle calls with the Jetpack Telecom API", + documentation = "https://developer.android.com/guide/topics/connectivity/telecom", + tags = ["telecom"] +) +@RequiresApi(Build.VERSION_CODES.O) +@Composable +fun TelecomCallSample() { + // To record the audio for the call + val permissions = mutableListOf(Manifest.permission.RECORD_AUDIO) + + // We should be using make_own_call permissions but this requires + // implementation of the telecom API to work correctly. + // Please see telecom example for full implementation + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + permissions.add(Manifest.permission.MANAGE_OWN_CALLS) + } + + // To show call notifications we need permissions since Android 13 + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + permissions.add(Manifest.permission.POST_NOTIFICATIONS) + } + + PermissionBox(permissions = permissions) { + TelecomCallOptions() + } +} + +/** + * Screen to launch incoming and outgoing calls. It would normally be the dialer. + */ +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun TelecomCallOptions() { + val context = LocalContext.current + val repository = remember { + TelecomCallRepository.instance ?: TelecomCallRepository.create(context.applicationContext) + } + val call by repository.currentCall.collectAsState() + val hasOngoingCall = call is TelecomCall.Registered + + if (hasOngoingCall) { + LaunchedEffect(Unit) { + context.startActivity( + Intent(context, TelecomCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + }, + ) + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val title = if (hasOngoingCall) { + "There is an active call" + } else { + "No active call" + } + Text(text = title, style = MaterialTheme.typography.titleLarge) + Button( + enabled = !hasOngoingCall, + onClick = { + Toast.makeText(context, "Incoming call in 2 seconds", Toast.LENGTH_SHORT).show() + context.launchCall( + action = TelecomCallService.ACTION_INCOMING_CALL, + name = "Alice", + uri = Uri.parse("tel:12345"), + ) + }, + ) { + Text(text = "Receive fake call") + } + Button( + enabled = !hasOngoingCall, + onClick = { + context.launchCall( + action = TelecomCallService.ACTION_OUTGOING_CALL, + name = "Bob", + uri = Uri.parse("tel:54321"), + ) + }, + ) { + Text(text = "Make fake call") + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private fun Context.launchCall(action: String, name: String, uri: Uri) { + startService( + Intent(this, TelecomCallService::class.java).apply { + this.action = action + putExtra(TelecomCallService.EXTRA_NAME, name) + putExtra(TelecomCallService.EXTRA_URI, uri) + }, + ) +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt new file mode 100644 index 00000000..0da07851 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallActivity.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.call + +import android.app.KeyguardManager +import android.content.Intent +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Modifier +import androidx.core.content.getSystemService +import com.example.platform.connectivity.telecom.model.TelecomCallRepository + + +/** + * This activity is used to launch the incoming or ongoing call. It uses special flags to be able + * to be launched in the lockscreen and as a full-screen notification. + */ +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // The repo contains all the call logic and communication with the Telecom SDK. + val repository = + TelecomCallRepository.instance ?: TelecomCallRepository.create(applicationContext) + + // Set the right flags for a call type activity. + setupCallActivity() + + setContent { + MaterialTheme { + Surface( + Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + ) { + // Show the in-call screen + TelecomCallScreen(repository) { + // If we receive that the called finished, finish the activity + finishAndRemoveTask() + Log.d("TelecomCallActivity", "Call finished. Finishing activity") + } + } + } + } + } + + override fun onResume() { + super.onResume() + // Force the service to update in case something change like Mic permissions. + startService( + Intent(this, TelecomCallService::class.java).apply { + action = TelecomCallService.ACTION_UPDATE_CALL + }, + ) + } + + /** + * Enable the calling activity to be shown in the lockscreen and dismiss the keyguard to enable + * users to answer without unblocking. + */ + private fun setupCallActivity() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + or WindowManager.LayoutParams.FLAG_ALLOW_LOCK_WHILE_SCREEN_ON, + ) + } + + val keyguardManager = getSystemService() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && keyguardManager != null) { + keyguardManager.requestDismissKeyguard(this, null) + } + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallBroadcast.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallBroadcast.kt new file mode 100644 index 00000000..62705083 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallBroadcast.kt @@ -0,0 +1,62 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.call + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction +import com.example.platform.connectivity.telecom.model.TelecomCallRepository + +/** + * A simple BroadcastReceiver that routes the call notification actions to the TelecomCallRepository + */ +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallBroadcast : BroadcastReceiver() { + + override fun onReceive(context: Context, intent: Intent) { + // Get the action or skip if none + val action = intent.getTelecomCallAction() ?: return + val repo = TelecomCallRepository.instance ?: TelecomCallRepository.create(context) + val call = repo.currentCall.value + + if (call is TelecomCall.Registered) { + // If the call is still registered perform action + call.processAction(action) + } else { + // Otherwise probably something went wrong and the notification is wrong. + TelecomCallNotificationManager(context).updateCallNotification(call) + } + } + + /** + * Get the [TelecomCallAction] parcelable object from the intent bundle. + */ + private fun Intent.getTelecomCallAction() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra( + TelecomCallNotificationManager.TELECOM_NOTIFICATION_ACTION, + TelecomCallAction::class.java, + ) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(TelecomCallNotificationManager.TELECOM_NOTIFICATION_ACTION) + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallNotificationManager.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallNotificationManager.kt new file mode 100644 index 00000000..25f3689b --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallNotificationManager.kt @@ -0,0 +1,198 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.call + +import android.Manifest +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.RingtoneManager +import android.os.Build +import android.telecom.DisconnectCause +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.content.PermissionChecker +import com.example.platform.connectivity.telecom.R +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction + +/** + * Handles call status changes and updates the notification accordingly. For more guidance around + * notifications check https://developer.android.com/develop/ui/views/notifications + * + * @see updateCallNotification + */ +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallNotificationManager(private val context: Context) { + + internal companion object { + const val TELECOM_NOTIFICATION_ID = 200 + const val TELECOM_NOTIFICATION_ACTION = "telecom_action" + const val TELECOM_NOTIFICATION_INCOMING_CHANNEL_ID = "telecom_incoming_channel" + const val TELECOM_NOTIFICATION_ONGOING_CHANNEL_ID = "telecom_ongoing_channel" + + private val ringToneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + } + + private val notificationManager: NotificationManagerCompat = + NotificationManagerCompat.from(context) + + /** + * Updates, creates or dismisses a CallStyle notification based on the given [TelecomCall] + */ + fun updateCallNotification(call: TelecomCall) { + // If notifications are not granted, skip it. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + PermissionChecker.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS, + ) != PermissionChecker.PERMISSION_GRANTED + ) { + return + } + + // Ensure that the channel is created + createNotificationChannels() + + // Update or dismiss notification + when (call) { + TelecomCall.None, is TelecomCall.Unregistered -> { + notificationManager.cancel(TELECOM_NOTIFICATION_ID) + } + + is TelecomCall.Registered -> { + val notification = createNotification(call) + notificationManager.notify(TELECOM_NOTIFICATION_ID, notification) + } + } + } + + private fun createNotification(call: TelecomCall.Registered): Notification { + // To display the caller information + val caller = Person.Builder() + .setName(call.callAttributes.displayName) + .setUri(call.callAttributes.address.toString()) + .setImportant(true) + .build() + + // Defines the full screen notification activity or the activity to launch once the user taps + // on the notification + val contentIntent = PendingIntent.getActivity( + /* context = */ context, + /* requestCode = */ 0, + /* intent = */ Intent(context, TelecomCallActivity::class.java), + /* flags = */ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + + // Define the call style based on the call state and set the right actions + val isIncoming = call.isIncoming() && !call.isActive + val callStyle = if (isIncoming) { + NotificationCompat.CallStyle.forIncomingCall( + caller, + getPendingIntent( + TelecomCallAction.Disconnect( + DisconnectCause(DisconnectCause.REJECTED), + ), + ), + getPendingIntent(TelecomCallAction.Answer), + ) + } else { + NotificationCompat.CallStyle.forOngoingCall( + caller, + getPendingIntent( + TelecomCallAction.Disconnect( + DisconnectCause(DisconnectCause.LOCAL), + ), + ), + ) + } + val channelId = if (isIncoming) { + TELECOM_NOTIFICATION_INCOMING_CHANNEL_ID + } else { + TELECOM_NOTIFICATION_ONGOING_CHANNEL_ID + } + + val builder = NotificationCompat.Builder(context, channelId) + .setContentIntent(contentIntent) + .setFullScreenIntent(contentIntent, true) + .setSmallIcon(R.drawable.ic_round_call_24) + .setOngoing(true) + .setStyle(callStyle) + + // TODO figure out why custom actions are not working + if (call.isOnHold) { + builder.addAction( + R.drawable.ic_phone_paused_24, "Resume", + getPendingIntent( + TelecomCallAction.Activate, + ), + ) + } + return builder.build() + } + + /** + * Creates a PendingIntent for the given [TelecomCallAction]. Since the actions are parcelable + * we can directly pass them as extra parameters in the bundle. + */ + private fun getPendingIntent(action: TelecomCallAction): PendingIntent { + val callIntent = Intent(context, TelecomCallBroadcast::class.java) + callIntent.putExtra( + TELECOM_NOTIFICATION_ACTION, + action, + ) + + return PendingIntent.getBroadcast( + context, + callIntent.hashCode(), + callIntent, + PendingIntent.FLAG_IMMUTABLE, + ) + } + + private fun createNotificationChannels() { + val incomingChannel = NotificationChannelCompat.Builder( + TELECOM_NOTIFICATION_INCOMING_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_HIGH, + ).setName("Incoming calls") + .setDescription("Handles the notifications when receiving a call") + .setVibrationEnabled(true).setSound( + ringToneUri, + AudioAttributes.Builder().setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setLegacyStreamType(AudioManager.STREAM_RING) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build(), + ).build() + + val ongoingChannel = NotificationChannelCompat.Builder( + TELECOM_NOTIFICATION_ONGOING_CHANNEL_ID, + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ).setName("Ongoing calls").setDescription("Displays the ongoing call notifications").build() + + notificationManager.createNotificationChannelsCompat( + listOf( + incomingChannel, + ongoingChannel, + ), + ) + } +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt new file mode 100644 index 00000000..376871a5 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallScreen.kt @@ -0,0 +1,457 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.call + +import android.Manifest +import android.os.Build +import android.telecom.DisconnectCause +import android.widget.Toast +import androidx.annotation.RequiresApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowDropDown +import androidx.compose.material.icons.rounded.ArrowDropUp +import androidx.compose.material.icons.rounded.BluetoothAudio +import androidx.compose.material.icons.rounded.Call +import androidx.compose.material.icons.rounded.Headphones +import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.MicOff +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.Phone +import androidx.compose.material.icons.rounded.PhonePaused +import androidx.compose.material.icons.rounded.SendToMobile +import androidx.compose.material.icons.rounded.SpeakerPhone +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.core.telecom.CallEndpointCompat +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_BLUETOOTH +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_SPEAKER +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_STREAMING +import androidx.core.telecom.CallEndpointCompat.Companion.TYPE_WIRED_HEADSET +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction +import com.example.platform.connectivity.telecom.model.TelecomCallRepository +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale +import kotlinx.coroutines.delay + +/** + * This composable observes the current state of the call and updates the UI based on its attributes + * + * Note: this only contains UI logic. All the telecom related actions are in [TelecomCallRepository] + */ +@RequiresApi(Build.VERSION_CODES.O) +@Composable +internal fun TelecomCallScreen(repository: TelecomCallRepository, onCallFinished: () -> Unit) { + // Collect the current call state and update UI + val call by repository.currentCall.collectAsState() + when (val newCall = call) { + is TelecomCall.Unregistered, TelecomCall.None -> { + // If there is no call invoke finish after a small delay + LaunchedEffect(Unit) { + delay(1500) + onCallFinished() + } + // Show call ended when there is no active call + NoCallScreen() + } + + is TelecomCall.Registered -> { + // Call screen only contains the logic to represent the values of the active call + // and process user input by calling the processAction of the active call. + CallScreen( + name = newCall.callAttributes.displayName.toString(), + info = newCall.callAttributes.address.toString(), + incoming = newCall.isIncoming(), + isActive = newCall.isActive, + isOnHold = newCall.isOnHold, + isMuted = newCall.isMuted, + errorCode = newCall.errorCode, + currentEndpoint = newCall.currentCallEndpoint, + endpoints = newCall.availableCallEndpoints, + onCallAction = newCall::processAction, + ) + } + } +} + +@Composable +private fun NoCallScreen() { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text(text = "Call ended", style = MaterialTheme.typography.titleLarge) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CallScreen( + name: String, + info: String, + incoming: Boolean, + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + errorCode: Int?, + currentEndpoint: CallEndpointCompat?, + endpoints: List, + onCallAction: (TelecomCallAction) -> Unit, +) { + + if(errorCode != null) { + Toast.makeText(LocalContext.current, "errorCode=($errorCode)", Toast.LENGTH_SHORT).show() + } + + Column( + Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.SpaceBetween, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CallInfoCard(name, info, isActive) + if (incoming && !isActive) { + IncomingCallActions(onCallAction) + } else { + OngoingCallActions( + isActive = isActive, + isOnHold = isOnHold, + isMuted = isMuted, + currentEndpoint = currentEndpoint, + endpoints = endpoints, + onCallAction = onCallAction, + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun OngoingCallActions( + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + currentEndpoint: CallEndpointCompat?, + endpoints: List, + onCallAction: (TelecomCallAction) -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.primaryContainer) + .shadow(1.dp) + .padding(26.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CallControls( + isActive = isActive, + isOnHold = isOnHold, + isMuted = isMuted, + endpointType = currentEndpoint?.type ?: CallEndpointCompat.TYPE_UNKNOWN, + availableTypes = endpoints, + onCallAction = onCallAction, + ) + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Disconnect( + DisconnectCause( + DisconnectCause.LOCAL, + ), + ), + ) + }, + containerColor = MaterialTheme.colorScheme.error, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = "Disconnect call", + modifier = Modifier.rotate(90f), + ) + } + } +} + +@RequiresApi(Build.VERSION_CODES.M) +@Composable +private fun IncomingCallActions(onCallAction: (TelecomCallAction) -> Unit) { + Row( + Modifier + .fillMaxWidth() + .padding(26.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Disconnect( + DisconnectCause( + DisconnectCause.REJECTED, + ), + ), + ) + }, + containerColor = MaterialTheme.colorScheme.error, + ) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = null, + modifier = Modifier.rotate(90f), + ) + } + FloatingActionButton( + onClick = { + onCallAction( + TelecomCallAction.Answer, + ) + }, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon(imageVector = Icons.Rounded.Call, contentDescription = null) + } + } +} + +@Composable +private fun CallInfoCard(name: String, info: String, isActive: Boolean) { + Column( + Modifier + .fillMaxSize(0.5f), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image(imageVector = Icons.Rounded.Person, contentDescription = null) + Text(text = name, style = MaterialTheme.typography.titleMedium) + Text(text = info, style = MaterialTheme.typography.bodyMedium) + + if (!isActive) { + Text(text = "Connecting...", style = MaterialTheme.typography.titleSmall) + } else { + Text(text = "Connected", style = MaterialTheme.typography.titleSmall) + } + + } +} + +/** + * Displays the call controls based on the current call attributes + */ +@RequiresApi(Build.VERSION_CODES.O) +@OptIn(ExperimentalPermissionsApi::class) +@Composable +private fun CallControls( + isActive: Boolean, + isOnHold: Boolean, + isMuted: Boolean, + endpointType: @CallEndpointCompat.Companion.EndpointType Int, + availableTypes: List, + onCallAction: (TelecomCallAction) -> Unit, +) { + val micPermission = rememberPermissionState(permission = Manifest.permission.RECORD_AUDIO) + var showRational by remember(micPermission.status) { + mutableStateOf(false) + } + + var showEndPoints by remember { + mutableStateOf(false) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + if (micPermission.status.isGranted) { + IconToggleButton( + checked = isMuted, + onCheckedChange = { + onCallAction(TelecomCallAction.ToggleMute(it)) + }, + ) { + if (isMuted) { + Icon(imageVector = Icons.Rounded.MicOff, contentDescription = "Mic on") + } else { + Icon(imageVector = Icons.Rounded.Mic, contentDescription = "Mic off") + } + } + } else { + IconButton( + onClick = { + if (micPermission.status.shouldShowRationale) { + showRational = true + } else { + micPermission.launchPermissionRequest() + } + }, + ) { + Icon( + imageVector = Icons.Rounded.MicOff, + contentDescription = "Missing mic permission", + tint = MaterialTheme.colorScheme.error, + ) + } + } + Box { + IconButton(onClick = { showEndPoints = !showEndPoints }) { + Icon( + getEndpointIcon(endpointType), + contentDescription = "Toggle Endpoints", + ) + Icon( + if (showEndPoints) { + Icons.Rounded.ArrowDropUp + } else { + Icons.Rounded.ArrowDropDown + }, + contentDescription = "Localized description", + modifier = Modifier.align(Alignment.TopEnd), + ) + } + DropdownMenu( + expanded = showEndPoints, + onDismissRequest = { showEndPoints = false }, + ) { + availableTypes.forEach { it -> + CallEndPointItem( + endPoint = it, + onDeviceSelected = { + onCallAction(TelecomCallAction.SwitchAudioEndpoint(it.identifier)) + showEndPoints = false + }, + ) + } + } + } + IconToggleButton( + enabled = isActive, + checked = isOnHold, + onCheckedChange = { + val action = if (it) { + TelecomCallAction.Hold + } else { + TelecomCallAction.Activate + } + onCallAction(action) + }, + ) { + Icon( + imageVector = Icons.Rounded.PhonePaused, + contentDescription = "Pause or resume call", + ) + } + } + + // Show a rational dialog if user didn't accepted the permissions + if (showRational) { + RationalMicDialog( + onResult = { request -> + if (request) { + micPermission.launchPermissionRequest() + } + showRational = false + }, + ) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +@Composable +private fun CallEndPointItem( + endPoint: CallEndpointCompat, + onDeviceSelected: (CallEndpointCompat) -> Unit, +) { + DropdownMenuItem( + text = { Text(text = endPoint.name.toString()) }, + onClick = { onDeviceSelected(endPoint) }, + leadingIcon = { + Icon( + getEndpointIcon(endPoint.type), + contentDescription = endPoint.name.toString(), + ) + }, + ) +} + +private fun getEndpointIcon(type: @CallEndpointCompat.Companion.EndpointType Int): ImageVector { + return when (type) { + TYPE_BLUETOOTH -> Icons.Rounded.BluetoothAudio + TYPE_SPEAKER -> Icons.Rounded.SpeakerPhone + TYPE_STREAMING -> Icons.Rounded.SendToMobile + TYPE_WIRED_HEADSET -> Icons.Rounded.Headphones + else -> Icons.Rounded.Phone + } +} + +@Composable +private fun RationalMicDialog(onResult: (Boolean) -> Unit) { + AlertDialog( + onDismissRequest = { onResult(false) }, + confirmButton = { + TextButton(onClick = { onResult(true) }) { + Text(text = "Continue") + } + }, + dismissButton = { + TextButton(onClick = { onResult(false) }) { + Text(text = "Cancel") + } + }, + title = { + Text(text = "Mic permission required") + }, + text = { + Text(text = "In order to speak in a call we need mic permission. Please press continue and grant the permission in the next dialog.") + }, + ) +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt new file mode 100644 index 00000000..d894c467 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/call/TelecomCallService.kt @@ -0,0 +1,193 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.call + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Service +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.IBinder +import androidx.annotation.RequiresApi +import androidx.core.content.PermissionChecker +import com.example.platform.connectivity.audio.AudioLoopSource +import com.example.platform.connectivity.telecom.model.TelecomCall +import com.example.platform.connectivity.telecom.model.TelecomCallAction +import com.example.platform.connectivity.telecom.model.TelecomCallRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch + + +/** + * This service handles the app call logic (show notification, record mic, display audio, etc..). + * It can get started by the user or by an upcoming push notification to start a call. + * + * It holds the call scope used to register a call with the Telecom SDK in our TelecomCallRepository. + * + * When registering a call with the Telecom SDK and displaying a CallStyle notification, the SDK will + * grant you foreground service delegation so there is no need to make this a FGS. + * + * Note: you could potentially make this service run in a different process since audio or video + * calls can consume significant memory, although that would require more complex setup to make it + * work across multiple process. + */ +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallService : Service() { + + companion object { + internal const val EXTRA_NAME: String = "extra_name" + internal const val EXTRA_URI: String = "extra_uri" + internal const val ACTION_INCOMING_CALL = "incoming_call" + internal const val ACTION_OUTGOING_CALL = "outgoing_call" + internal const val ACTION_UPDATE_CALL = "update_call" + } + + private lateinit var notificationManager: TelecomCallNotificationManager + private lateinit var telecomRepository: TelecomCallRepository + + private val scope: CoroutineScope = CoroutineScope(SupervisorJob()) + private var audioJob: Job? = null + + override fun onCreate() { + super.onCreate() + notificationManager = TelecomCallNotificationManager(applicationContext) + telecomRepository = + TelecomCallRepository.instance ?: TelecomCallRepository.create(applicationContext) + + // Observe call status updates once the call is registered and update the service + telecomRepository.currentCall + .onEach { call -> + updateServiceState(call) + } + .onCompletion { + // If the scope is completed stop the service + stopSelf() + } + .launchIn(scope) + } + + override fun onDestroy() { + super.onDestroy() + // Remove notification and clean resources + scope.cancel() + notificationManager.updateCallNotification(TelecomCall.None) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) { + return START_NOT_STICKY + } + + when (intent.action) { + ACTION_INCOMING_CALL -> registerCall(intent = intent, incoming = true) + ACTION_OUTGOING_CALL -> registerCall(intent = intent, incoming = false) + ACTION_UPDATE_CALL -> updateServiceState(telecomRepository.currentCall.value) + + else -> throw IllegalArgumentException("Unknown action") + } + + return START_STICKY + } + + private fun registerCall(intent: Intent, incoming: Boolean) { + // If we have an ongoing call ignore command + if (telecomRepository.currentCall.value is TelecomCall.Registered) { + return + } + + val name = intent.getStringExtra(EXTRA_NAME)!! + val uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(EXTRA_URI, Uri::class.java)!! + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(EXTRA_URI)!! + } + + scope.launch { + if (incoming) { + // fake a delay for the incoming call for demo purposes + delay(2000) + } + + launch { + // Register the call with the Telecom stack + telecomRepository.registerCall(name, uri, incoming) + } + + if (!incoming) { + // If doing an outgoing call, fake the other end picks it up for demo purposes. + delay(2000) + (telecomRepository.currentCall.value as? TelecomCall.Registered)?.processAction( + TelecomCallAction.Activate, + ) + } + } + } + + /** + * Update our calling service based on the call state. Here is where you would update the + * connection socket, the notification, etc... + */ + @SuppressLint("MissingPermission") + private fun updateServiceState(call: TelecomCall) { + // Update the call notification + notificationManager.updateCallNotification(call) + + when (call) { + is TelecomCall.None -> { + // Stop any call tasks, in this demo we stop the audio loop + audioJob?.cancel() + } + + is TelecomCall.Registered -> { + // Update the call state. + // For this sample it means start/stop the audio loop + if (call.isActive && !call.isOnHold && !call.isMuted && hasMicPermission()) { + if (audioJob == null || audioJob?.isActive == false) { + audioJob = scope.launch { + AudioLoopSource.openAudioLoop() + } + } + } else { + audioJob?.cancel() + } + } + + is TelecomCall.Unregistered -> { + // Stop service and clean resources + stopSelf() + } + } + } + + override fun onBind(intent: Intent): IBinder? = null + + private fun hasMicPermission() = + PermissionChecker.checkSelfPermission( + this, + Manifest.permission.RECORD_AUDIO, + ) == PermissionChecker.PERMISSION_GRANTED + +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt new file mode 100644 index 00000000..606afe04 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCall.kt @@ -0,0 +1,72 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.model + +import android.os.ParcelUuid +import android.telecom.DisconnectCause +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallEndpointCompat +import kotlinx.coroutines.channels.Channel + +/** + * Custom representation of a call state. + */ +sealed class TelecomCall { + + /** + * There is no current or past calls in the stack + */ + object None : TelecomCall() + + /** + * Represents a registered call with the telecom stack with the values provided by the + * Telecom SDK + */ + data class Registered( + val id: ParcelUuid, + val callAttributes: CallAttributesCompat, + val isActive: Boolean, + val isOnHold: Boolean, + val isMuted: Boolean, + val errorCode: Int?, + val currentCallEndpoint: CallEndpointCompat?, + val availableCallEndpoints: List, + internal val actionSource: Channel, + ) : TelecomCall() { + + /** + * @return true if it's an incoming registered call, false otherwise + */ + fun isIncoming() = callAttributes.direction == CallAttributesCompat.DIRECTION_INCOMING + + /** + * Sends an action to the call session. It will be processed if it's still registered. + * + * @return true if the action was sent, false otherwise + */ + fun processAction(action: TelecomCallAction) = actionSource.trySend(action).isSuccess + } + + /** + * Represent a previously registered call that was disconnected + */ + data class Unregistered( + val id: ParcelUuid, + val callAttributes: CallAttributesCompat, + val disconnectCause: DisconnectCause, + ) : TelecomCall() +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt new file mode 100644 index 00000000..4199c4c3 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallAction.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.model + +import android.os.ParcelUuid +import android.os.Parcelable +import android.telecom.DisconnectCause +import kotlinx.parcelize.Parcelize + +/** + * Simple interface to represent related call actions to communicate with the registered call scope + * in the [TelecomCallRepository.registerCall] + * + * Note: we are using [Parcelize] to make the actions parcelable so they can be directly used in the + * call notification. + */ +sealed interface TelecomCallAction : Parcelable { + @Parcelize + object Answer : TelecomCallAction + + @Parcelize + data class Disconnect(val cause: DisconnectCause) : TelecomCallAction + + @Parcelize + object Hold : TelecomCallAction + + @Parcelize + object Activate : TelecomCallAction + + @Parcelize + data class ToggleMute(val isMute: Boolean) : TelecomCallAction + + @Parcelize + data class SwitchAudioEndpoint(val endpointId: ParcelUuid) : TelecomCallAction + + @Parcelize + data class TransferCall(val endpointId: ParcelUuid) : TelecomCallAction +} diff --git a/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt new file mode 100644 index 00000000..8c56e715 --- /dev/null +++ b/samples/connectivity/telecom/src/main/java/com/example/platform/connectivity/telecom/model/TelecomCallRepository.kt @@ -0,0 +1,329 @@ +/* + * Copyright 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example.platform.connectivity.telecom.model + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.telecom.DisconnectCause +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlResult +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallException +import androidx.core.telecom.CallsManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * The central repository that keeps track of the current call and allows to register new calls. + * + * This class contains the main logic to integrate with Telecom SDK. + * + * @see registerCall + */ +@RequiresApi(Build.VERSION_CODES.O) +class TelecomCallRepository(private val callsManager: CallsManager) { + + companion object { + var instance: TelecomCallRepository? = null + private set + + /** + * This does not illustrate best practices for instantiating classes in Android but for + * simplicity we use this create method to create a singleton with the CallsManager class. + */ + fun create(context: Context): TelecomCallRepository { + Log.d("MPB", "New instance") + check(instance == null) { + "CallRepository instance already created" + } + + // Create the Jetpack Telecom entry point + val callsManager = CallsManager(context).apply { + // Register with the telecom interface with the supported capabilities + registerAppWithTelecom( + capabilities = CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING and + CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING, + ) + } + + return TelecomCallRepository( + callsManager = callsManager, + ).also { + instance = it + } + } + } + + // Keeps track of the current TelecomCall state + private val _currentCall: MutableStateFlow = MutableStateFlow(TelecomCall.None) + val currentCall = _currentCall.asStateFlow() + + /** + * Register a new call with the provided attributes. + * Use the [currentCall] StateFlow to receive status updates and process call related actions. + */ + suspend fun registerCall(displayName: String, address: Uri, isIncoming: Boolean) { + // For simplicity we don't support multiple calls + check(_currentCall.value !is TelecomCall.Registered) { + "There cannot be more than one call at the same time." + } + + // Create the call attributes + val attributes = CallAttributesCompat( + displayName = displayName, + address = address, + direction = if (isIncoming) { + CallAttributesCompat.DIRECTION_INCOMING + } else { + CallAttributesCompat.DIRECTION_OUTGOING + }, + callType = CallAttributesCompat.CALL_TYPE_AUDIO_CALL, + callCapabilities = (CallAttributesCompat.SUPPORTS_SET_INACTIVE + or CallAttributesCompat.SUPPORTS_STREAM + or CallAttributesCompat.SUPPORTS_TRANSFER), + ) + + // Creates a channel to send actions to the call scope. + val actionSource = Channel() + // Register the call and handle actions in the scope + try { + callsManager.addCall( + attributes, + onIsCallAnswered, // Watch needs to know if it can answer the call + onIsCallDisconnected, + onIsCallActive, + onIsCallInactive + ) { + // Consume the actions to interact with the call inside the scope + launch { + processCallActions(actionSource.consumeAsFlow()) + } + + // Update the state to registered with default values while waiting for Telecom updates + _currentCall.value = TelecomCall.Registered( + id = getCallId(), + isActive = false, + isOnHold = false, + callAttributes = attributes, + isMuted = false, + errorCode = null, + currentCallEndpoint = null, + availableCallEndpoints = emptyList(), + actionSource = actionSource, + ) + + launch { + currentCallEndpoint.collect { + updateCurrentCall { + copy(currentCallEndpoint = it) + } + } + } + launch { + availableEndpoints.collect { + updateCurrentCall { + copy(availableCallEndpoints = it) + } + } + } + launch { + isMuted.collect { + updateCurrentCall { + copy(isMuted = it) + } + } + } + } + } finally { + _currentCall.value = TelecomCall.None + } + } + + /** + * Collect the action source to handle client actions inside the call scope + */ + private suspend fun CallControlScope.processCallActions(actionSource: Flow) { + actionSource.collect { action -> + when (action) { + is TelecomCallAction.Answer -> { + doAnswer() + } + + is TelecomCallAction.Disconnect -> { + doDisconnect(action) + } + + is TelecomCallAction.SwitchAudioEndpoint -> { + doSwitchEndpoint(action) + } + + is TelecomCallAction.TransferCall -> { + val call = _currentCall.value as? TelecomCall.Registered + val endpoints = call?.availableCallEndpoints?.firstOrNull { + it.identifier == action.endpointId + } + requestEndpointChange( + endpoint = endpoints ?: return@collect, + ) + } + + TelecomCallAction.Hold -> { + when (val result = setInactive()) { + is CallControlResult.Success -> { + onIsCallInactive() + } + + is CallControlResult.Error -> { + updateCurrentCall { + copy(errorCode = result.errorCode) + } + } + } + } + + TelecomCallAction.Activate -> { + when (val result = setActive()) { + is CallControlResult.Success -> { + onIsCallActive() + } + + is CallControlResult.Error -> { + updateCurrentCall { + copy(errorCode = result.errorCode) + } + } + } + } + + is TelecomCallAction.ToggleMute -> { + // We cannot programmatically mute the telecom stack. Instead we just update + // the state of the call and this will start/stop audio capturing. + updateCurrentCall { + copy(isMuted = !isMuted) + } + } + } + } + } + + /** + * Update the current state of our call applying the transform lambda only if the call is + * registered. Otherwise keep the current state + */ + private fun updateCurrentCall(transform: TelecomCall.Registered.() -> TelecomCall) { + _currentCall.update { call -> + if (call is TelecomCall.Registered) { + call.transform() + } else { + call + } + } + } + + private suspend fun CallControlScope.doSwitchEndpoint(action: TelecomCallAction.SwitchAudioEndpoint) { + // TODO once availableCallEndpoints is a state flow we can just get the value + val endpoints = (_currentCall.value as TelecomCall.Registered).availableCallEndpoints + + // Switch to the given endpoint or fallback to the best possible one. + val newEndpoint = endpoints.firstOrNull { it.identifier == action.endpointId } + + if (newEndpoint != null) { + requestEndpointChange(newEndpoint).also { + Log.d("MPB", "Endpoint ${newEndpoint.name} changed: $it") + } + } + } + + private suspend fun CallControlScope.doDisconnect(action: TelecomCallAction.Disconnect) { + disconnect(action.cause) + onIsCallDisconnected(action.cause) + } + + private suspend fun CallControlScope.doAnswer() { + when (answer(CallAttributesCompat.CALL_TYPE_AUDIO_CALL)) { + is CallControlResult.Success -> { + onIsCallAnswered(CallAttributesCompat.CALL_TYPE_AUDIO_CALL) + } + + is CallControlResult.Error -> { + updateCurrentCall { + TelecomCall.Unregistered( + id = id, + callAttributes = callAttributes, + disconnectCause = DisconnectCause(DisconnectCause.BUSY), + ) + } + } + } + } + + /** + * Can the call be successfully answered?? + * TIP: We would check the connection/call state to see if we can answer a call + * Example you may need to wait for another call to hold. + **/ + val onIsCallAnswered: suspend(type: Int) -> Unit = { + updateCurrentCall { + copy(isActive = true, isOnHold = false) + } + } + + /** + * Can the call perform a disconnect + */ + val onIsCallDisconnected: suspend (cause: DisconnectCause) -> Unit = { + updateCurrentCall { + TelecomCall.Unregistered(id, callAttributes, it) + } + } + + /** + * Check is see if we can make the call active. + * Other calls and state might stop us from activating the call + */ + val onIsCallActive: suspend () -> Unit = { + updateCurrentCall { + copy( + errorCode = null, + isActive = true, + isOnHold = false, + ) + } + } + + /** + * Check to see if we can make the call inactivate + */ + val onIsCallInactive: suspend () -> Unit = { + updateCurrentCall { + copy( + errorCode = null, + isOnHold = true) + } + } +} \ No newline at end of file diff --git a/samples/connectivity/telecom/src/main/res/drawable/ic_phone_paused_24.xml b/samples/connectivity/telecom/src/main/res/drawable/ic_phone_paused_24.xml new file mode 100644 index 00000000..a9afe06f --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/ic_phone_paused_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/samples/connectivity/telecom/src/main/res/drawable/ic_round_call_24.xml b/samples/connectivity/telecom/src/main/res/drawable/ic_round_call_24.xml new file mode 100644 index 00000000..c22fb83a --- /dev/null +++ b/samples/connectivity/telecom/src/main/res/drawable/ic_round_call_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/samples/user-interface/haptics/build.gradle.kts b/samples/user-interface/haptics/build.gradle.kts index b2bda624..07600256 100644 --- a/samples/user-interface/haptics/build.gradle.kts +++ b/samples/user-interface/haptics/build.gradle.kts @@ -21,7 +21,3 @@ plugins { android { namespace = "com.example.platform.ui.haptics" } - -dependencies { - implementation(libs.compose.material.iconsext) -}