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)
-}