diff --git a/.github/workflows/build-basic-video-chat-with-foregroundservices-java.yml b/.github/workflows/build-basic-video-chat-connectionservice-java.yml similarity index 72% rename from .github/workflows/build-basic-video-chat-with-foregroundservices-java.yml rename to .github/workflows/build-basic-video-chat-connectionservice-java.yml index ee2e751d..a45fc64f 100644 --- a/.github/workflows/build-basic-video-chat-with-foregroundservices-java.yml +++ b/.github/workflows/build-basic-video-chat-connectionservice-java.yml @@ -1,4 +1,4 @@ -name: Build Basic-Video-Chat-With-ForegroundServices-Java +name: Build Basic-Video-Chat-ConnectionService-Java on: push: @@ -22,4 +22,4 @@ jobs: java-version: 17 - name: Build - run: cd Basic-Video-Chat-With-ForegroundServices-Java && ./gradlew app:assembleRelease && cd .. + run: cd Basic-Video-Chat-ConnectionService-Java && ./gradlew app:assembleRelease && cd .. diff --git a/.github/workflows/build-basic-video-chat-connectionservice-kotlin.yml b/.github/workflows/build-basic-video-chat-connectionservice-kotlin.yml new file mode 100644 index 00000000..9805fb0d --- /dev/null +++ b/.github/workflows/build-basic-video-chat-connectionservice-kotlin.yml @@ -0,0 +1,25 @@ +name: Build Basic-Video-Chat-ConnectionService-Kotlin + +on: + push: + branches: [main] # Just in case main was not up to date while merging PR + pull_request: + types: [opened, synchronize] + +jobs: + run: + continue-on-error: true + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - name: checkout + uses: actions/checkout@v2 + + - name: Set up JDK + uses: actions/setup-java@v1 + with: + java-version: 17 + + - name: Build + run: cd Basic-Video-Chat-ConnectionService-Kotlin && ./gradlew app:assembleRelease && cd .. diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/.gitignore b/Basic-Video-Chat-ConnectionService-Kotlin/.gitignore new file mode 100644 index 00000000..4d47e751 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/.gitignore @@ -0,0 +1,18 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/captures +.externalNativeBuild +.cxx +local.properties +app/build + +.settings/ +app/jniLibs/ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/.gitignore b/Basic-Video-Chat-ConnectionService-Kotlin/app/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/build.gradle.kts b/Basic-Video-Chat-ConnectionService-Kotlin/app/build.gradle.kts new file mode 100644 index 00000000..7c3d00a9 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) + alias(libs.plugins.ksp) + alias(libs.plugins.google.dagger.hilt) +} + +android { + namespace = "com.vonage.basic_video_chat_connectionservice" + compileSdk = 36 + + defaultConfig { + applicationId = "com.vonage.basic_video_chat_connectionservice" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = "11" + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(libs.hilt.android) + ksp(libs.hilt.compiler) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/AndroidManifest.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a1d1baab --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/AndroidManifest.xml @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/ic_launcher-playstore.png b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/ic_launcher-playstore.png new file mode 100644 index 00000000..87bd8830 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/ic_launcher-playstore.png differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/Call.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/Call.kt new file mode 100644 index 00000000..6e90e1eb --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/Call.kt @@ -0,0 +1,17 @@ +package com.vonage.basic_video_chat_connectionservice + +data class Call( + val callID: Int, + val name: String, + val state: CallState +) + +enum class CallState { + IDLE, + CONNECTING, + DIALING, + ANSWERING, + CONNECTED, + HOLDING, + DISCONNECTED +} diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallActionReceiver.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallActionReceiver.kt new file mode 100644 index 00000000..99d554cc --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallActionReceiver.kt @@ -0,0 +1,63 @@ +package com.vonage.basic_video_chat_connectionservice + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.util.Log +import com.vonage.basic_video_chat_connectionservice.connectionservice.VonageConnectionHolder + +class CallActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent != null && intent.action != null) { + val action = intent.action + when (action) { + ACTION_ANSWER_CALL -> { + Log.d(TAG, "Action: Answer call") + answerCall() + } + + ACTION_REJECT_CALL -> { + Log.d(TAG, "Action: Reject call") + rejectCall() + } + + ACTION_END_CALL -> { + Log.d(TAG, "Action: End call") + endCall() + } + + else -> Log.w( + TAG, + "Unknown action: $action" + ) + } + } + } + + private fun answerCall() { + VonageConnectionHolder.connection?.onAnswer() + } + + private fun rejectCall() { + VonageConnectionHolder.connection?.onReject() + } + + private fun endCall() { + VonageConnectionHolder.connection?.onDisconnect() + } + + companion object { + private const val TAG = "CallActionReceiver" + + const val ACTION_ANSWER_CALL: String = + "com.tokbox.sample.basicvideochatconnectionservice.ACTION_ANSWER_CALL" + const val ACTION_REJECT_CALL: String = + "com.tokbox.sample.basicvideochatconnectionservice.ACTION_REJECT_CALL" + const val ACTION_END_CALL: String = + "com.tokbox.sample.basicvideochatconnectionservice.ACTION_END_CALL" + + const val ACTION_ANSWER_CALL_ID: Int = 2 + const val ACTION_REJECT_CALL_ID: Int = 3 + const val ACTION_END_CALL_ID: Int = 4 + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallException.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallException.kt new file mode 100644 index 00000000..f8c6c4e8 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallException.kt @@ -0,0 +1,3 @@ +package com.vonage.basic_video_chat_connectionservice + +class CallException(message: String, val code: Int = 0) : Exception(message) \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallHolder.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallHolder.kt new file mode 100644 index 00000000..7c095153 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/CallHolder.kt @@ -0,0 +1,54 @@ +package com.vonage.basic_video_chat_connectionservice + +import android.view.View +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class CallHolder { + private val _callFlow = MutableStateFlow(null) + val callFlow: Flow = _callFlow.asStateFlow() + + private val _callStateFlow = MutableStateFlow(CallState.IDLE) + val callStateFlow: Flow = _callStateFlow.asStateFlow() + + private val subscriberMap = mutableMapOf() + + private val _publisherViewFlow = MutableStateFlow(null) + val publisherViewFlow: Flow = _publisherViewFlow.asStateFlow() + + private val _subscriberViewFlow = MutableStateFlow>(emptyList()) + val subscriberViewFlow: Flow> = _subscriberViewFlow.asStateFlow() + + fun setCall(call: Call?) { + _callFlow.value = call + } + + fun updateCallState(state: CallState) { + _callStateFlow.value = state + } + + fun setPublisherView(view: View?) { + _publisherViewFlow.value = view + } + + fun addSubscriberView(streamId: String, view: View) { + subscriberMap[streamId] = view + + _subscriberViewFlow.value = subscriberMap.values.toList() + } + + fun removeSubscriberView(streamId: String) { + subscriberMap.remove(streamId) + + _subscriberViewFlow.value = subscriberMap.values.toList() + } + + fun clear() { + _callFlow.value = null + _callStateFlow.value = CallState.IDLE + subscriberMap.clear() + _publisherViewFlow.value = null + _subscriberViewFlow.value = emptyList() + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/MainActivity.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/MainActivity.kt new file mode 100644 index 00000000..e9fa29e5 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/MainActivity.kt @@ -0,0 +1,189 @@ +package com.vonage.basic_video_chat_connectionservice + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.PowerManager +import android.provider.Settings +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestMultiplePermissions +import androidx.activity.viewModels +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Scaffold +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.Modifier +import androidx.core.app.ActivityCompat +import androidx.core.net.toUri +import com.vonage.basic_video_chat_connectionservice.deviceselector.AudioDeviceSelector +import com.vonage.basic_video_chat_connectionservice.deviceselector.AudioDeviceSelectorDialog +import com.vonage.basic_video_chat_connectionservice.home.HomeView +import com.vonage.basic_video_chat_connectionservice.home.HomeViewModel +import com.vonage.basic_video_chat_connectionservice.room.RoomView +import com.vonage.basic_video_chat_connectionservice.room.RoomViewModel +import com.vonage.basic_video_chat_connectionservice.ui.theme.BasicVideoChatConnectionServiceKotlinTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +@AndroidEntryPoint +class MainActivity : ComponentActivity() { + private val homeViewModel: HomeViewModel by viewModels() + private val roomViewModel: RoomViewModel by viewModels() + + @Inject + lateinit var powerManager: PowerManager + @Inject + lateinit var vonageManager: VonageManager + @Inject + lateinit var callHolder: CallHolder + @Inject + lateinit var audioDeviceSelector: AudioDeviceSelector + + val isBatteryOptimizationIgnored: Boolean + get() = powerManager.isIgnoringBatteryOptimizations(applicationContext.packageName) + + private val permissionLauncher: ActivityResultLauncher> = + registerForActivityResult(RequestMultiplePermissions()) { result -> + for ((permission, isGranted) in result) { + Log.d("Permission", "$permission -> ${if (isGranted) "GRANTED" else "DENIED"}") + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + requestPermissions() + + enableEdgeToEdge() + setContent { + val call by callHolder.callFlow.collectAsState(initial = null) + var showAudioDeviceSelector by remember { mutableStateOf(false) } + val error by vonageManager.errorFlow.collectAsState() + + BasicVideoChatConnectionServiceKotlinTheme { + Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> + if (call != null) { + RoomView( + roomViewModel = roomViewModel, + onShowAudioDevicesClick = { + showAudioDeviceSelector = true + }) + } else { + HomeView( + homeViewModel = homeViewModel, + modifier = Modifier.padding(innerPadding)) + } + + if (showAudioDeviceSelector) { + AudioDeviceSelectorDialog( + audioDeviceSelector = audioDeviceSelector, + onDismissRequest = { + showAudioDeviceSelector = false + } + ) + } + + error?.let { opentokError -> + OpenTokErrorDialog( + error = opentokError, + onDismiss = { vonageManager.clearError() }, + onEndCall = { + vonageManager.clearError() + vonageManager.endCall() + } + ) + } + } + } + } + } + + private fun requestPermissions() { + val perms = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> arrayOf( + Manifest.permission.INTERNET, + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CALL_PHONE, + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.POST_NOTIFICATIONS, + Manifest.permission.MANAGE_OWN_CALLS + ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> arrayOf( + Manifest.permission.INTERNET, + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CALL_PHONE, + Manifest.permission.BLUETOOTH, + Manifest.permission.BLUETOOTH_CONNECT, + Manifest.permission.MANAGE_OWN_CALLS + ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.O -> arrayOf( + Manifest.permission.INTERNET, + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CALL_PHONE, + Manifest.permission.BLUETOOTH, + Manifest.permission.MANAGE_OWN_CALLS + ) + else -> arrayOf( + Manifest.permission.INTERNET, + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CALL_PHONE, + Manifest.permission.BLUETOOTH, + ) + } + + val permissionsNeeded = perms.any { permission -> + ActivityCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED + } + + if (permissionsNeeded) { + permissionLauncher.launch(perms) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && !isBatteryOptimizationIgnored) { + requestBatteryOptimizationIntent() + } + } + + private fun requestBatteryOptimizationIntent() { + val packageName = applicationContext.packageName + + if (!powerManager.isIgnoringBatteryOptimizations(packageName)) { + val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = "package:$packageName".toUri() + } + startActivity(intent) + } + } + + @SuppressLint("UnspecifiedRegisterReceiverFlag") + public override fun onResume() { + super.onResume() + vonageManager.onResume() + } + + public override fun onPause() { + super.onPause() + vonageManager.onPause() + } + + public override fun onDestroy() { + super.onDestroy() + vonageManager.endSession() + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/MyApp.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/MyApp.kt new file mode 100644 index 00000000..74fdc000 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/MyApp.kt @@ -0,0 +1,23 @@ +package com.vonage.basic_video_chat_connectionservice + +import android.app.Application +import android.os.Build +import com.vonage.basic_video_chat_connectionservice.connectionservice.PhoneAccountManager +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject + +@HiltAndroidApp +class MyApp: Application() { + @Inject lateinit var phoneAccountManager: PhoneAccountManager + @Inject lateinit var notificationChannelManager: NotificationChannelManager + + override fun onCreate() { + super.onCreate() + + phoneAccountManager.registerPhoneAccount() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationChannelManager.setup() + } + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/NotificationChannelManager.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/NotificationChannelManager.kt new file mode 100644 index 00000000..605ce15d --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/NotificationChannelManager.kt @@ -0,0 +1,57 @@ +package com.vonage.basic_video_chat_connectionservice + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.media.AudioAttributes +import android.media.RingtoneManager +import android.os.Build +import androidx.annotation.RequiresApi + +class NotificationChannelManager(private val context: Context) { + + @RequiresApi(api = Build.VERSION_CODES.O) + fun setup() { + setupIncomingCallChannel() + setupOngoingCallChannel() + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun setupIncomingCallChannel() { + val channel = NotificationChannel( + INCOMING_CALL_CHANNEL_ID, "Incoming Calls", + NotificationManager.IMPORTANCE_HIGH + ) + + val ringtoneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + channel.setSound( + ringtoneUri, AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) + + val mgr = context.getSystemService( + NotificationManager::class.java + ) + mgr.createNotificationChannel(channel) + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun setupOngoingCallChannel() { + val channel = NotificationChannel( + ONGOING_CALL_CHANNEL_ID, "Ongoing Calls", + NotificationManager.IMPORTANCE_DEFAULT + ) + + val mgr = context.getSystemService( + NotificationManager::class.java + ) + mgr.createNotificationChannel(channel) + } + + companion object { + const val INCOMING_CALL_CHANNEL_ID: String = "vonage_video_call_channel" + const val ONGOING_CALL_CHANNEL_ID: String = "vonage_ongoing_video_call_channel" + } +} diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/OpenTokConfig.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/OpenTokConfig.kt new file mode 100644 index 00000000..aa39c0c1 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/OpenTokConfig.kt @@ -0,0 +1,43 @@ +package com.vonage.basic_video_chat_connectionservice + +import android.text.TextUtils + +object OpenTokConfig { + /* + Fill the following variables using your own Project info from the OpenTok dashboard + https://dashboard.tokbox.com/projects + + Note that this application will ignore credentials in the `OpenTokConfig` file when `CHAT_SERVER_URL` contains a + valid URL. + */ + // Replace with a API key + const val API_KEY: String = "" + + // Replace with a generated Session ID + const val SESSION_ID: String = "" + + // Replace with a generated token (from the dashboard or using an OpenTok server SDK) + const val TOKEN: String = "" + + val isValid: Boolean + // *** The code below is to validate this configuration file. You do not need to modify it *** + get() { + if (TextUtils.isEmpty(API_KEY) + || TextUtils.isEmpty(SESSION_ID) + || TextUtils.isEmpty(TOKEN) + ) { + return false + } + + return true + } + + val description: String + get() = (""" + OpenTokConfig: + API_KEY: $API_KEY + SESSION_ID: $SESSION_ID + TOKEN: $TOKEN + + """.trimIndent()) +} diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/OpenTokErrorDialog.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/OpenTokErrorDialog.kt new file mode 100644 index 00000000..3cd241a8 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/OpenTokErrorDialog.kt @@ -0,0 +1,50 @@ +package com.vonage.basic_video_chat_connectionservice + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.opentok.android.OpentokError + +@Composable +fun OpenTokErrorDialog( + error: OpentokError, + onDismiss: () -> Unit, + onEndCall: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Connection error") }, + text = { + Column { + Text("A communication error happened:") + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = error.message ?: "Unknown error", + fontWeight = FontWeight.Bold + ) + Text("Código: ${error.errorCode}") + } + }, + confirmButton = { + TextButton(onClick = onEndCall) { + Text("Finish") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Continue") + } + }, + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.error + ) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/VonageManager.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/VonageManager.kt new file mode 100644 index 00000000..3235774d --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/VonageManager.kt @@ -0,0 +1,245 @@ +package com.vonage.basic_video_chat_connectionservice + +import android.content.Context +import android.opengl.GLSurfaceView +import android.os.Handler +import android.os.Looper +import android.telecom.DisconnectCause +import android.util.Log +import com.opentok.android.AudioDeviceManager +import com.opentok.android.BaseAudioDevice.AudioFocusManager +import com.opentok.android.BaseVideoRenderer +import com.opentok.android.OpentokError +import com.opentok.android.Publisher +import com.opentok.android.PublisherKit +import com.opentok.android.PublisherKit.PublisherListener +import com.opentok.android.Session +import com.opentok.android.Stream +import com.opentok.android.Subscriber +import com.opentok.android.SubscriberKit +import com.opentok.android.SubscriberKit.SubscriberListener +import com.vonage.basic_video_chat_connectionservice.connectionservice.VonageConnectionHolder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.launch + +class VonageManager( + private val context: Context, + private val audioDeviceManager: AudioDeviceManager, + private val callHolder: CallHolder +) { + private var session: Session? = null + private var publisher: Publisher? = null + private var subscriber: Subscriber? = null + private var audioFocusManager: AudioFocusManager? = null + + private val _errorFlow = MutableStateFlow(null) + val errorFlow: StateFlow = _errorFlow.asStateFlow() + + private val publisherListener: PublisherListener = object : PublisherListener { + override fun onStreamCreated(publisherKit: PublisherKit, stream: Stream) { + Log.d(TAG, "onStreamCreated: Publisher Stream Created. Own stream " + stream.streamId) + } + + override fun onStreamDestroyed(publisherKit: PublisherKit, stream: Stream) { + Log.d( + TAG, + "onStreamDestroyed: Publisher Stream Destroyed. Own stream " + stream.streamId + ) + } + + override fun onError(publisherKit: PublisherKit, opentokError: OpentokError) { + handleError(opentokError) + } + } + + private val sessionListener: Session.SessionListener = object : Session.SessionListener { + override fun onConnected(session: Session) { + Log.d(TAG, "onConnected: Connected to session: " + session.sessionId) + + publisher?.destroy() + + val publisher = Publisher.Builder(context).build() + this@VonageManager.publisher = publisher + + publisher.setPublisherListener(publisherListener) + publisher.renderer + .setStyle(BaseVideoRenderer.STYLE_VIDEO_SCALE, BaseVideoRenderer.STYLE_VIDEO_FILL) + + callHolder.setPublisherView(publisher.view) + + if (publisher.view is GLSurfaceView) { + (publisher.view as GLSurfaceView).setZOrderOnTop(true) + } + + session.publish(publisher) + + CoroutineScope(Dispatchers.IO).launch { + val currentState = callHolder.callStateFlow.firstOrNull() + if (currentState == CallState.ANSWERING || currentState == CallState.DIALING) { + callHolder.updateCallState(CallState.CONNECTED) + } + } + } + + override fun onDisconnected(session: Session) { + Log.d(TAG, "onDisconnected: Disconnected from session: " + session.sessionId) + + callHolder.setPublisherView(null) + } + + override fun onStreamReceived(session: Session, stream: Stream) { + Log.d( + TAG, + "onStreamReceived: New Stream Received " + stream.streamId + " in session: " + session.sessionId + ) + + if (subscriber == null) { + val subscriber = Subscriber.Builder(context, stream).build() + this@VonageManager.subscriber = subscriber + + subscriber.renderer.setStyle( + BaseVideoRenderer.STYLE_VIDEO_SCALE, + BaseVideoRenderer.STYLE_VIDEO_FILL + ) + subscriber.setSubscriberListener(subscriberListener) + session.subscribe(subscriber) + callHolder.addSubscriberView(streamId = stream.streamId, view = subscriber.view) + } + } + + override fun onStreamDropped(session: Session, stream: Stream) { + Log.d( + TAG, + "onStreamDropped: Stream Dropped: " + stream.streamId + " in session: " + session.sessionId + ) + + callHolder.removeSubscriberView(streamId = stream.streamId) + } + + override fun onError(session: Session, opentokError: OpentokError) { + handleError(opentokError) + } + } + + var subscriberListener: SubscriberListener = object : SubscriberListener { + override fun onConnected(subscriberKit: SubscriberKit) { + Log.d( + TAG, + "onConnected: Subscriber connected. Stream: " + subscriberKit.stream.streamId + ) + } + + override fun onDisconnected(subscriberKit: SubscriberKit) { + Log.d( + TAG, + "onDisconnected: Subscriber disconnected. Stream: " + subscriberKit.stream.streamId + ) + } + + override fun onError(subscriberKit: SubscriberKit, opentokError: OpentokError) { + Log.e( + TAG, + "onError: Subscriber did error ${opentokError.message}. Stream: " + subscriberKit.stream.streamId + ) + } + } + + fun initializeSession(apiKey: String, sessionId: String, token: String) { + Log.i(TAG, "apiKey: $apiKey") + Log.i(TAG, "sessionId: $sessionId") + Log.i(TAG, "token: $token") + + val session = Session.Builder(context.applicationContext, apiKey, sessionId).build() + this.session = session + + session.setSessionListener(sessionListener) + session.connect(token) + } + + fun onResume() { + session?.onResume() + } + + fun onPause() { + session?.onPause() + } + + fun endSession() { + callHolder.clear() + + subscriber?.let { subscriber -> + session?.unsubscribe(subscriber) + } + + publisher?.let { publisher -> + session?.unpublish(publisher) + publisher.destroy() + } + + session?.disconnect() + + session = null + publisher = null + subscriber = null + } + + fun setAudioFocusManager() { + audioFocusManager = audioDeviceManager.audioFocusManager + + if (audioFocusManager == null) { + throw RuntimeException("Audio Focus Manager should have been granted") + } else { + audioFocusManager?.setRequestAudioFocus(false) + } + } + + fun notifyAudioFocusIsActive() { + Log.d("VonageCallManager", "notifyAudioFocusIsActive() called") + if (audioFocusManager == null) { + throw RuntimeException("Audio Focus Manager should have been granted") + } + audioFocusManager?.audioFocusActivated() + } + + fun notifyAudioFocusIsInactive() { + Log.d("VonageCallManager", "notifyAudioFocusIsInactive() called") + if (audioFocusManager == null) { + throw RuntimeException("Audio Focus Manager should have been granted") + } + audioFocusManager?.audioFocusDeactivated() + } + + fun endCall() { + VonageConnectionHolder.connection?.onDisconnect() + } + + fun setMuted(isMuted: Boolean) { + if (publisher != null) { + publisher!!.publishAudio = !isMuted + } + } + + fun handleError(error: OpentokError, terminateCall: Boolean = true) { + _errorFlow.value = error + + if (terminateCall) { + Handler(Looper.getMainLooper()).postDelayed({ + VonageConnectionHolder.connection?.onDisconnect(cause = DisconnectCause.ERROR) + endSession() + }, 500) + } + } + + fun clearError() { + _errorFlow.value = null + } + + companion object { + private val TAG: String = VonageManager::class.java.simpleName + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/PhoneAccountManager.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/PhoneAccountManager.kt new file mode 100644 index 00000000..f6a95f70 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/PhoneAccountManager.kt @@ -0,0 +1,136 @@ +package com.vonage.basic_video_chat_connectionservice.connectionservice + +import android.Manifest +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Color +import android.graphics.drawable.Icon +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.telecom.PhoneAccount +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import android.telecom.VideoProfile +import android.util.Log +import androidx.core.app.ActivityCompat +import com.vonage.basic_video_chat_connectionservice.R + +class PhoneAccountManager( + private val context: Context, + private val telecomManager: TelecomManager +) { + var handle: PhoneAccountHandle? = null + + fun registerPhoneAccount() { + + val componentName: ComponentName = + ComponentName(context, VonageConnectionService::class.java) + handle = PhoneAccountHandle(componentName, ACCOUNT_ID) + val phoneAccount = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + PhoneAccount.builder(handle, "Vonage Video") + .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED or PhoneAccount.CAPABILITY_VIDEO_CALLING) + .setHighlightColor(Color.BLUE) + .setIcon(Icon.createWithResource(context, R.mipmap.ic_launcher)) + .addSupportedUriScheme(PhoneAccount.SCHEME_TEL) + .addSupportedUriScheme(VONAGE_CALL_SCHEME) + .build() + } else { + PhoneAccount.builder(handle, "Vonage Video") + .setCapabilities(PhoneAccount.CAPABILITY_VIDEO_CALLING) + .setHighlightColor(Color.BLUE) + .setIcon(Icon.createWithResource(context, R.mipmap.ic_launcher)) + .addSupportedUriScheme(PhoneAccount.SCHEME_TEL) + .addSupportedUriScheme(VONAGE_CALL_SCHEME) + .build() + } + + telecomManager.registerPhoneAccount(phoneAccount) + Log.d("PhoneAccountManager", "PhoneAccount registered: " + phoneAccount.isEnabled) + } + + fun startOutgoingVideoCall(callerName: String?, callerId: String?) { + val extras = Bundle() + extras.putString(CALLER_NAME, callerName) + extras.putString(CALLER_ID, callerId) + extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, handle) + extras.putInt( + TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, + VideoProfile.STATE_BIDIRECTIONAL + ) + + val calleeUri = Uri.Builder() + .scheme(VONAGE_CALL_SCHEME) + .authority(callerId) + .appendQueryParameter("callerName", callerName) + .build() + + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.CALL_PHONE + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + telecomManager.placeCall(calleeUri, extras) + } + + fun notifyIncomingVideoCall(callerName: String?, callerId: String?) { + val extras = Bundle() + extras.putString(CALLER_NAME, callerName) + extras.putString(CALLER_ID, callerId) + extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, handle) + + val calleeUri = Uri.Builder() + .scheme(VONAGE_CALL_SCHEME) + .authority(callerId) + .appendQueryParameter("callerName", callerName) + .build() + + extras.putString(TelecomManager.EXTRA_INCOMING_CALL_ADDRESS, calleeUri.toString()) + + if (handle != null) { + telecomManager.addNewIncomingCall(handle, extras) + Log.d("PhoneAccountManager", "Incoming video call notified.") + } else { + Log.e( + "PhoneAccountManager", + "TelecomManager or PhoneAccountHandle is null. Cannot notify incoming call." + ) + } + } + + fun canPlaceIncomingCall(): Boolean { + if (handle == null) { + Log.e("PhoneAccountManager", "TelecomManager or PhoneAccountHandle is not initialized.") + return false + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + telecomManager.isIncomingCallPermitted(handle) + } else { + true + } + } + + fun canPlaceOutgoingCall(): Boolean { + if (handle == null) { + Log.e("PhoneAccountManager", "TelecomManager or PhoneAccountHandle is not initialized.") + return false + } + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + telecomManager.isOutgoingCallPermitted(handle) + } else { + true + } + } + + companion object { + const val ACCOUNT_ID: String = "vonage_video_call" + private const val VONAGE_CALL_SCHEME = "vonagecall" + var CALLER_NAME: String = "CALLER_NAME" + var CALLER_ID: String = "CALLER_ID" + } +} diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnection.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnection.kt new file mode 100644 index 00000000..c9d226f0 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnection.kt @@ -0,0 +1,358 @@ +package com.vonage.basic_video_chat_connectionservice.connectionservice + +import android.app.Notification +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Person +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.OutcomeReceiver +import android.telecom.CallAudioState +import android.telecom.CallEndpoint +import android.telecom.CallEndpointException +import android.telecom.Connection +import android.telecom.DisconnectCause +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import com.vonage.basic_video_chat_connectionservice.CallActionReceiver +import com.vonage.basic_video_chat_connectionservice.CallHolder +import com.vonage.basic_video_chat_connectionservice.CallState +import com.vonage.basic_video_chat_connectionservice.MainActivity +import com.vonage.basic_video_chat_connectionservice.NotificationChannelManager +import com.vonage.basic_video_chat_connectionservice.OpenTokConfig.API_KEY +import com.vonage.basic_video_chat_connectionservice.OpenTokConfig.SESSION_ID +import com.vonage.basic_video_chat_connectionservice.OpenTokConfig.TOKEN +import com.vonage.basic_video_chat_connectionservice.R +import com.vonage.basic_video_chat_connectionservice.VonageManager +import com.vonage.basic_video_chat_connectionservice.deviceselector.AudioDeviceSelectionListener +import com.vonage.basic_video_chat_connectionservice.deviceselector.AudioDeviceSelector + +class VonageConnection( + private val context: Context, + private val audioDeviceSelector: AudioDeviceSelector, + private val vonageManager: VonageManager, + private val callHolder: CallHolder, + private val remoteName: String, + private val callNotificationId: Int +) : Connection(), AudioDeviceSelectionListener { + + var onCallEnded: (() -> Unit)? = null + + override fun onSilence() { + super.onSilence() + Log.d(TAG, "onSilence") + + postIncomingCallNotification() + } + + fun onPlaceCall() { + Log.d(TAG, "onPlaceCall") + setActive() + vonageManager.initializeSession(API_KEY, SESSION_ID, TOKEN) + + callHolder.updateCallState(CallState.DIALING) + } + + override fun onAnswer() { + super.onAnswer() + Log.d(TAG, "onAnswer") + + setActive() + vonageManager.initializeSession(API_KEY, SESSION_ID, TOKEN) + postIncomingCallNotification() + updateOngoingCallNotification() + + callHolder.updateCallState(CallState.ANSWERING) + } + + override fun onDisconnect() { + super.onDisconnect() + + onDisconnect(DisconnectCause.LOCAL) + } + + fun onDisconnect(cause: Int) { + super.onDisconnect() + Log.d(TAG, "onDisconnect") + + callHolder.updateCallState(CallState.DISCONNECTED) + + vonageManager.endSession() + audioDeviceSelector.listener = null + setDisconnected(DisconnectCause(cause)) + + destroy() + + onCallEnded?.invoke() + } + + override fun onAbort() { + super.onAbort() + Log.d(TAG, "onAbort") + + onDisconnect() + } + + override fun onReject() { + super.onReject() + Log.d(TAG, "onReject") + + callHolder.updateCallState(CallState.DISCONNECTED) + + setDisconnected(DisconnectCause(DisconnectCause.REJECTED)) + + destroy() + + onCallEnded?.invoke() + } + + override fun onHold() { + super.onHold() + Log.d(TAG, "onHold") + + setOnHold() + vonageManager.setMuted(true) + + callHolder.updateCallState(CallState.HOLDING) + } + + override fun onUnhold() { + super.onUnhold() + Log.d(TAG, "onUnhold") + + setActive() + vonageManager.setMuted(false) + + callHolder.updateCallState(CallState.CONNECTED) + } + + override fun onStateChanged(state: Int) { + super.onStateChanged(state) + Log.d(TAG, "onStateChanged " + stateToString(state)) + } + + override fun onAvailableCallEndpointsChanged(endpoints: List) { + super.onAvailableCallEndpointsChanged(endpoints) + Log.d(TAG, "onAvailableCallEndpointsChanged") + + audioDeviceSelector.onAvailableCallEndpointsChanged(endpoints) + } + + override fun onCallEndpointChanged(endpoint: CallEndpoint) { + super.onCallEndpointChanged(endpoint) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + Log.d("VonageConnection", "Active audio endpoint changed to: " + endpoint.endpointType) + } + + audioDeviceSelector.onCallEndpointChanged(endpoint) + } + + @RequiresApi(api = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + private fun changeCallEndpoint(endpoint: CallEndpoint) { + val executor = ContextCompat.getMainExecutor(context) + + requestCallEndpointChange( + endpoint, + executor, + object : OutcomeReceiver { + override fun onResult(result: Void?) { + Log.d( + "VonageConnection", + "Successfully switched to endpoint: " + endpoint.endpointType + ) + } + + override fun onError(error: CallEndpointException) { + Log.e("VonageConnection", "Failed to switch endpoint: " + error.message) + } + }) + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + override fun onCallAudioStateChanged(state: CallAudioState?) { + super.onCallAudioStateChanged(state) + + Log.d("VonageConnection", "Current audio route: " + state?.route) + + state?.let { + audioDeviceSelector.onCallAudioStateChanged(it) + } + } + + + @Suppress("DEPRECATION") + override fun onAudioDeviceSelected(device: AudioDeviceSelector.AudioDevice) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + device.endpoint?.let { + changeCallEndpoint(it) + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setAudioRoute(device.type) + } + } + + override fun onMuteStateChanged(isMuted: Boolean) { + super.onMuteStateChanged(isMuted) + Log.d(TAG, "onMuteStateChanged") + + vonageManager.setMuted(isMuted) + } + + private fun postIncomingCallNotification() { + val notification = getIncomingCallNotification(false) + notification.flags = notification.flags or Notification.FLAG_INSISTENT + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + notificationManager.notify(callNotificationId, notification) + } + + fun getIncomingCallNotification(isRinging: Boolean): Notification { + // Create an intent which triggers your fullscreen incoming call user interface. + val intent = Intent(Intent.ACTION_MAIN, null) + intent.flags = Intent.FLAG_ACTIVITY_NO_USER_ACTION or Intent.FLAG_ACTIVITY_NEW_TASK + intent.setClass(context, MainActivity::class.java) + val pendingIntent = + PendingIntent.getActivity(context, 1, intent, PendingIntent.FLAG_IMMUTABLE) + + // Build the notification as an ongoing high priority item; this ensures it will show as + // a heads up notification which slides down over top of the current content. + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, NotificationChannelManager.INCOMING_CALL_CHANNEL_ID) + } else { + Notification.Builder(context) + } + builder.setOngoing(true) + builder.setPriority(Notification.PRIORITY_HIGH) + builder.setOnlyAlertOnce(!isRinging) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setColorized(true) + } + // Set notification content intent to take user to fullscreen UI if user taps on the + // notification body. + builder.setContentIntent(pendingIntent) + // Set full screen intent to trigger display of the fullscreen UI when the notification + // manager deems it appropriate. + builder.setFullScreenIntent(pendingIntent, true) + + builder.setSmallIcon(R.drawable.ic_stat_ic_notification) + builder.setContentTitle("Incoming call") + builder.setContentText("$remoteName is calling...") + builder.setColor(-0xde690d) + + val answerIntent = Intent( + context, + CallActionReceiver::class.java + ) + answerIntent.action = CallActionReceiver.ACTION_ANSWER_CALL + val answerPendingIntent = PendingIntent.getBroadcast( + context, + CallActionReceiver.ACTION_ANSWER_CALL_ID, + answerIntent, + PendingIntent.FLAG_IMMUTABLE + ) + + val rejectIntent = Intent( + context, + CallActionReceiver::class.java + ) + rejectIntent.action = CallActionReceiver.ACTION_REJECT_CALL + val rejectPendingIntent = PendingIntent.getBroadcast( + context, + CallActionReceiver.ACTION_REJECT_CALL_ID, + rejectIntent, + PendingIntent.FLAG_IMMUTABLE + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val caller = Person.Builder() + .setName(remoteName) + .setImportant(true) + .build() + builder.style = Notification.CallStyle.forIncomingCall( + caller, + rejectPendingIntent, + answerPendingIntent + ) + } else { + builder.addAction( + Notification.Action.Builder( + R.drawable.answer_call, "Answer", answerPendingIntent + ).build() + ) + builder.addAction( + Notification.Action.Builder( + R.drawable.end_call, "Reject", rejectPendingIntent + ).build() + ) + } + + return builder.build() + } + + private fun updateOngoingCallNotification() { + val notificationManager = context.getSystemService( + NotificationManager::class.java + ) + if (notificationManager != null) { + val notification = ongoingCallNotification + notificationManager.notify(callNotificationId, notification) + } + } + + val ongoingCallNotification: Notification + get() { + val hangupIntent = Intent( + context, + CallActionReceiver::class.java + ) + hangupIntent.action = CallActionReceiver.ACTION_END_CALL + val hangupPendingIntent = PendingIntent.getBroadcast( + context, + CallActionReceiver.ACTION_END_CALL_ID, + hangupIntent, + PendingIntent.FLAG_IMMUTABLE + ) + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, NotificationChannelManager.ONGOING_CALL_CHANNEL_ID) + } else { + Notification.Builder(context) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder.setColorized(true) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val caller = Person.Builder() + .setName(remoteName) + .setImportant(true) + .build() + builder.style = Notification.CallStyle.forOngoingCall(caller, hangupPendingIntent) + } else { + builder.addAction( + Notification.Action.Builder( + R.drawable.end_call, "End call", hangupPendingIntent + ).build() + ) + } + + builder.setColor(-0xde690d) + builder.setOngoing(true) + .setContentTitle("Ongoing call") + .setContentText("Talking with $remoteName...") + .setSmallIcon(R.drawable.ic_stat_ic_notification) + .setOnlyAlertOnce(true) + .setUsesChronometer(true) + .setWhen(System.currentTimeMillis()) + + return builder.build() + } + + companion object { + private val TAG: String = VonageConnection::class.java.simpleName + } +} diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnectionHolder.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnectionHolder.kt new file mode 100644 index 00000000..52e03e65 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnectionHolder.kt @@ -0,0 +1,8 @@ +package com.vonage.basic_video_chat_connectionservice.connectionservice + +import android.annotation.SuppressLint + +object VonageConnectionHolder { + @SuppressLint("StaticFieldLeak") + var connection: VonageConnection? = null +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnectionService.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnectionService.kt new file mode 100644 index 00000000..f1190ba7 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/connectionservice/VonageConnectionService.kt @@ -0,0 +1,179 @@ +package com.vonage.basic_video_chat_connectionservice.connectionservice + +import android.app.Notification +import android.os.Build +import android.telecom.Connection +import android.telecom.ConnectionRequest +import android.telecom.ConnectionService +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import android.util.Log +import com.vonage.basic_video_chat_connectionservice.Call +import com.vonage.basic_video_chat_connectionservice.CallHolder +import com.vonage.basic_video_chat_connectionservice.CallState +import com.vonage.basic_video_chat_connectionservice.VonageManager +import com.vonage.basic_video_chat_connectionservice.deviceselector.AudioDeviceSelector +import dagger.hilt.android.AndroidEntryPoint +import java.util.Random +import javax.inject.Inject + +@AndroidEntryPoint +class VonageConnectionService : ConnectionService() { + + @Inject + lateinit var vonageManager: VonageManager + @Inject + lateinit var audioDeviceSelector: AudioDeviceSelector + @Inject + lateinit var callHolder: CallHolder + + override fun onCreateOutgoingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle, + request: ConnectionRequest + ): Connection { + val addressUri = request.address + val callerName = addressUri.getQueryParameter("callerName") ?: "" + val random = Random() + val randomValue = random.nextInt() * random.nextInt() + + val call = Call( + callID = randomValue, + name = callerName, + state = CallState.CONNECTING + ) + callHolder.setCall(call) + + val connection = VonageConnection( + context = applicationContext, + audioDeviceSelector = audioDeviceSelector, + vonageManager = vonageManager, + remoteName = callerName, + callNotificationId = randomValue, + callHolder = callHolder + ) + + connection.onCallEnded = { + connection.onCallEnded = null + onCallEnded() + } + + connection.setInitialized() + + connection.setCallerDisplayName(callerName, TelecomManager.PRESENTATION_ALLOWED) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + connection.connectionProperties = Connection.PROPERTY_SELF_MANAGED + } + + connection.audioModeIsVoip = true + connection.videoState = request.videoState + + val capabilities = + Connection.CAPABILITY_HOLD or Connection.CAPABILITY_SUPPORT_HOLD or Connection.CAPABILITY_MUTE + connection.connectionCapabilities = capabilities + + audioDeviceSelector.listener = connection + + VonageConnectionHolder.connection = connection + + val notification: Notification = connection.ongoingCallNotification + startForeground(randomValue, notification) + + connection.onPlaceCall() + + return connection + } + + override fun onCreateIncomingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle, + request: ConnectionRequest + ): Connection { + val extras = request.extras + val callerName = extras.getString(PhoneAccountManager.CALLER_NAME) ?: "" + + val random = Random() + val randomValue = random.nextInt() * random.nextInt() + + val call = Call( + callID = randomValue, + name = callerName, + state = CallState.CONNECTING + ) + callHolder.setCall(call) + + val connection = VonageConnection( + context = applicationContext, + audioDeviceSelector = audioDeviceSelector, + vonageManager = vonageManager, + remoteName = callerName, + callNotificationId = randomValue, + callHolder = callHolder + ) + connection.onCallEnded = { + connection.onCallEnded = null + onCallEnded() + } + connection.setRinging() + connection.setCallerDisplayName(callerName, TelecomManager.PRESENTATION_ALLOWED) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + connection.connectionProperties = Connection.PROPERTY_SELF_MANAGED + } + + connection.audioModeIsVoip = true + connection.videoState = request.videoState + + val capabilities = + Connection.CAPABILITY_HOLD or Connection.CAPABILITY_SUPPORT_HOLD or Connection.CAPABILITY_MUTE + connection.connectionCapabilities = capabilities + + audioDeviceSelector.listener = connection + + VonageConnectionHolder.connection = connection + + val notification = connection.getIncomingCallNotification(true) + startForeground(randomValue, notification) + + return connection + } + + override fun onCreateIncomingConnectionFailed( + connectionManagerPhoneAccount: PhoneAccountHandle, + request: ConnectionRequest + ) { + Log.e(TAG, "Incoming connection failed: " + request.address) + VonageConnectionHolder.connection = null + } + + override fun onCreateOutgoingConnectionFailed( + connectionManagerPhoneAccount: PhoneAccountHandle, + request: ConnectionRequest + ) { + Log.e(TAG, "Outgoing connection failed: " + request.address) + VonageConnectionHolder.connection = null + } + + override fun onConnectionServiceFocusGained() { + super.onConnectionServiceFocusGained() + + vonageManager.notifyAudioFocusIsActive() + Log.d(TAG, "onConnectionServiceFocusGained") + } + + override fun onConnectionServiceFocusLost() { + super.onConnectionServiceFocusLost() + + vonageManager.notifyAudioFocusIsInactive() + Log.d(TAG, "onConnectionServiceFocusLost") + } + + private fun onCallEnded() { + stopForeground(STOP_FOREGROUND_REMOVE) + VonageConnectionHolder.connection = null + callHolder.setCall(null) + } + + companion object { + private val TAG: String = VonageConnectionService::class.java.simpleName + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelectionListener.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelectionListener.kt new file mode 100644 index 00000000..5c72641a --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelectionListener.kt @@ -0,0 +1,9 @@ +package com.vonage.basic_video_chat_connectionservice.deviceselector + +interface AudioDeviceSelectionListener { + /** + * Is called when the user selects an audio device. + * @param device The selected audio device. + */ + fun onAudioDeviceSelected(device: AudioDeviceSelector.AudioDevice) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelector.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelector.kt new file mode 100644 index 00000000..613f884c --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelector.kt @@ -0,0 +1,149 @@ +package com.vonage.basic_video_chat_connectionservice.deviceselector + +import android.os.Build +import android.telecom.CallAudioState +import android.telecom.CallEndpoint +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + + +class AudioDeviceSelector { + private val TAG: String = AudioDeviceSelector::class.java.simpleName + + class AudioDevice { + val name: String + val type: Int + val endpoint: CallEndpoint? + + internal constructor(name: String, type: Int) { + this.name = name + this.type = type + this.endpoint = null + } + + internal constructor(name: String, type: Int, endpoint: CallEndpoint?) { + this.name = name + this.type = type + this.endpoint = endpoint + } + } + + private val _availableDevices = MutableStateFlow>(emptyList()) + val availableDevices: StateFlow> = _availableDevices.asStateFlow() + + private val _activeDevice = MutableStateFlow(null) + val activeDevice: StateFlow = _activeDevice.asStateFlow() + + var listener: AudioDeviceSelectionListener? = null + + fun onAvailableCallEndpointsChanged(endpoints: List) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return + } + + val devices: MutableList = ArrayList() + + for (endpoint in endpoints) { + val name: String + val type: Int + + when (endpoint.endpointType) { + CallEndpoint.TYPE_BLUETOOTH -> { + name = "Bluetooth" + type = CallAudioState.ROUTE_BLUETOOTH + } + + CallEndpoint.TYPE_WIRED_HEADSET -> { + name = "Wired Headset" + type = CallAudioState.ROUTE_WIRED_HEADSET + } + + CallEndpoint.TYPE_SPEAKER -> { + name = "Speaker" + type = CallAudioState.ROUTE_SPEAKER + } + + CallEndpoint.TYPE_EARPIECE -> { + name = "Earpiece" + type = CallAudioState.ROUTE_EARPIECE + } + + else -> { + name = "Unknown" + type = 0 + } + } + + devices.add(AudioDevice(name, type, endpoint)) + } + + _availableDevices.value = devices + } + + fun onCallEndpointChanged(endpoint: CallEndpoint) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return + } + + val devices = availableDevices.value ?: return + + for (device in devices) { + if (device.endpoint != null && + device.endpoint.endpointType == endpoint.endpointType + ) { + _activeDevice.value = device + break + } + } + } + + fun onCallAudioStateChanged(audioState: CallAudioState) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + // In Android 14+, use CallEndpoint for audio routing + return + } + + val devices: MutableList = ArrayList() + val supportedRoutes = audioState.supportedRouteMask + val currentRoute = audioState.route + + if ((supportedRoutes and CallAudioState.ROUTE_EARPIECE) != 0) { + val device = AudioDevice("Earpiece", CallAudioState.ROUTE_EARPIECE) + devices.add(device) + if (currentRoute == CallAudioState.ROUTE_EARPIECE) { + _activeDevice.value = device + } + } + + if ((supportedRoutes and CallAudioState.ROUTE_BLUETOOTH) != 0) { + val device = AudioDevice("Bluetooth", CallAudioState.ROUTE_BLUETOOTH) + devices.add(device) + if (currentRoute == CallAudioState.ROUTE_BLUETOOTH) { + _activeDevice.value = device + } + } + + if ((supportedRoutes and CallAudioState.ROUTE_WIRED_HEADSET) != 0) { + val device = AudioDevice("Wired Headset", CallAudioState.ROUTE_WIRED_HEADSET) + devices.add(device) + if (currentRoute == CallAudioState.ROUTE_WIRED_HEADSET) { + _activeDevice.value = device + } + } + + if ((supportedRoutes and CallAudioState.ROUTE_SPEAKER) != 0) { + val device = AudioDevice("Speaker", CallAudioState.ROUTE_SPEAKER) + devices.add(device) + if (currentRoute == CallAudioState.ROUTE_SPEAKER) { + _activeDevice.value = device + } + } + + _availableDevices.value = devices + } + + fun selectDevice(device: AudioDevice) { + listener?.onAudioDeviceSelected(device) + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelectorDialog.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelectorDialog.kt new file mode 100644 index 00000000..80c4699c --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/deviceselector/AudioDeviceSelectorDialog.kt @@ -0,0 +1,67 @@ +package com.vonage.basic_video_chat_connectionservice.deviceselector + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun AudioDeviceSelectorDialog( + audioDeviceSelector: AudioDeviceSelector, + onDismissRequest: () -> Unit +) { + val availableDevices by audioDeviceSelector.availableDevices.collectAsState() + val activeDevice by audioDeviceSelector.activeDevice.collectAsState() + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text("Audio devices") }, + text = { + LazyColumn { + items(availableDevices) { device -> + val isSelected = device == activeDevice + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + audioDeviceSelector.selectDevice(device) + onDismissRequest() + } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = { + audioDeviceSelector.selectDevice(device) + onDismissRequest() + } + ) + Spacer(modifier = Modifier.width(8.dp)) + Text(text = device.name) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismissRequest) { + Text("Close") + } + } + ) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/di/AppModule.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/di/AppModule.kt new file mode 100644 index 00000000..c269d10d --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/di/AppModule.kt @@ -0,0 +1,89 @@ +package com.vonage.basic_video_chat_connectionservice.di + +import android.content.Context +import android.content.Context.POWER_SERVICE +import android.os.PowerManager +import android.telecom.TelecomManager +import com.opentok.android.AudioDeviceManager +import com.vonage.basic_video_chat_connectionservice.CallHolder +import com.vonage.basic_video_chat_connectionservice.VonageManager +import com.vonage.basic_video_chat_connectionservice.connectionservice.PhoneAccountManager +import com.vonage.basic_video_chat_connectionservice.deviceselector.AudioDeviceSelector +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton +import com.vonage.basic_video_chat_connectionservice.NotificationChannelManager + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Singleton + fun provideVonageManager( + @ApplicationContext context: Context, + audioDeviceManager: AudioDeviceManager, + callHolder: CallHolder + ): VonageManager { + val manager = VonageManager(context, audioDeviceManager, callHolder) + manager.setAudioFocusManager() + return manager + } + + @Provides + @Singleton + fun provideAudioDeviceManager( + @ApplicationContext context: Context + ): AudioDeviceManager { + return AudioDeviceManager(context) + } + + @Provides + @Singleton + fun provideAudioDeviceSelector( + ): AudioDeviceSelector { + return AudioDeviceSelector() + } + + @Provides + @Singleton + fun providePhoneAccountManager( + @ApplicationContext context: Context, + telecomManager: TelecomManager + ): PhoneAccountManager { + return PhoneAccountManager(context, telecomManager) + } + + @Provides + @Singleton + fun provideTelecomManager( + @ApplicationContext context: Context + ): TelecomManager { + return context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + } + + @Provides + @Singleton + fun providePowerManager( + @ApplicationContext context: Context + ): PowerManager { + return context.getSystemService(POWER_SERVICE) as PowerManager + } + + @Provides + @Singleton + fun provideNotificationChannelManager( + @ApplicationContext context: Context + ): NotificationChannelManager { + return NotificationChannelManager(context) + } + + @Provides + @Singleton + fun provideCallHolder( + ): CallHolder { + return CallHolder() + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/CallErrorDialog.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/CallErrorDialog.kt new file mode 100644 index 00000000..9e30f0dc --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/CallErrorDialog.kt @@ -0,0 +1,30 @@ +package com.vonage.basic_video_chat_connectionservice.home + +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import com.vonage.basic_video_chat_connectionservice.CallException + +@Composable +fun CallErrorDialog( + error: Exception, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Error") }, + text = { + if (error is CallException) { + Text("Error (${error.code}): ${error.message}") + } else { + Text(error.message ?: "Error desconocido") + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text("Aceptar") + } + } + ) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/HomeView.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/HomeView.kt new file mode 100644 index 00000000..6853575b --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/HomeView.kt @@ -0,0 +1,59 @@ +package com.vonage.basic_video_chat_connectionservice.home + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Button +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun HomeView(homeViewModel: HomeViewModel, modifier: Modifier) { + val error by homeViewModel.errorFlow.collectAsState() + + HomeView( + onOutgoingCall = homeViewModel::startOutgoingCall, + onIncomingCall = homeViewModel::startIncomingCall, + modifier = modifier) + + error?.let { exception -> + CallErrorDialog( + error = exception, + onDismiss = { homeViewModel.clearError() } + ) + } +} + +@Composable +fun HomeView( + onOutgoingCall: () -> Unit = {}, + onIncomingCall: () -> Unit = {}, + modifier: Modifier +) { + + Column(modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center) { + Button(onClick = { + onIncomingCall() + }) { + Text(text = "Incoming Call") + } + Button(onClick = { + onOutgoingCall() + }) { + Text(text = "Outgoing Call") + } + } +} + +@Preview +@Composable +fun HomeViewPreview() { + HomeView(modifier = Modifier) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/HomeViewModel.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/HomeViewModel.kt new file mode 100644 index 00000000..ee2294e5 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/home/HomeViewModel.kt @@ -0,0 +1,45 @@ +package com.vonage.basic_video_chat_connectionservice.home + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vonage.basic_video_chat_connectionservice.usecases.StartIncomingCallUseCase +import com.vonage.basic_video_chat_connectionservice.usecases.StartOutgoingCallUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + private val startIncomingCallUseCase: StartIncomingCallUseCase, + private val startOutgoingCallUseCase: StartOutgoingCallUseCase +): ViewModel() { + + private val _errorFlow = MutableStateFlow(null) + val errorFlow = _errorFlow.asStateFlow() + + fun startOutgoingCall() { + viewModelScope.launch { + try { + startOutgoingCallUseCase() + } catch (exception: Exception) { + _errorFlow.value = exception + } + } + } + + fun startIncomingCall() { + viewModelScope.launch { + try { + startIncomingCallUseCase() + } catch (exception: Exception) { + _errorFlow.value = exception + } + } + } + + fun clearError() { + _errorFlow.value = null + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/PublisherView.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/PublisherView.kt new file mode 100644 index 00000000..ae4fc2f1 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/PublisherView.kt @@ -0,0 +1,26 @@ +package com.vonage.basic_video_chat_connectionservice.room + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +@Composable +fun PublisherView( + androidView: android.view.View?, + modifier: Modifier = Modifier +) { + AndroidView( + factory = { context -> + android.widget.FrameLayout(context) + }, + update = { container -> + container.removeAllViews() + + androidView?.let { view -> + (view.parent as? android.view.ViewGroup)?.removeView(view) + container.addView(view) + } + }, + modifier = modifier + ) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/RoomView.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/RoomView.kt new file mode 100644 index 00000000..c9918941 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/RoomView.kt @@ -0,0 +1,176 @@ +package com.vonage.basic_video_chat_connectionservice.room + +import android.view.View +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun RoomView(roomViewModel: RoomViewModel, onShowAudioDevicesClick: () -> Unit) { + val callStatus by roomViewModel.callStatusName.collectAsState() + val callName by roomViewModel.callName.collectAsState() + val isOnHold by roomViewModel.isOnHold.collectAsState() + val publisherView by roomViewModel.publisherViewFlow.collectAsState() + val subscriberViews by roomViewModel.subscriberViewsFlow.collectAsState() + + RoomView( + callStatus = callStatus, + participantName = callName, + isPublisherVisible = true, + isOnHoldVisible = isOnHold, + publisherView = publisherView, + subscriberViews = subscriberViews, + onShowAudioDevicesClick = onShowAudioDevicesClick, + onHangUpClick = roomViewModel::onEndCall, + onUnHoldClick = roomViewModel::onUnhold + ) +} + +@Composable +fun RoomView( + callStatus: String, + participantName: String, + isPublisherVisible: Boolean, + isOnHoldVisible: Boolean, + publisherView: View?? = null, + subscriberViews: List = emptyList(), + onShowAudioDevicesClick: () -> Unit, + onHangUpClick: () -> Unit, + onUnHoldClick: () -> Unit +) { + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + Column( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0x80000000)) + .padding(top = statusBarHeight + 8.dp, bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (callStatus.isNotEmpty()) { + Text( + text = callStatus, + color = Color.White, + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ) + } + if (participantName.isNotEmpty()) { + Text( + text = participantName, + color = Color.White, + fontSize = 16.sp + ) + } + } + + Button( + onClick = onShowAudioDevicesClick, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE91E63)), + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 10.dp) + ) { + Text("Audio") + } + } + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + ) { + SubscriberGrid(subscriberViews) + + if (isPublisherVisible && publisherView != null) { + Box( + modifier = Modifier + .size(width = 90.dp, height = 120.dp) + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 16.dp) + .background(Color(0xFFCCCCCC)) + ) { + PublisherView( + androidView = publisherView, + modifier = Modifier.fillMaxSize() + ) + } + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .background(Color(0x80000000)) + .padding( + vertical = 8.dp, + horizontal = 16.dp + ).padding( + bottom = navBarHeight + 8.dp + ), + verticalAlignment = Alignment.CenterVertically + ) { + Button( + onClick = onHangUpClick, + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFE91E63)) + ) { + Text("End call") + } + + if (isOnHoldVisible) { + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = onUnHoldClick) { + Text("Unhold call") + } + } + + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Preview +@Composable +fun RoomViewPreview() { + RoomView( + callStatus = "In call", + participantName = "John Doe", + isPublisherVisible = true, + isOnHoldVisible = false, + publisherView = null, + subscriberViews = emptyList(), + onShowAudioDevicesClick = {}, + onHangUpClick = {}, + onUnHoldClick = {} + ) +} + diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/RoomViewModel.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/RoomViewModel.kt new file mode 100644 index 00000000..1896692d --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/RoomViewModel.kt @@ -0,0 +1,76 @@ +package com.vonage.basic_video_chat_connectionservice.room + +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vonage.basic_video_chat_connectionservice.CallHolder +import com.vonage.basic_video_chat_connectionservice.CallState +import com.vonage.basic_video_chat_connectionservice.usecases.EndCallUseCase +import com.vonage.basic_video_chat_connectionservice.usecases.UnholdUseCase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject + +@HiltViewModel +class RoomViewModel @Inject constructor( +private val callHolder: CallHolder, +private val endCallUseCase: EndCallUseCase, +private val unholdUseCase: UnholdUseCase +): ViewModel() { + + val publisherViewFlow = callHolder.publisherViewFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + null + ) + + val subscriberViewsFlow = callHolder.subscriberViewFlow.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + emptyList() + ) + + val callStatusName = callHolder.callStateFlow + .map { state -> + when (state) { + CallState.IDLE -> "Idle" + CallState.CONNECTING -> "Connecting" + CallState.CONNECTED -> "Connected" + CallState.DIALING -> "Dialing" + CallState.ANSWERING -> "Answering" + CallState.HOLDING -> "Holding" + CallState.DISCONNECTED -> "Disconnected" + } + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + "Idle" + ) + + val callName = callHolder.callFlow + .map { call -> + call?.name ?: "No Call" + }.stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + "No Call" + ) + + val isOnHold = callHolder.callStateFlow + .map { it == CallState.HOLDING } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + false + ) + + fun onEndCall() { + endCallUseCase() + } + + fun onUnhold() { + unholdUseCase() + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/SubscriberGrid.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/SubscriberGrid.kt new file mode 100644 index 00000000..7365bca5 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/room/SubscriberGrid.kt @@ -0,0 +1,59 @@ +package com.vonage.basic_video_chat_connectionservice.room + +import android.view.View +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun SubscriberGrid( + subscriberViews: List, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val screenWidth = maxWidth + val screenHeight = maxHeight + + val columns = when { + subscriberViews.isEmpty() -> 1 + subscriberViews.size == 1 -> 1 + subscriberViews.size <= 4 -> 2 + subscriberViews.size <= 9 -> 3 + else -> 4 + } + + val cellWidth = screenWidth / columns + val cellHeight = cellWidth * 4/3 + + LazyVerticalGrid( + columns = GridCells.Fixed(columns), + modifier = Modifier.padding(4.dp) + ) { + items(subscriberViews.size) { index -> + val subscriberView = subscriberViews[index] + Box( + modifier = Modifier + .width(cellWidth - 8.dp) + .height(cellHeight - 8.dp) + .padding(4.dp) + .background(Color(0xFFCCCCCC)) + ) { + PublisherView( + androidView = subscriberView, + modifier = Modifier.fillMaxSize() + ) + } + } + } + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Color.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Color.kt new file mode 100644 index 00000000..e43611d5 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.vonage.basic_video_chat_connectionservice.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Theme.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Theme.kt new file mode 100644 index 00000000..2bc863b7 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.vonage.basic_video_chat_connectionservice.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun BasicVideoChatConnectionServiceKotlinTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Type.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Type.kt new file mode 100644 index 00000000..5f8a56cc --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.vonage.basic_video_chat_connectionservice.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/EndCallUseCase.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/EndCallUseCase.kt new file mode 100644 index 00000000..b902bd44 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/EndCallUseCase.kt @@ -0,0 +1,11 @@ +package com.vonage.basic_video_chat_connectionservice.usecases + +import com.vonage.basic_video_chat_connectionservice.connectionservice.VonageConnectionHolder +import javax.inject.Inject + +class EndCallUseCase @Inject constructor() { + + operator fun invoke() { + VonageConnectionHolder.connection?.onDisconnect() + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/StartIncomingCallUseCase.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/StartIncomingCallUseCase.kt new file mode 100644 index 00000000..7237aa2b --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/StartIncomingCallUseCase.kt @@ -0,0 +1,26 @@ +package com.vonage.basic_video_chat_connectionservice.usecases + +import com.vonage.basic_video_chat_connectionservice.CallException +import com.vonage.basic_video_chat_connectionservice.OpenTokConfig +import com.vonage.basic_video_chat_connectionservice.connectionservice.PhoneAccountManager +import javax.inject.Inject + +class StartIncomingCallUseCase @Inject constructor( +private val phoneAccountManager: PhoneAccountManager +) { + + operator fun invoke() { + val callerName = "Simulated Caller" + val callerId = "+4401539702257" + + if (!OpenTokConfig.isValid) { + throw CallException("Invalid credentials", 1001) + } + + if (!phoneAccountManager.canPlaceIncomingCall()) { + throw CallException("Can't launch incoming call", 1001) + } + + phoneAccountManager.notifyIncomingVideoCall(callerName, callerId) + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/StartOutgoingCallUseCase.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/StartOutgoingCallUseCase.kt new file mode 100644 index 00000000..4ddf3eeb --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/StartOutgoingCallUseCase.kt @@ -0,0 +1,26 @@ +package com.vonage.basic_video_chat_connectionservice.usecases + +import com.vonage.basic_video_chat_connectionservice.CallException +import com.vonage.basic_video_chat_connectionservice.OpenTokConfig +import com.vonage.basic_video_chat_connectionservice.connectionservice.PhoneAccountManager +import javax.inject.Inject + +class StartOutgoingCallUseCase @Inject constructor( +private val phoneAccountManager: PhoneAccountManager +) { + + operator fun invoke() { + val callerName = "Simulated Caller" + val callerId = "+4401539702257" + + if (!OpenTokConfig.isValid) { + throw CallException("Invalid credentials", 1001) + } + + if (!phoneAccountManager.canPlaceOutgoingCall()) { + throw CallException("Can't launch outgoing call", 1001) + } + + phoneAccountManager.startOutgoingVideoCall(callerName, callerId) + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/UnholdUseCase.kt b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/UnholdUseCase.kt new file mode 100644 index 00000000..f89e5400 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/java/com/vonage/basic_video_chat_connectionservice/usecases/UnholdUseCase.kt @@ -0,0 +1,11 @@ +package com.vonage.basic_video_chat_connectionservice.usecases + +import com.vonage.basic_video_chat_connectionservice.connectionservice.VonageConnectionHolder +import javax.inject.Inject + +class UnholdUseCase @Inject constructor() { + + operator fun invoke() { + VonageConnectionHolder.connection?.onUnhold() + } +} \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/answer_call.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/answer_call.xml new file mode 100644 index 00000000..d16e9854 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/answer_call.xml @@ -0,0 +1,9 @@ + + + diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/end_call.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/end_call.xml new file mode 100644 index 00000000..d0a7d883 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/end_call.xml @@ -0,0 +1,9 @@ + + + diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_launcher_background.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 00000000..07d5da9c --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_launcher_foreground.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 00000000..2b068d11 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_stat_ic_notification.png b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_stat_ic_notification.png new file mode 100644 index 00000000..54a85c91 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/drawable/ic_stat_ic_notification.png differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 00000000..036d09bc --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 00000000..5bd08962 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..5d623543 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 00000000..e80d9cd0 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 00000000..ffdbc8f7 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..a7d29d6c Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 00000000..da606b9f Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 00000000..ba97bab3 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..e7958aea Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..94c27d0c Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 00000000..c279caae Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..0e544185 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..bd7c93b2 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 00000000..80581d45 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 00000000..0ac60b59 Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 00000000..1600939a Binary files /dev/null and b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/colors.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/colors.xml new file mode 100644 index 00000000..f8c6127d --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/ic_launcher_background.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 00000000..c5d5899f --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/strings.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..a16fa23c --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Basic Video Chat ConnectionService Kotlin + \ No newline at end of file diff --git a/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/themes.xml b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..bb54a3b6 --- /dev/null +++ b/Basic-Video-Chat-ConnectionService-Kotlin/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +