From 3d204656623b88650b35ef4b44b453a44957855c Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sat, 30 Mar 2024 01:19:36 +0700 Subject: [PATCH] android: more user-friendly way of granting permissions in call (#3945) * android: more user-friendly way of granting permissions in call * changes * ask permissions without click at first * changes in logic and UI * enhancements * show alert only when non in PiP * change --------- Co-authored-by: Evgeny Poberezkin --- apps/multiplatform/android/build.gradle.kts | 3 + .../simplex/app/views/call/CallActivity.kt | 34 ++- apps/multiplatform/common/build.gradle.kts | 3 + .../simplex/common/helpers/Permissions.kt | 30 +++ .../common/views/call/CallView.android.kt | 223 +++++++++++++----- .../common/views/helpers/AlertManager.kt | 5 +- .../commonMain/resources/MR/base/strings.xml | 8 + 7 files changed, 244 insertions(+), 62 deletions(-) create mode 100644 apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index f5114108b5..e89abc5d41 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -143,6 +143,9 @@ dependencies { implementation("com.jakewharton:process-phoenix:2.2.0") + //Camera Permission + implementation("com.google.accompanist:accompanist-permissions:0.23.0") + //implementation("androidx.compose.material:material-icons-extended:$compose_version") //implementation("androidx.compose.ui:ui-util:$compose_version") diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt index 6b1b01db99..b78f3ac518 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/views/call/CallActivity.kt @@ -1,7 +1,9 @@ package chat.simplex.app.views.call +import android.Manifest import android.app.* import android.content.* +import android.content.pm.PackageManager import android.content.res.Configuration import android.graphics.Rect import android.os.* @@ -28,6 +30,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.content.ContextCompat import androidx.lifecycle.Lifecycle import chat.simplex.app.* import chat.simplex.app.R @@ -36,10 +39,12 @@ import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.AcceptCallAction import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* import chat.simplex.common.views.call.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR +import com.google.accompanist.permissions.rememberMultiplePermissionsState import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.launch import kotlinx.datetime.Clock @@ -109,9 +114,20 @@ class CallActivity: ComponentActivity(), ServiceConnection { m.callCommand.add(WCallCommand.Layout(layoutType)) } + private fun hasGrantedPermissions(): Boolean { + val grantedAudio = ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + val grantedCamera = !callSupportsVideo() || ContextCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + return grantedAudio && grantedCamera + } + override fun onBackPressed() { if (isOnLockScreenNow()) { super.onBackPressed() + } else if (!hasGrantedPermissions() && !callSupportsVideo()) { + val call = m.activeCall.value + if (call != null) { + withBGApi { chatModel.callManager.endCall(call) } + } } else { m.activeCallViewIsCollapsed.value = true } @@ -223,8 +239,21 @@ fun CallActivityView() { } Box(Modifier.background(Color.Black)) { if (call != null) { + val permissionsState = rememberMultiplePermissionsState( + permissions = if (callSupportsVideo()) { + listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + } else { + listOf(Manifest.permission.RECORD_AUDIO) + } + ) + if (permissionsState.allPermissionsGranted) { + ActiveCallView() + } else { + CallPermissionsView(remember { m.activeCallViewIsCollapsed }.value, callSupportsVideo()) { + withBGApi { chatModel.callManager.endCall(call) } + } + } val view = LocalView.current - ActiveCallView() if (callSupportsVideo()) { val scope = rememberCoroutineScope() LaunchedEffect(Unit) { @@ -252,6 +281,9 @@ fun CallActivityView() { } } } + if (!m.activeCallViewIsCollapsed.value) { + AlertManager.shared.showInView() + } } LaunchedEffect(call == null) { if (call != null) { diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index b020a8641b..4cc5ced0d1 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -90,6 +90,9 @@ kotlin { implementation("androidx.camera:camera-camera2:${cameraXVersion}") implementation("androidx.camera:camera-lifecycle:${cameraXVersion}") implementation("androidx.camera:camera-view:${cameraXVersion}") + + // Calls lifecycle listener + implementation("androidx.lifecycle:lifecycle-process:2.4.1") } } val desktopMain by getting { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt new file mode 100644 index 0000000000..bfb45d7413 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/Permissions.kt @@ -0,0 +1,30 @@ +package chat.simplex.common.helpers + +import android.content.* +import android.net.Uri +import android.provider.Settings +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR + +fun Context.openAppSettingsInSystem() { + Intent().apply { + action = Settings.ACTION_APPLICATION_DETAILS_SETTINGS + data = Uri.parse("package:${androidAppContext.packageName}") + try { + startActivity(this) + } catch (e: ActivityNotFoundException) { + Log.e(TAG, e.stackTraceToString()) + } + } +} + +fun Context.showAllowPermissionInSettingsAlert(action: () -> Unit = ::openAppSettingsInSystem) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.permissions_grant_in_settings), + text = generalGetString(MR.strings.permissions_find_in_settings_and_grant), + confirmText = generalGetString(MR.strings.permissions_open_settings), + onConfirm = action, + ) +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 8a958327d8..b0c4beded0 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -1,5 +1,7 @@ package chat.simplex.common.views.call +import SectionSpacer +import SectionView import android.Manifest import android.annotation.SuppressLint import android.app.Activity @@ -24,17 +26,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalLifecycleOwner import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.* import androidx.webkit.WebViewAssetLoader import androidx.webkit.WebViewClientCompat +import chat.simplex.common.helpers.showAllowPermissionInSettingsAlert import chat.simplex.common.model.* import chat.simplex.common.ui.theme.* import chat.simplex.common.model.ChatModel @@ -42,13 +45,12 @@ import chat.simplex.common.model.Contact import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR -import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.* import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.datetime.Clock -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString // Should be destroy()'ed and set as null when call is ended. Otherwise, it will be a leak @@ -209,7 +211,6 @@ actual fun ActiveCallView() { ActiveCallOverlay(call, chatModel, audioViaBluetooth) } } - val context = LocalContext.current DisposableEffect(Unit) { val activity = context as? Activity ?: return@DisposableEffect onDispose {} @@ -510,34 +511,138 @@ private fun DisabledBackgroundCallsButton() { // } //} +@Composable +fun CallPermissionsView(pipActive: Boolean, hasVideo: Boolean, cancel: () -> Unit) { + val audioPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO) + val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA) + val permissionsState = rememberMultiplePermissionsState( + permissions = if (hasVideo) { + listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO) + } else { + listOf(Manifest.permission.RECORD_AUDIO) + } + ) + val context = LocalContext.current + val buttonEnabled = remember { mutableStateOf(true) } + LaunchedEffect(Unit) { + if (!pipActive) { + permissionsState.launchMultiplePermissionRequestWithFallback(buttonEnabled, context::showAllowPermissionInSettingsAlert) + } + } + + if (pipActive) { + Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.SpaceEvenly) { + if (audioPermission.status is PermissionStatus.Denied) { + Icon( + painterResource(MR.images.ic_call_500), + stringResource(MR.strings.permissions_record_audio), + Modifier.size(24.dp), + tint = Color(0xFFFFFFD8) + ) + } + if (hasVideo && cameraPermission.status is PermissionStatus.Denied) { + Icon( + painterResource(MR.images.ic_videocam), + stringResource(MR.strings.permissions_camera), + Modifier.size(24.dp), + tint = Color(0xFFFFFFD8) + ) + } + } + } else { + ColumnWithScrollBar(Modifier.fillMaxSize()) { + Spacer(Modifier.height(AppBarHeight)) + + AppBarTitle(stringResource(MR.strings.permissions_required)) + Spacer(Modifier.weight(1f)) + + val onClick = { + if (permissionsState.shouldShowRationale) { + context.showAllowPermissionInSettingsAlert() + } else { + permissionsState.launchMultiplePermissionRequestWithFallback(buttonEnabled, context::showAllowPermissionInSettingsAlert) + } + } + Text(stringResource(MR.strings.permissions_grant), Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), textAlign = TextAlign.Center, color = Color(0xFFFFFFD8)) + SectionSpacer() + SectionView { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + val text = if (hasVideo && audioPermission.status is PermissionStatus.Denied && cameraPermission.status is PermissionStatus.Denied) { + stringResource(MR.strings.permissions_camera_and_record_audio) + } else if (audioPermission.status is PermissionStatus.Denied) { + stringResource(MR.strings.permissions_record_audio) + } else if (hasVideo && cameraPermission.status is PermissionStatus.Denied) { + stringResource(MR.strings.permissions_camera) + } else "" + GrantPermissionButton(text, buttonEnabled.value, onClick) + } + } + + Spacer(Modifier.weight(1f)) + Box(Modifier.fillMaxWidth().padding(bottom = if (hasVideo) 0.dp else DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.Center) { + SimpleButtonFrame(cancel, Modifier.height(64.dp)) { + Text(stringResource(MR.strings.call_service_notification_end_call), fontSize = 20.sp, color = Color(0xFFFFFFD8)) + } + } + } + } +} + +@Composable +private fun GrantPermissionButton(text: String, enabled: Boolean, onClick: () -> Unit) { + Row( + Modifier + .clickable(enabled = enabled, onClick = onClick) + .heightIn(min = 30.dp) + .background(WarningOrange.copy(0.3f), RoundedCornerShape(50)), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), fontSize = 20.sp, color = WarningOrange) + } +} + +/** + * The idea of this function is to ask system to show permission dialog and to see if it's really doing it. + * Otherwise, show alert with a button that opens settings for manual permission granting + * */ +private fun MultiplePermissionsState.launchMultiplePermissionRequestWithFallback(buttonEnabled: MutableState, fallback: () -> Unit) { + buttonEnabled.value = false + val lifecycleOwner = ProcessLifecycleOwner.get().lifecycle + var useFallback = true + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_PAUSE) { + useFallback = false + buttonEnabled.value = true + } + } + lifecycleOwner.addObserver(observer) + withBGApi { + delay(2000) + if (useFallback && chatModel.activeCall.value != null) { + fallback() + } + buttonEnabled.value = true + }.invokeOnCompletion { + // Main thread only + withApi { + lifecycleOwner.removeObserver(observer) + } + } + launchMultiplePermissionRequest() +} + @Composable fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIMessage) -> Unit) { val webView = remember { mutableStateOf(null) } - val permissionsState = rememberMultiplePermissionsState( - permissions = listOf( - Manifest.permission.CAMERA, - Manifest.permission.RECORD_AUDIO, - Manifest.permission.MODIFY_AUDIO_SETTINGS, - Manifest.permission.INTERNET - ) - ) fun processCommand(wv: WebView, cmd: WCallCommand) { val apiCall = WVAPICall(command = cmd) wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null) } - val lifecycleOwner = LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME || event == Lifecycle.Event.ON_START) { - permissionsState.launchMultiplePermissionRequest() - } - } - lifecycleOwner.lifecycle.addObserver(observer) + DisposableEffect(Unit) { onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) -// val wv = webView.value -// if (wv != null) processCommand(wv, WCallCommand.End) -// webView.value?.destroy() + // val wv = webView.value + // if (wv != null) processCommand(wv, WCallCommand.End) + // webView.value?.destroy() webView.value = null } } @@ -560,44 +665,42 @@ fun WebRTCView(callCommand: SnapshotStateList, onResponse: (WVAPIM .addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current)) .build() - if (permissionsState.allPermissionsGranted) { - Box(Modifier.fillMaxSize()) { - AndroidView( - factory = { AndroidViewContext -> - (staticWebView ?: WebView(androidAppContext)).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - this.webChromeClient = object: WebChromeClient() { - override fun onPermissionRequest(request: PermissionRequest) { - if (request.origin.toString().startsWith("file:/")) { - request.grant(request.resources) - } else { - Log.d(TAG, "Permission request from webview denied.") - request.deny() - } + Box(Modifier.fillMaxSize()) { + AndroidView( + factory = { AndroidViewContext -> + (staticWebView ?: WebView(androidAppContext)).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + this.webChromeClient = object: WebChromeClient() { + override fun onPermissionRequest(request: PermissionRequest) { + if (request.origin.toString().startsWith("file:/")) { + request.grant(request.resources) + } else { + Log.d(TAG, "Permission request from webview denied.") + request.deny() } } - this.webViewClient = LocalContentWebViewClient(webView, assetLoader) - this.clearHistory() - this.clearCache(true) - this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") - val webViewSettings = this.settings - webViewSettings.allowFileAccess = true - webViewSettings.allowContentAccess = true - webViewSettings.javaScriptEnabled = true - webViewSettings.mediaPlaybackRequiresUserGesture = false - webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE - if (staticWebView == null) { - this.loadUrl("file:android_asset/www/android/call.html") - } else { - webView.value = this - } + } + this.webViewClient = LocalContentWebViewClient(webView, assetLoader) + this.clearHistory() + this.clearCache(true) + this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") + val webViewSettings = this.settings + webViewSettings.allowFileAccess = true + webViewSettings.allowContentAccess = true + webViewSettings.javaScriptEnabled = true + webViewSettings.mediaPlaybackRequiresUserGesture = false + webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE + if (staticWebView == null) { + this.loadUrl("file:android_asset/www/android/call.html") + } else { + webView.value = this } } - ) { /* WebView */ } - } + } + ) { /* WebView */ } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index fa72ab2b3c..98f3921059 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -190,6 +190,7 @@ class AlertManager { fun showAlertMsg( title: String, text: String? = null, confirmText: String = generalGetString(MR.strings.ok), + onConfirm: (() -> Unit)? = null, hostDevice: Pair? = null, shareText: Boolean? = null ) { @@ -220,6 +221,7 @@ class AlertManager { } TextButton( onClick = { + onConfirm?.invoke() hideAlert() }, Modifier.focusRequester(focusRequester) @@ -257,8 +259,9 @@ class AlertManager { title: StringResource, text: StringResource? = null, confirmText: StringResource = MR.strings.ok, + onConfirm: (() -> Unit)? = null, hostDevice: Pair? = null, - ) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), hostDevice) + ) = showAlertMsg(generalGetString(title), if (text != null) generalGetString(text) else null, generalGetString(confirmText), onConfirm, hostDevice) @Composable fun showInView() { diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index c2045fe436..ddd4ab553b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -814,6 +814,14 @@ Error opening browser The default web browser is required for calls. Please configure the default browser in the system, and share more information with the developers. + Grant permissions + Record audio + Camera + Camera and record audio + Grant the following permission(s) before using calls + Grant in settings + Try to find this permission in settings and grant it manually. + Open settings The next generation of private messaging