From afba6f9ecf537c08448e858c9e29f8b00b40cbd7 Mon Sep 17 00:00:00 2001 From: Infi Date: Sat, 31 May 2025 20:15:23 +0200 Subject: [PATCH] feat: permission management for voice chat Signed-off-by: Infi --- .../chat/revolt/activities/MainActivity.kt | 30 +++- .../screens/chat/TypingIndicator.kt | 17 ++- .../chat/molecules/JoinVoiceChannelButton.kt | 61 ++++++++ .../screens/voice/VoiceChannelOverlay.kt | 64 -------- .../voice/VoicePermissionSwitch.kt | 144 ++++++++++++++++++ .../revolt/composables/voice/VoiceSheet.kt | 8 + .../revolt/screens/chat/ChatRouterScreen.kt | 18 +-- .../chat/views/channel/ChannelScreen.kt | 12 +- app/src/main/res/drawable/icn_mic_24dp.xml | 10 ++ .../main/res/drawable/icn_videocam_24dp.xml | 11 ++ app/src/main/res/values/strings.xml | 13 +- 11 files changed, 287 insertions(+), 101 deletions(-) create mode 100644 app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt delete mode 100644 app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt create mode 100644 app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt create mode 100644 app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt create mode 100644 app/src/main/res/drawable/icn_mic_24dp.xml create mode 100644 app/src/main/res/drawable/icn_videocam_24dp.xml diff --git a/app/src/main/java/chat/revolt/activities/MainActivity.kt b/app/src/main/java/chat/revolt/activities/MainActivity.kt index 63790cd4..3588fdab 100644 --- a/app/src/main/java/chat/revolt/activities/MainActivity.kt +++ b/app/src/main/java/chat/revolt/activities/MainActivity.kt @@ -48,10 +48,12 @@ import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSiz import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -60,6 +62,7 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset @@ -84,6 +87,7 @@ import chat.revolt.api.settings.Experiments import chat.revolt.api.settings.LoadedSettings import chat.revolt.api.settings.SyncedSettings import chat.revolt.composables.generic.HealthAlert +import chat.revolt.composables.voice.VoicePermissionSwitch import chat.revolt.material.EasingTokens import chat.revolt.ndk.NativeLibraries import chat.revolt.persistence.KVStorage @@ -433,7 +437,9 @@ fun AppEntrypoint( onRetryConnection: () -> Unit, onUpdateNextDestination: (String) -> Unit = {} ) { - var showVoiceUI by remember { mutableStateOf(false) } + var showVoiceUI by rememberSaveable { mutableStateOf(false) } + var voiceChannelID by rememberSaveable { mutableStateOf(null) } + val chatUIScale by animateFloatAsState( if (showVoiceUI) 0.8f else 1.0f, animationSpec = tween( @@ -453,6 +459,11 @@ fun AppEntrypoint( showVoiceUI = false } + val keyboardController = LocalSoftwareKeyboardController.current + LaunchedEffect(showVoiceUI) { + if (showVoiceUI) keyboardController?.hide() + } + val navController = rememberNavController() RevoltTheme( @@ -612,8 +623,9 @@ fun AppEntrypoint( ) navController.navigate("default") }, - onEnterVoiceUI = { + onEnterVoiceUI = { channelId -> showVoiceUI = true + voiceChannelID = channelId }, ) } @@ -752,10 +764,16 @@ fun AppEntrypoint( .widthIn(max = 600.dp) .padding(8.dp) ) { - Button(onClick = { - showVoiceUI = false - }) { - Text("Close voice UI") + VoicePermissionSwitch( + onCancel = { + showVoiceUI = false + } + ) { + Button(onClick = { + showVoiceUI = false + }) { + Text("Close voice UI") + } } } } diff --git a/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt b/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt index 9af1c0c3..17fa440d 100644 --- a/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt +++ b/app/src/main/java/chat/revolt/composables/screens/chat/TypingIndicator.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.revolt.R @@ -29,10 +30,16 @@ import chat.revolt.api.schemas.User import chat.revolt.composables.generic.UserAvatar @Composable -fun StackedUserAvatars(users: List, amount: Int = 3, serverId: String?) { +fun StackedUserAvatars( + users: List, + amount: Int = 3, + size: Dp = 16.dp, + offset: Dp = 8.dp, + serverId: String? +) { Box( modifier = Modifier - .size(16.dp + (8.dp * minOf(users.size, amount)), 16.dp) + .size(size + (offset * minOf(users.size, amount)), size) ) { users.take(amount).forEachIndexed { index, userId -> val user = RevoltAPI.userCache[userId] @@ -44,10 +51,10 @@ fun StackedUserAvatars(users: List, amount: Int = 3, serverId: String?) username = user?.let { User.resolveDefaultName(it) } ?: stringResource(id = R.string.unknown), rawUrl = maybeMember?.avatar?.let { "$REVOLT_FILES/avatars/${it.id}" }, - size = 16.dp, + size = size, modifier = Modifier .offset( - x = (index * 8).dp + x = (index * offset.value).dp ) ) } @@ -91,7 +98,7 @@ fun TypingIndicator(users: List, serverId: String?) { RevoltAPI.userCache[userId]?.let { u -> val maybeMember = serverId?.let { RevoltAPI.members.getMember(serverId, userId) } - + maybeMember?.nickname ?: User.resolveDefaultName(u) } ?: userId } diff --git a/app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt b/app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt new file mode 100644 index 00000000..dcdec0a9 --- /dev/null +++ b/app/src/main/java/chat/revolt/composables/screens/chat/molecules/JoinVoiceChannelButton.kt @@ -0,0 +1,61 @@ +package chat.revolt.composables.screens.chat.molecules + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import chat.revolt.R +import chat.revolt.callbacks.Action +import chat.revolt.callbacks.ActionChannel +import chat.revolt.composables.screens.chat.StackedUserAvatars +import kotlinx.coroutines.launch + +@Composable +fun JoinVoiceChannelButton(channelId: String, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .clip(MaterialTheme.shapes.large) + .clickable { + scope.launch { ActionChannel.send(Action.OpenVoiceChannelOverlay(channelId)) } + } + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(16.dp) + .fillMaxWidth() + ) { + StackedUserAvatars( + listOf( + "01FHGJ3NPP7XANQQH8C2BE44ZY", + "01F1WKM5TK2V6KCZWR6DGBJDTZ", + "01EX2NCWQ0CHS3QJF0FEQS1GR4" + ), + size = 24.dp, + offset = 12.dp, + amount = 3, + serverId = null + ) + Text( + stringResource(R.string.voice_join_offering), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + Text( + stringResource(R.string.voice_join_offering_description_other, Integer.MAX_VALUE), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt b/app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt deleted file mode 100644 index 0720ab77..00000000 --- a/app/src/main/java/chat/revolt/composables/screens/voice/VoiceChannelOverlay.kt +++ /dev/null @@ -1,64 +0,0 @@ -package chat.revolt.composables.screens.voice - -import android.content.Context -import android.util.DisplayMetrics -import android.view.WindowManager -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties - -@Composable -fun VoiceChannelOverlay(channelId: String, onCollapse: () -> Unit) { - val context = LocalContext.current - val windowManager = - remember { context.getSystemService(Context.WINDOW_SERVICE) as WindowManager } - - val metrics = DisplayMetrics().apply { - @Suppress("DEPRECATION") // We *need* the real metrics here, not the window metrics - windowManager.defaultDisplay.getRealMetrics(this) - } - val (width, height) = with(LocalDensity.current) { - Pair(metrics.widthPixels.toDp(), metrics.heightPixels.toDp()) - } - - Dialog( - onDismissRequest = { - onCollapse() - }, - properties = DialogProperties( - usePlatformDefaultWidth = false, - decorFitsSystemWindows = true, - dismissOnClickOutside = false, - dismissOnBackPress = true - ) - ) { - Column( - Modifier - .requiredSize(width, height) - .background(MaterialTheme.colorScheme.background), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically) - ) { - Text(text = "Voice channel overlay for $channelId", textAlign = TextAlign.Center) - Button(onClick = { - onCollapse() - }) { - Text("Close") - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt b/app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt new file mode 100644 index 00000000..7410e153 --- /dev/null +++ b/app/src/main/java/chat/revolt/composables/voice/VoicePermissionSwitch.kt @@ -0,0 +1,144 @@ +package chat.revolt.composables.voice + +import android.Manifest +import android.content.Intent +import android.provider.Settings +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import chat.revolt.R +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberMultiplePermissionsState +import com.google.accompanist.permissions.shouldShowRationale + +@OptIn(ExperimentalPermissionsApi::class, ExperimentalMaterial3Api::class) +@Composable +fun VoicePermissionSwitch(onCancel: () -> Unit, onPermissionGranted: @Composable () -> Unit) { + val context = LocalContext.current + + var permissionState = rememberMultiplePermissionsState( + listOf( + Manifest.permission.RECORD_AUDIO, + Manifest.permission.CAMERA + ), + onPermissionsResult = {} + ) + var fullyRevoked = permissionState + .revokedPermissions + .any { it.status is PermissionStatus.Denied && !it.status.shouldShowRationale } + + if (permissionState.allPermissionsGranted) { + onPermissionGranted() + } else if (permissionState.shouldShowRationale || fullyRevoked) { + ModalBottomSheet( + onDismissRequest = onCancel, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + sheetGesturesEnabled = false, + dragHandle = {} + ) { + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 16.dp) + ) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.voice_join_permission_rationale_heading), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = stringResource(R.string.voice_join_permission_rationale_description), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.icn_mic_24dp), + contentDescription = null + ) + Text( + text = stringResource(R.string.voice_join_permission_rationale_permission_mic), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = painterResource(R.drawable.icn_videocam_24dp), + contentDescription = null + ) + Text( + text = stringResource(R.string.voice_join_permission_rationale_permission_camera), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Start, + modifier = Modifier.weight(1f) + ) + } + Text( + text = stringResource(R.string.voice_join_permission_rationale_assurance), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.fillMaxWidth() + ) + Button( + onClick = { + if (fullyRevoked) { + // Launch settings to allow the user to manually enable permissions + val intent = + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + data = "package:${context.packageName}".toUri() + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } else { + permissionState.launchMultiplePermissionRequest() + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = stringResource(R.string.voice_join_permission_rationale_cta)) + } + } + } + } else { + LaunchedEffect(Unit) { + permissionState.launchMultiplePermissionRequest() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt new file mode 100644 index 00000000..0eaa06b4 --- /dev/null +++ b/app/src/main/java/chat/revolt/composables/voice/VoiceSheet.kt @@ -0,0 +1,8 @@ +package chat.revolt.composables.voice + +import androidx.compose.runtime.Composable + +@Composable +fun VoiceSheet(channelId: String) { + +} \ No newline at end of file diff --git a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt index 79ebe7b5..ff53b22e 100644 --- a/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/ChatRouterScreen.kt @@ -77,7 +77,6 @@ import chat.revolt.callbacks.Action import chat.revolt.callbacks.ActionChannel import chat.revolt.composables.chat.DisconnectedNotice import chat.revolt.composables.screens.chat.drawer.ChannelSideDrawer -import chat.revolt.composables.screens.voice.VoiceChannelOverlay import chat.revolt.dialogs.NotificationRationaleDialog import chat.revolt.internals.Changelogs import chat.revolt.internals.extensions.zero @@ -277,7 +276,7 @@ fun ChatRouterScreen( windowSizeClass: WindowSizeClass, disableBackHandler: Boolean, onNullifiedUser: () -> Unit, - onEnterVoiceUI: () -> Unit, + onEnterVoiceUI: (String) -> Unit, viewModel: ChatRouterViewModel = hiltViewModel() ) { val drawerState = rememberDrawerState(DrawerValue.Closed) @@ -315,9 +314,6 @@ fun ChatRouterScreen( var useTabletAwareUI by remember { mutableStateOf(false) } - var voiceChannelOverlay by remember { mutableStateOf(false) } - var voiceChannelOverlayChannelId by remember { mutableStateOf("") } - var showReportUser by remember { mutableStateOf(false) } var reportUserTarget by remember { mutableStateOf("") } @@ -457,8 +453,7 @@ fun ChatRouterScreen( } is Action.OpenVoiceChannelOverlay -> { - voiceChannelOverlayChannelId = action.channelId - voiceChannelOverlay = true + onEnterVoiceUI(action.channelId) } is Action.OpenWebhookSheet -> { @@ -725,12 +720,6 @@ fun ChatRouterScreen( } } - if (voiceChannelOverlay) { - VoiceChannelOverlay(voiceChannelOverlayChannelId) { - voiceChannelOverlay = false - } - } - val askNotificationsPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted -> if (isGranted) { @@ -990,7 +979,7 @@ fun ChannelNavigator( drawerState: DrawerState? = null, drawerGestureEnabled: Boolean = true, disableBackHandler: Boolean = false, - onEnterVoiceUI: () -> Unit = {}, + onEnterVoiceUI: (String) -> Unit = {}, setDrawerGestureEnabled: (Boolean) -> Unit = {}, ) { val scope = rememberCoroutineScope() @@ -1033,7 +1022,6 @@ fun ChannelNavigator( drawerGestureEnabled = drawerGestureEnabled, setDrawerGestureEnabled = setDrawerGestureEnabled, drawerIsOpen = drawerState?.isOpen == true, - onEnterVoiceUI = onEnterVoiceUI, ) } diff --git a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt index ba163362..03df56af 100644 --- a/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/revolt/screens/chat/views/channel/ChannelScreen.kt @@ -142,6 +142,7 @@ import chat.revolt.composables.screens.chat.ChannelIcon import chat.revolt.composables.screens.chat.ReplyManager import chat.revolt.composables.screens.chat.TypingIndicator import chat.revolt.composables.screens.chat.atoms.RegularMessage +import chat.revolt.composables.screens.chat.molecules.JoinVoiceChannelButton import chat.revolt.composables.skeletons.MessageSkeleton import chat.revolt.composables.skeletons.MessageSkeletonVariant import chat.revolt.internals.extensions.rememberChannelPermissions @@ -201,7 +202,6 @@ fun ChannelScreen( drawerIsOpen: Boolean = false, backButtonAction: (() -> Unit)? = null, useChatUI: Boolean = false, - onEnterVoiceUI: () -> Unit = {}, viewModel: ChannelScreenViewModel = hiltViewModel() ) { // @@ -936,15 +936,7 @@ fun ChannelScreen( } if (viewModel.channel?.channelType == ChannelType.VoiceChannel) { - Button( - onClick = { - onEnterVoiceUI() - }, - modifier = Modifier - .fillMaxWidth() - ) { - Text("Join Voice Channel") - } + JoinVoiceChannelButton(channelId) } } } diff --git a/app/src/main/res/drawable/icn_mic_24dp.xml b/app/src/main/res/drawable/icn_mic_24dp.xml new file mode 100644 index 00000000..b13baa43 --- /dev/null +++ b/app/src/main/res/drawable/icn_mic_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/icn_videocam_24dp.xml b/app/src/main/res/drawable/icn_videocam_24dp.xml new file mode 100644 index 00000000..ce6074f4 --- /dev/null +++ b/app/src/main/res/drawable/icn_videocam_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 177b4ff7..08db6b0a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -593,6 +593,17 @@ Search results Clear search + Join the voice channel + Start the call + with %1$s + with %1$d others + We need your permission + To join a voice channel, you need to grant the following permissions: + Your microphone, so others can hear you when unmuted + Your camera, so others can see you if you want to share video + Don\'t worry, we won\'t use your microphone or camera without your permission. + Grant permissions + Stay in the loop Enable notifications to be kept up to date with messages and mentions. Enable notifications @@ -752,7 +763,7 @@ This is not a valid share intent. This attachment is too large for Revolt (max. $1$s). Search channels - m select a channel to share to. + Select a channel to share to. Conversations Friends and Social