From f8506c315eee59897a9546fa16ee3fab7296fabd Mon Sep 17 00:00:00 2001 From: Liviu Timar <65943217+liviu-timar@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:56:16 +0300 Subject: [PATCH] Implement direct calls with Stream Google users for staging app (#863) Add Direct Call screen and functionality --- dogfooding/build.gradle.kts | 5 + dogfooding/src/main/AndroidManifest.xml | 2 +- ...gCallActivity.kt => DirectCallActivity.kt} | 8 +- .../video/android/IncomingCallActivity.kt | 2 + .../android/data/dto/GetGoogleAccountsDto.kt | 54 ++++ .../repositories/GoogleAccountRepository.kt | 141 +++++++++ .../getstream/video/android/di/AppModule.kt | 8 + .../video/android/models/GoogleAccount.kt | 25 ++ .../video/android/models/StreamUser.kt | 25 ++ .../video/android/ui/DogfoodingNavHost.kt | 19 +- .../video/android/ui/join/CallJoinScreen.kt | 25 +- .../video/android/ui/login/FirebaseSignIn.kt | 42 --- .../video/android/ui/login/GoogleSignIn.kt | 56 ++++ .../android/ui/login/GoogleSignInLauncher.kt | 55 ++++ .../video/android/ui/login/LoginScreen.kt | 33 +- .../video/android/ui/login/LoginViewModel.kt | 39 ++- .../android/ui/outgoing/DebugCallScreen.kt | 143 --------- .../ui/outgoing/DirectCallJoinScreen.kt | 281 ++++++++++++++++++ .../ui/outgoing/DirectCallViewModel.kt | 92 ++++++ .../android/ui/theme/StreamImageButton.kt | 53 ++++ .../video/android/util/GoogleSignInHelper.kt | 36 +++ .../android/util/StreamVideoInitHelper.kt | 2 +- .../{UserIdGenerator.kt => UserIdHelper.kt} | 8 +- dogfooding/src/main/res/values/strings.xml | 3 + gradle/libs.versions.toml | 1 + 25 files changed, 903 insertions(+), 255 deletions(-) rename dogfooding/src/main/kotlin/io/getstream/video/android/{RingCallActivity.kt => DirectCallActivity.kt} (96%) create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/data/dto/GetGoogleAccountsDto.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/data/repositories/GoogleAccountRepository.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/models/GoogleAccount.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/models/StreamUser.kt delete mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/FirebaseSignIn.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt delete mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DebugCallScreen.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallViewModel.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/ui/theme/StreamImageButton.kt create mode 100644 dogfooding/src/main/kotlin/io/getstream/video/android/util/GoogleSignInHelper.kt rename dogfooding/src/main/kotlin/io/getstream/video/android/util/{UserIdGenerator.kt => UserIdHelper.kt} (87%) diff --git a/dogfooding/build.gradle.kts b/dogfooding/build.gradle.kts index be88329662..54434df794 100644 --- a/dogfooding/build.gradle.kts +++ b/dogfooding/build.gradle.kts @@ -242,6 +242,8 @@ dependencies { implementation(libs.androidx.hilt.navigation) implementation(libs.landscapist.coil) implementation(libs.accompanist.permission) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.coil.compose) // hilt implementation(libs.hilt.android) @@ -251,6 +253,9 @@ dependencies { implementation(libs.firebase.crashlytics) implementation(libs.firebase.analytics) + // moshi + implementation(libs.moshi.kotlin) + // Play Install Referrer library - used to extract the meeting link from demo flow after install implementation(libs.play.install.referrer) diff --git a/dogfooding/src/main/AndroidManifest.xml b/dogfooding/src/main/AndroidManifest.xml index 019a480f28..98b0ab252a 100644 --- a/dogfooding/src/main/AndroidManifest.xml +++ b/dogfooding/src/main/AndroidManifest.xml @@ -93,7 +93,7 @@ , ): Intent { - return Intent(context, RingCallActivity::class.java).apply { + return Intent(context, DirectCallActivity::class.java).apply { putExtra(EXTRA_CID, callId) putExtra(EXTRA_MEMBERS_ARRAY, members.toTypedArray()) } diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt index 650a2ec5d7..02e83869b1 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/IncomingCallActivity.kt @@ -27,6 +27,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope +import dagger.hilt.android.AndroidEntryPoint import io.getstream.result.Result import io.getstream.video.android.compose.theme.VideoTheme import io.getstream.video.android.compose.ui.components.call.activecall.CallContent @@ -46,6 +47,7 @@ import io.getstream.video.android.util.StreamVideoInitHelper import kotlinx.coroutines.launch import javax.inject.Inject +@AndroidEntryPoint class IncomingCallActivity : ComponentActivity() { @Inject diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/data/dto/GetGoogleAccountsDto.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/data/dto/GetGoogleAccountsDto.kt new file mode 100644 index 0000000000..bfec26d004 --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/data/dto/GetGoogleAccountsDto.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.data.dto + +import io.getstream.video.android.models.GoogleAccount +import io.getstream.video.android.util.UserIdHelper +import java.util.Locale + +data class GetGoogleAccountsResponseDto( + val people: List, +) + +data class GoogleAccountDto( + val photos: List?, + val emailAddresses: List, +) + +data class PhotoDto( + val url: String, +) + +data class EmailAddressDto( + val value: String, +) + +fun GoogleAccountDto.asDomainModel(): GoogleAccount { + val email = emailAddresses.firstOrNull()?.value + + return GoogleAccount( + email = email, + id = email?.let { UserIdHelper.getUserIdFromEmail(it) }, + name = email + ?.split("@") + ?.firstOrNull() + ?.split(".") + ?.firstOrNull() + ?.capitalize(Locale.ROOT) ?: email, + photoUrl = photos?.firstOrNull()?.url, + ) +} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/data/repositories/GoogleAccountRepository.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/data/repositories/GoogleAccountRepository.kt new file mode 100644 index 0000000000..f6d74c2e86 --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/data/repositories/GoogleAccountRepository.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.data.repositories + +import android.content.Context +import android.util.Log +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.common.api.ApiException +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import dagger.hilt.android.qualifiers.ApplicationContext +import io.getstream.video.android.data.dto.GetGoogleAccountsResponseDto +import io.getstream.video.android.data.dto.asDomainModel +import io.getstream.video.android.models.GoogleAccount +import io.getstream.video.android.util.GoogleSignInHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import javax.inject.Inject + +class GoogleAccountRepository @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val baseUrl = "https://people.googleapis.com/v1/people:listDirectoryPeople" + + suspend fun getAllAccounts(): List? { + val readMask = "readMask=emailAddresses,names,photos" + val sources = "sources=DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE" + val pageSize = "pageSize=1000" + + return if (silentSignIn()) { + GoogleSignIn.getLastSignedInAccount(context)?.let { account -> + withContext(Dispatchers.IO) { + getAccessToken(account)?.let { accessToken -> + val urlString = "$baseUrl?access_token=$accessToken&$readMask&$sources&$pageSize" + val request = buildRequest(urlString) + val okHttpClient = buildOkHttpClient() + var responseBody: String? + + okHttpClient.newCall(request).execute().let { response -> + if (response.isSuccessful) { + responseBody = response.body?.string() + responseBody?.let { parseUserListJson(it) } + } else { + null + } + } + } + } + } + } else { + null + } + } + + private fun silentSignIn(): Boolean { // Used to refresh token + val gsc = GoogleSignInHelper.getGoogleSignInClient(context) + val task = gsc.silentSignIn() + + // Below code needed for debugging silent sign-in failures + if (task.isSuccessful) { + Log.d("Google Silent Sign In", "Successful") + return true + } else { + task.addOnCompleteListener { + try { + val signInAccount = task.getResult(ApiException::class.java) + Log.d("Google Silent Sign In", signInAccount.email.toString()) + } catch (apiException: ApiException) { + // You can get from apiException.getStatusCode() the detailed error code + // e.g. GoogleSignInStatusCodes.SIGN_IN_REQUIRED means user needs to take + // explicit action to finish sign-in; + // Please refer to GoogleSignInStatusCodes Javadoc for details + Log.d("Google Silent Sign In", apiException.statusCode.toString()) + } + } + return false + } + } + + private fun getAccessToken(account: GoogleSignInAccount) = + try { + GoogleAuthUtil.getToken( + context, + account.account, + "oauth2:profile email", + ) + } catch (e: Exception) { + null + } + + private fun buildRequest(urlString: String) = Request.Builder() + .url(urlString.toHttpUrl()) + .build() + + private fun buildOkHttpClient() = OkHttpClient.Builder() + .retryOnConnectionFailure(true) + .build() + + @OptIn(ExperimentalStdlibApi::class) + private fun parseUserListJson(jsonString: String): List? { + val moshi: Moshi = Moshi.Builder() + .add(KotlinJsonAdapterFactory()) + .build() + val jsonAdapter: JsonAdapter = moshi.adapter() + + val response = jsonAdapter.fromJson(jsonString) + return response?.people?.map { it.asDomainModel() } + } + + fun getCurrentUser(): GoogleAccount { + val currentUser = GoogleSignIn.getLastSignedInAccount(context) + return GoogleAccount( + email = currentUser?.email ?: "", + id = currentUser?.id ?: "", + name = currentUser?.givenName ?: "", + photoUrl = currentUser?.photoUrl?.toString(), + isFavorite = false, + ) + } +} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/di/AppModule.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/di/AppModule.kt index f34c1e8acc..fe3e20bca3 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/di/AppModule.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/di/AppModule.kt @@ -16,9 +16,12 @@ package io.getstream.video.android.di +import android.content.Context import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import io.getstream.video.android.data.repositories.GoogleAccountRepository import io.getstream.video.android.datastore.delegate.StreamUserDataStore import javax.inject.Singleton @@ -31,4 +34,9 @@ object AppModule { fun provideUserDataStore(): StreamUserDataStore { return StreamUserDataStore.instance() } + + @Provides + fun provideGoogleAccountRepository( + @ApplicationContext context: Context, + ) = GoogleAccountRepository(context) } diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/models/GoogleAccount.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/models/GoogleAccount.kt new file mode 100644 index 0000000000..befc88a1d4 --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/models/GoogleAccount.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.models + +data class GoogleAccount( + val email: String?, + val id: String?, + val name: String?, + val photoUrl: String?, + val isFavorite: Boolean = false, +) diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/models/StreamUser.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/models/StreamUser.kt new file mode 100644 index 0000000000..1dbb21bc8a --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/models/StreamUser.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.models + +data class StreamUser( + val email: String, + val id: String, + val name: String, + val avatarUrl: String?, + val isFavorite: Boolean, +) diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/DogfoodingNavHost.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/DogfoodingNavHost.kt index 213a75be88..5340646d34 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/DogfoodingNavHost.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/DogfoodingNavHost.kt @@ -26,11 +26,11 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import io.getstream.video.android.RingCallActivity +import io.getstream.video.android.DirectCallActivity import io.getstream.video.android.ui.join.CallJoinScreen import io.getstream.video.android.ui.lobby.CallLobbyScreen import io.getstream.video.android.ui.login.LoginScreen -import io.getstream.video.android.ui.outgoing.DebugCallScreen +import io.getstream.video.android.ui.outgoing.DirectCallJoinScreen @Composable fun AppNavHost( @@ -62,8 +62,8 @@ fun AppNavHost( popUpTo(AppScreens.CallJoin.destination) { inclusive = true } } }, - navigateToRingTest = { - navController.navigate(AppScreens.DebugCall.destination) + navigateToDirectCallJoin = { + navController.navigate(AppScreens.DirectCallJoin.destination) }, ) } @@ -79,15 +79,14 @@ fun AppNavHost( }, ) } - composable(AppScreens.DebugCall.destination) { + composable(AppScreens.DirectCallJoin.destination) { val context = LocalContext.current - DebugCallScreen( - navigateToRingCall = { callId, members -> + DirectCallJoinScreen( + navigateToDirectCall = { members -> context.startActivity( - RingCallActivity.createIntent( + DirectCallActivity.createIntent( context, members = members.split(","), - callId = callId, ), ) }, @@ -100,5 +99,5 @@ enum class AppScreens(val destination: String) { Login("login"), CallJoin("call_join"), CallLobby("call_preview"), - DebugCall("debug_call"), + DirectCallJoin("direct_call_join"), } diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt index d89102641a..0f0e384092 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/join/CallJoinScreen.kt @@ -94,7 +94,7 @@ fun CallJoinScreen( callJoinViewModel: CallJoinViewModel = hiltViewModel(), navigateToCallLobby: (callId: String) -> Unit, navigateUpToLogin: () -> Unit, - navigateToRingTest: () -> Unit, + navigateToDirectCallJoin: () -> Unit, ) { val uiState by callJoinViewModel.uiState.collectAsState(CallJoinUiState.Nothing) val isLoggedOut by callJoinViewModel.isLoggedOut.collectAsState(initial = false) @@ -115,7 +115,7 @@ fun CallJoinScreen( ) { CallJoinHeader( callJoinViewModel = callJoinViewModel, - onRingTestClicked = navigateToRingTest, + onDirectCallClick = navigateToDirectCallJoin, ) CallJoinBody( @@ -144,7 +144,7 @@ fun CallJoinScreen( @Composable private fun CallJoinHeader( callJoinViewModel: CallJoinViewModel = hiltViewModel(), - onRingTestClicked: () -> Unit, + onDirectCallClick: () -> Unit, ) { val user by callJoinViewModel.user.collectAsState(initial = null) @@ -167,18 +167,21 @@ private fun CallJoinHeader( Text( modifier = Modifier.weight(1f), color = Color.White, - text = user?.name?.ifBlank { user?.id }?.ifBlank { user!!.custom["email"] } - .orEmpty(), + text = user?.name?.ifBlank { user?.id }?.ifBlank { user!!.custom["email"] }.orEmpty(), maxLines = 1, fontSize = 16.sp, ) if (BuildConfig.FLAVOR == "dogfooding") { - TextButton( - colors = ButtonDefaults.textButtonColors(contentColor = Color.White), - content = { Text(text = "Ring test") }, - onClick = { onRingTestClicked.invoke() }, - ) + if (user?.custom?.get("email")?.contains("getstreamio") == true) { + TextButton( + colors = ButtonDefaults.textButtonColors(contentColor = Color.White), + content = { Text(text = stringResource(R.string.direct_call)) }, + onClick = { onDirectCallClick.invoke() }, + ) + + Spacer(modifier = Modifier.width(5.dp)) + } StreamButton( modifier = Modifier.widthIn(125.dp), @@ -417,7 +420,7 @@ private fun CallJoinScreenPreview() { callJoinViewModel = CallJoinViewModel(StreamUserDataStore.instance()), navigateToCallLobby = {}, navigateUpToLogin = {}, - navigateToRingTest = {}, + navigateToDirectCallJoin = {}, ) } } diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/FirebaseSignIn.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/FirebaseSignIn.kt deleted file mode 100644 index 2bccfa3717..0000000000 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/FirebaseSignIn.kt +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-video-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.video.android.ui.login - -import androidx.activity.ComponentActivity -import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.compose.runtime.Composable -import com.firebase.ui.auth.FirebaseAuthUIActivityResultContract - -@Composable -fun rememberRegisterForActivityResult( - onSignInSuccess: (email: String) -> Unit, - onSignInFailed: () -> Unit, -) = rememberLauncherForActivityResult( - FirebaseAuthUIActivityResultContract(), -) { result -> - - if (result.resultCode != ComponentActivity.RESULT_OK) { - onSignInFailed.invoke() - } - - val email = result?.idpResponse?.email - if (email != null) { - onSignInSuccess(email) - } else { - onSignInFailed() - } -} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt new file mode 100644 index 0000000000..f878a6a3ef --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignIn.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.ui.login + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.tasks.Task + +@Composable +fun rememberRegisterForActivityResult( + onSignInSuccess: (email: String) -> Unit, + onSignInFailed: () -> Unit, +): ManagedActivityResultLauncher { + return rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode != ComponentActivity.RESULT_OK) { + onSignInFailed.invoke() + } + + val task: Task = GoogleSignIn.getSignedInAccountFromIntent(result.data) + try { + val account = task.getResult(ApiException::class.java) + + account?.email?.let { + onSignInSuccess(it) + } ?: onSignInFailed() + } catch (e: ApiException) { + // The ApiException status code indicates the detailed failure reason. + // Please refer to the GoogleSignInStatusCodes class reference for more information. + onSignInFailed() + } + } +} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt new file mode 100644 index 0000000000..091789d667 --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/GoogleSignInLauncher.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.ui.login + +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.tasks.Task + +@Composable +fun rememberLauncherForGoogleSignInActivityResult( + onSignInSuccess: (email: String) -> Unit, + onSignInFailed: () -> Unit, +): ManagedActivityResultLauncher { + return rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> + if (result.resultCode != ComponentActivity.RESULT_OK) { + onSignInFailed() + } else { + val task: Task = GoogleSignIn.getSignedInAccountFromIntent( + result.data, + ) + try { + val account = task.getResult(ApiException::class.java) + account?.email?.let { onSignInSuccess(it) } ?: onSignInFailed() + } catch (e: ApiException) { + // The ApiException status code indicates the detailed failure reason. + // Please refer to the GoogleSignInStatusCodes class reference for more information. + onSignInFailed() + } + } + } +} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt index 7f569173fb..98fb565807 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginScreen.kt @@ -64,7 +64,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.core.content.ContextCompat.startActivity import androidx.hilt.navigation.compose.hiltViewModel -import com.firebase.ui.auth.AuthUI import io.getstream.video.android.BuildConfig import io.getstream.video.android.R import io.getstream.video.android.compose.theme.VideoTheme @@ -72,6 +71,8 @@ import io.getstream.video.android.ui.theme.Colors import io.getstream.video.android.ui.theme.LinkText import io.getstream.video.android.ui.theme.LinkTextData import io.getstream.video.android.ui.theme.StreamButton +import io.getstream.video.android.util.GoogleSignInHelper +import io.getstream.video.android.util.UserIdHelper @Composable fun LoginScreen( @@ -188,7 +189,7 @@ private fun LoginContent( text = "Login for Benchmark", onClick = { loginViewModel.handleUiEvent( - LoginEvent.SignInInSuccess("benchmark.test@getstream.io"), + LoginEvent.SignInSuccess("benchmark.test@getstream.io"), ) }, ) @@ -242,11 +243,8 @@ private fun EmailLoginDialog( .fillMaxWidth() .padding(horizontal = 16.dp), onClick = { - val userId = email - .replace(" ", "") - .replace(".", "") - .replace("@", "") - loginViewModel.handleUiEvent(LoginEvent.SignInInSuccess(userId)) + val userId = UserIdHelper.getUserIdFromEmail(email) + loginViewModel.handleUiEvent(LoginEvent.SignInSuccess(userId)) }, text = "Log in", ) @@ -263,34 +261,27 @@ private fun HandleLoginUiStates( loginViewModel: LoginViewModel = hiltViewModel(), ) { val context = LocalContext.current - val signInLauncher = rememberRegisterForActivityResult( + val signInLauncher = rememberLauncherForGoogleSignInActivityResult( onSignInSuccess = { email -> - val userId = email - .replace(" ", "") - .replace(".", "") - .replace("@", "") - loginViewModel.handleUiEvent(LoginEvent.SignInInSuccess(userId = userId)) + val userId = UserIdHelper.getUserIdFromEmail(email) + loginViewModel.handleUiEvent(LoginEvent.SignInSuccess(userId = userId)) }, onSignInFailed = { + loginViewModel.handleUiEvent(LoginEvent.Nothing) Toast.makeText(context, "Verification failed!", Toast.LENGTH_SHORT).show() }, ) LaunchedEffect(key1 = Unit) { - loginViewModel.sigInInIfValidUserExist() + loginViewModel.signInIfValidUserExist() } LaunchedEffect(key1 = loginUiState) { when (loginUiState) { is LoginUiState.GoogleSignIn -> { - val providers = arrayListOf( - AuthUI.IdpConfig.GoogleBuilder().build(), + signInLauncher.launch( + GoogleSignInHelper.getGoogleSignInClient(context).signInIntent, ) - - val signInIntent = AuthUI.getInstance().createSignInIntentBuilder() - .setAvailableProviders(providers) - .build() - signInLauncher.launch(signInIntent) } is LoginUiState.SignInComplete -> { diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginViewModel.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginViewModel.kt index 52c50ef236..1f137d2f56 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginViewModel.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/login/LoginViewModel.kt @@ -18,18 +18,18 @@ package io.getstream.video.android.ui.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.google.firebase.auth.FirebaseAuth import dagger.hilt.android.lifecycle.HiltViewModel import io.getstream.log.streamLog import io.getstream.video.android.API_KEY import io.getstream.video.android.BuildConfig import io.getstream.video.android.core.StreamVideo +import io.getstream.video.android.data.repositories.GoogleAccountRepository import io.getstream.video.android.datastore.delegate.StreamUserDataStore import io.getstream.video.android.model.User import io.getstream.video.android.token.StreamVideoNetwork import io.getstream.video.android.token.TokenResponse import io.getstream.video.android.util.StreamVideoInitHelper -import io.getstream.video.android.util.UserIdGenerator +import io.getstream.video.android.util.UserIdHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow @@ -48,6 +48,7 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( private val dataStore: StreamUserDataStore, + private val googleAccountRepository: GoogleAccountRepository, ) : ViewModel() { private val event: MutableSharedFlow = MutableSharedFlow() @@ -56,7 +57,7 @@ class LoginViewModel @Inject constructor( when (event) { is LoginEvent.Loading -> flowOf(LoginUiState.Loading) is LoginEvent.GoogleSignIn -> flowOf(LoginUiState.GoogleSignIn) - is LoginEvent.SignInInSuccess -> signInInSuccess(event.userId) + is LoginEvent.SignInSuccess -> signInSuccess(event.userId) else -> flowOf(LoginUiState.Nothing) } }.shareIn(viewModelScope, SharingStarted.Lazily, 0) @@ -65,36 +66,34 @@ class LoginViewModel @Inject constructor( viewModelScope.launch { this@LoginViewModel.event.emit(event) } } - private fun signInInSuccess(email: String) = flow { + private fun signInSuccess(email: String) = flow { // skip login if we are already logged in (use has navigated back) if (StreamVideo.isInstalled) { emit(LoginUiState.AlreadyLoggedIn) } else { try { - val response = StreamVideoNetwork.tokenService.fetchToken( + val tokenResponse = StreamVideoNetwork.tokenService.fetchToken( userId = email, apiKey = API_KEY, ) - // if we are logged in with Google account then read the data (demo app doesn't have - // firebase login) - val authFirebaseUser = FirebaseAuth.getInstance().currentUser + val loggedInUser = googleAccountRepository.getCurrentUser() val user = User( - id = response.userId, - name = authFirebaseUser?.displayName ?: "", - image = authFirebaseUser?.photoUrl?.toString() ?: "", + id = tokenResponse.userId, + name = loggedInUser.name ?: "", + image = loggedInUser.photoUrl ?: "", role = "admin", - custom = mapOf("email" to response.userId), + custom = mapOf("email" to tokenResponse.userId), ) // Store the data in the demo app dataStore.updateUser(user) - dataStore.updateUserToken(response.token) + dataStore.updateUserToken(tokenResponse.token) // Init the Video SDK with the data StreamVideoInitHelper.loadSdk(dataStore) - emit(LoginUiState.SignInComplete(response)) + emit(LoginUiState.SignInComplete(tokenResponse)) } catch (exception: Throwable) { emit(LoginUiState.SignInFailure(exception.message ?: "General error")) streamLog { "Failed to fetch token - cause: $exception" } @@ -103,17 +102,17 @@ class LoginViewModel @Inject constructor( }.flowOn(Dispatchers.IO) init { - sigInInIfValidUserExist() + signInIfValidUserExist() } - fun sigInInIfValidUserExist() { + fun signInIfValidUserExist() { viewModelScope.launch { val user = dataStore.user.firstOrNull() if (user != null) { handleUiEvent(LoginEvent.Loading) if (!BuildConfig.BENCHMARK.toBoolean()) { delay(10) - handleUiEvent(LoginEvent.SignInInSuccess(userId = user.id)) + handleUiEvent(LoginEvent.SignInSuccess(userId = user.id)) } } else { // Production apps have an automatic guest login. Logging the user out @@ -121,8 +120,8 @@ class LoginViewModel @Inject constructor( if (BuildConfig.FLAVOR == "production") { handleUiEvent(LoginEvent.Loading) handleUiEvent( - LoginEvent.SignInInSuccess( - UserIdGenerator.generateRandomString(upperCaseOnly = true), + LoginEvent.SignInSuccess( + UserIdHelper.generateRandomString(upperCaseOnly = true), ), ) } @@ -152,5 +151,5 @@ sealed interface LoginEvent { data class GoogleSignIn(val id: String = UUID.randomUUID().toString()) : LoginEvent - data class SignInInSuccess(val userId: String) : LoginEvent + data class SignInSuccess(val userId: String) : LoginEvent } diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DebugCallScreen.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DebugCallScreen.kt deleted file mode 100644 index 5e405ac27b..0000000000 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DebugCallScreen.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. - * - * Licensed under the Stream License; - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://github.com/GetStream/stream-video-android/blob/main/LICENSE - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.getstream.video.android.ui.outgoing - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.Text -import androidx.compose.material.TextField -import androidx.compose.material.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import io.getstream.video.android.compose.theme.VideoTheme -import io.getstream.video.android.mock.StreamMockUtils -import io.getstream.video.android.ui.theme.Colors -import io.getstream.video.android.ui.theme.StreamButton - -@Composable -fun DebugCallScreen( - navigateToRingCall: (callId: String, membersList: String) -> Unit, -) { - var callId by remember { mutableStateOf("") } - var membersList by remember { mutableStateOf("") } - - VideoTheme { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier - .fillMaxSize() - .background(Colors.background) - .padding(12.dp), - horizontalAlignment = Alignment.Start, - ) { - Text( - modifier = Modifier - .fillMaxWidth(), - text = "Call ID (optional)", - color = Color(0xFF979797), - fontSize = 13.sp, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - TextField( - modifier = Modifier.border( - BorderStroke(1.dp, Color(0xFF4C525C)), - RoundedCornerShape(6.dp), - ), - value = callId, - onValueChange = { callId = it }, - colors = TextFieldDefaults.textFieldColors( - textColor = Color.White, - focusedLabelColor = VideoTheme.colors.primaryAccent, - unfocusedIndicatorColor = Colors.secondBackground, - focusedIndicatorColor = Colors.secondBackground, - backgroundColor = Colors.secondBackground, - ), - ) - - Spacer(modifier = Modifier.height(12.dp)) - - Text( - modifier = Modifier - .fillMaxWidth(), - text = "Members list - separated by comma", - color = Color(0xFF979797), - fontSize = 13.sp, - ) - - Spacer(modifier = Modifier.height(4.dp)) - - TextField( - modifier = Modifier.border( - BorderStroke(1.dp, Color(0xFF4C525C)), - RoundedCornerShape(6.dp), - ), - value = membersList, - onValueChange = { membersList = it }, - colors = TextFieldDefaults.textFieldColors( - textColor = Color.White, - focusedLabelColor = VideoTheme.colors.primaryAccent, - unfocusedIndicatorColor = Colors.secondBackground, - focusedIndicatorColor = Colors.secondBackground, - backgroundColor = Colors.secondBackground, - ), - ) - - Spacer(modifier = Modifier.height(4.dp)) - - StreamButton( - modifier = Modifier, - onClick = { - navigateToRingCall.invoke(callId, membersList) - }, - text = "Ring", - ) - } - } - } -} - -@Preview -@Composable -private fun DebugCallScreenPreview() { - StreamMockUtils.initializeStreamVideo(LocalContext.current) - VideoTheme { - DebugCallScreen( - navigateToRingCall = { _, _ -> }, - ) - } -} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt new file mode 100644 index 0000000000..c903087172 --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallJoinScreen.kt @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.ui.outgoing + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.RadioButton +import androidx.compose.material.RadioButtonDefaults +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.size.Size +import io.getstream.video.android.compose.theme.VideoTheme +import io.getstream.video.android.compose.ui.components.avatar.UserAvatar +import io.getstream.video.android.core.R +import io.getstream.video.android.mock.StreamMockUtils +import io.getstream.video.android.model.User +import io.getstream.video.android.ui.theme.Colors +import io.getstream.video.android.ui.theme.StreamImageButton + +@Composable +fun DirectCallJoinScreen( + viewModel: DirectCallViewModel = hiltViewModel(), + navigateToDirectCall: (memberList: String) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = Unit) { viewModel.getGoogleAccounts() } + + VideoTheme { + Column( + modifier = Modifier + .fillMaxSize() + .background(Colors.background), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Header(user = uiState.currentUser) + + Body( + uiState = uiState, + toggleUserSelection = { viewModel.toggleGoogleAccountSelection(it) }, + onStartCallClick = navigateToDirectCall, + ) + } + } +} + +@Composable +private fun Header(user: User?) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) // Outer padding + .padding(vertical = 12.dp), // Inner padding + verticalAlignment = Alignment.CenterVertically, + ) { + user?.let { + UserAvatar( + modifier = Modifier.size(24.dp), + userName = it.userNameOrId, + userImage = it.image, + ) + Spacer(modifier = Modifier.width(8.dp)) + } + + Text( + modifier = Modifier.weight(1f), + color = Color.White, + text = user?.name?.ifBlank { user.id }?.ifBlank { user.custom["email"] }.orEmpty(), + maxLines = 1, + fontSize = 16.sp, + ) + + Text( + text = stringResource(io.getstream.video.android.R.string.select_direct_call_users), + color = Color(0xFF979797), + fontSize = 13.sp, + ) + } +} + +@Composable +private fun Body( + uiState: DirectCallUiState, + toggleUserSelection: (Int) -> Unit, + onStartCallClick: (membersList: String) -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp), + ) { + if (uiState.isLoading) { + CircularProgressIndicator( + modifier = Modifier + .size(50.dp) + .align(Alignment.Center), + color = VideoTheme.colors.primaryAccent, + ) + } else { + uiState.googleAccounts?.let { users -> + UserList( + entries = users, + onUserClick = { clickedIndex -> toggleUserSelection(clickedIndex) }, + ) + StreamImageButton( // Floating button + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 10.dp), + enabled = users.any { it.isSelected }, + imageRes = R.drawable.stream_video_ic_call, + onClick = { + onStartCallClick( + users + .filter { it.isSelected } + .joinToString(separator = ",") { it.account.id ?: "" }, + ) + }, + ) + } ?: Text( + text = stringResource(io.getstream.video.android.R.string.cannot_load_google_account_list), + modifier = Modifier.align(Alignment.Center).padding(horizontal = 24.dp), + color = Color.White, + fontSize = 16.sp, + textAlign = TextAlign.Center, + lineHeight = 24.sp, + ) + } + } +} + +@Composable +private fun UserList(entries: List, onUserClick: (Int) -> Unit) { + Column(Modifier.verticalScroll(rememberScrollState())) { + entries.forEachIndexed { index, entry -> + UserRow( + index = index, + name = entry.account.name ?: "", + avatarUrl = entry.account.photoUrl, + isSelected = entry.isSelected, + onClick = { onUserClick(index) }, + ) + Spacer(modifier = Modifier.height(10.dp)) + } + } +} + +@Composable +private fun UserRow( + index: Int, + name: String, + avatarUrl: String?, + isSelected: Boolean, + onClick: (Int) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick(index) }, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserAvatar(avatarUrl) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = name, + color = Color.White, + fontSize = 16.sp, + ) + } + RadioButton( + selected = isSelected, + modifier = Modifier.size(20.dp), + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = VideoTheme.colors.primaryAccent, + unselectedColor = Color.LightGray, + ), + ) + } +} + +@Composable +private fun UserAvatar(url: String?) { + NetworkImage( + url = url ?: "", + modifier = Modifier + .size(50.dp) + .clip(shape = CircleShape), + crossfadeMillis = 200, + alpha = 0.8f, + error = ColorPainter(color = Color.DarkGray), + fallback = ColorPainter(color = Color.DarkGray), + placeholder = ColorPainter(color = Color.DarkGray), + ) +} + +@Composable +private fun NetworkImage( + url: String, + modifier: Modifier = Modifier, + crossfadeMillis: Int = 0, + alpha: Float = 1f, + error: Painter? = null, + fallback: Painter? = null, + placeholder: Painter? = null, +) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(url) + .size(Size.ORIGINAL) + .crossfade(durationMillis = crossfadeMillis) + .build(), + contentDescription = null, + modifier = modifier, + contentScale = ContentScale.Crop, + alpha = alpha, + error = error, + fallback = fallback, + placeholder = placeholder, + ) +} + +@Preview +@Composable +private fun DebugCallScreenPreview() { + StreamMockUtils.initializeStreamVideo(LocalContext.current) + VideoTheme { + DirectCallJoinScreen( + navigateToDirectCall = {}, + ) + } +} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallViewModel.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallViewModel.kt new file mode 100644 index 0000000000..a665a9be76 --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/outgoing/DirectCallViewModel.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.ui.outgoing + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import io.getstream.video.android.data.repositories.GoogleAccountRepository +import io.getstream.video.android.datastore.delegate.StreamUserDataStore +import io.getstream.video.android.model.User +import io.getstream.video.android.models.GoogleAccount +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class DirectCallViewModel @Inject constructor( + private val userDataStore: StreamUserDataStore, + private val googleAccountRepository: GoogleAccountRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(DirectCallUiState()) + val uiState = _uiState.asStateFlow() + + init { + viewModelScope.launch { + _uiState.update { it.copy(currentUser = userDataStore.user.firstOrNull()) } + } + } + + fun getGoogleAccounts() { + _uiState.update { it.copy(isLoading = true) } + + viewModelScope.launch { + _uiState.update { + it.copy( + isLoading = false, + googleAccounts = googleAccountRepository.getAllAccounts()?.map { user -> + GoogleAccountUiState( + isSelected = false, + account = user, + ) + }, + ) + } + } + } + + fun toggleGoogleAccountSelection(selectedIndex: Int) { + _uiState.update { + it.copy( + googleAccounts = it.googleAccounts?.mapIndexed { index, accountUiState -> + if (index == selectedIndex) { + GoogleAccountUiState( + isSelected = !accountUiState.isSelected, + account = accountUiState.account, + ) + } else { + accountUiState + } + }, + ) + } + } +} + +data class DirectCallUiState( + val isLoading: Boolean = false, + val currentUser: User? = null, + val googleAccounts: List? = emptyList(), +) + +data class GoogleAccountUiState( + val isSelected: Boolean = false, + val account: GoogleAccount, +) diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/ui/theme/StreamImageButton.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/theme/StreamImageButton.kt new file mode 100644 index 0000000000..929ffad991 --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/ui/theme/StreamImageButton.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.ui.theme + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import io.getstream.video.android.compose.theme.VideoTheme + +@Composable +fun StreamImageButton( + modifier: Modifier, + enabled: Boolean = true, + @DrawableRes imageRes: Int, + onClick: () -> Unit, +) { + Button( + modifier = modifier, + enabled = enabled, + shape = CircleShape, + colors = ButtonDefaults.buttonColors( + backgroundColor = VideoTheme.colors.primaryAccent, + contentColor = VideoTheme.colors.primaryAccent, + disabledBackgroundColor = Colors.description, + disabledContentColor = Colors.description, + ), + onClick = onClick, + ) { + Image( + painter = painterResource(id = imageRes), + contentDescription = null, + ) + } +} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/util/GoogleSignInHelper.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/util/GoogleSignInHelper.kt new file mode 100644 index 0000000000..0a7399064b --- /dev/null +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/util/GoogleSignInHelper.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2014-2023 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-video-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.video.android.util + +import android.content.Context +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.Scope +import io.getstream.video.android.R + +object GoogleSignInHelper { + fun getGoogleSignInClient(context: Context): GoogleSignInClient { + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestIdToken(context.getString(R.string.default_web_client_id)) + .requestScopes(Scope("https://www.googleapis.com/auth/directory.readonly")) + .build() + + return GoogleSignIn.getClient(context, gso) + } +} diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt index ceb1382c76..06ddfa2d40 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/util/StreamVideoInitHelper.kt @@ -74,7 +74,7 @@ object StreamVideoInitHelper { // Create and login a random new user if user is null and we allow a random user login if (loggedInUser == null && useRandomUserAsFallback) { - val userId = UserIdGenerator.generateRandomString() + val userId = UserIdHelper.generateRandomString() val result = StreamVideoNetwork.tokenService.fetchToken( userId = userId, diff --git a/dogfooding/src/main/kotlin/io/getstream/video/android/util/UserIdGenerator.kt b/dogfooding/src/main/kotlin/io/getstream/video/android/util/UserIdHelper.kt similarity index 87% rename from dogfooding/src/main/kotlin/io/getstream/video/android/util/UserIdGenerator.kt rename to dogfooding/src/main/kotlin/io/getstream/video/android/util/UserIdHelper.kt index 4b01c28564..435a4bf749 100644 --- a/dogfooding/src/main/kotlin/io/getstream/video/android/util/UserIdGenerator.kt +++ b/dogfooding/src/main/kotlin/io/getstream/video/android/util/UserIdHelper.kt @@ -16,8 +16,7 @@ package io.getstream.video.android.util -object UserIdGenerator { - +object UserIdHelper { fun generateRandomString(length: Int = 8, upperCaseOnly: Boolean = false): String { val allowedChars: List = ('A'..'Z') + ('0'..'9') + if (!upperCaseOnly) { ('a'..'z') @@ -29,4 +28,9 @@ object UserIdGenerator { .map { allowedChars.random() } .joinToString("") } + + fun getUserIdFromEmail(email: String) = email + .replace(" ", "") + .replace(".", "") + .replace("@", "") } diff --git a/dogfooding/src/main/res/values/strings.xml b/dogfooding/src/main/res/values/strings.xml index 0565c562f7..7e2dcf8206 100644 --- a/dogfooding/src/main/res/values/strings.xml +++ b/dogfooding/src/main/res/values/strings.xml @@ -35,6 +35,9 @@ Call ID Number Stream Video Try out a video call in this demo powered by Stream\'s video SDK + Cannot load user list.\nPlease try again or re-login into the app. + Direct Call + Select users and tap the call button below %s is typing %s and %d more are typing diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 407871d0f0..67e636fab9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -109,6 +109,7 @@ androidx-compose-tracing = { group = "androidx.compose.runtime", name = "runtime compose-stable-marker = { group = "com.github.skydoves", name = "compose-stable-marker", version.ref = "composeStableMarker" } coil = { group = "io.coil-kt", name = "coil", version.ref = "coil" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar" } landscapist-coil = { group = "com.github.skydoves", name = "landscapist-coil", version.ref = "landscapist" }