feat: permission management for voice chat

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-05-31 20:15:23 +02:00
parent 2b752fefbc
commit afba6f9ecf
11 changed files with 287 additions and 101 deletions

View File

@ -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<String?>(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")
}
}
}
}

View File

@ -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<String>, amount: Int = 3, serverId: String?) {
fun StackedUserAvatars(
users: List<String>,
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<String>, 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<String>, serverId: String?) {
RevoltAPI.userCache[userId]?.let { u ->
val maybeMember =
serverId?.let { RevoltAPI.members.getMember(serverId, userId) }
maybeMember?.nickname ?: User.resolveDefaultName(u)
} ?: userId
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
package chat.revolt.composables.voice
import androidx.compose.runtime.Composable
@Composable
fun VoiceSheet(channelId: String) {
}

View File

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

View File

@ -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()
) {
// <editor-fold desc="State and effects">
@ -936,15 +936,7 @@ fun ChannelScreen(
}
if (viewModel.channel?.channelType == ChannelType.VoiceChannel) {
Button(
onClick = {
onEnterVoiceUI()
},
modifier = Modifier
.fillMaxWidth()
) {
Text("Join Voice Channel")
}
JoinVoiceChannelButton(channelId)
}
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,560Q430,560 395,525Q360,490 360,440L360,200Q360,150 395,115Q430,80 480,80Q530,80 565,115Q600,150 600,200L600,440Q600,490 565,525Q530,560 480,560ZM480,320Q480,320 480,320Q480,320 480,320L480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320Q480,320 480,320L480,320Q480,320 480,320Q480,320 480,320ZM440,840L440,717Q336,703 268,624Q200,545 200,440L280,440Q280,523 338.5,581.5Q397,640 480,640Q563,640 621.5,581.5Q680,523 680,440L760,440Q760,545 692,624Q624,703 520,717L520,840L440,840ZM480,480Q497,480 508.5,468.5Q520,457 520,440L520,200Q520,183 508.5,171.5Q497,160 480,160Q463,160 451.5,171.5Q440,183 440,200L440,440Q440,457 451.5,468.5Q463,480 480,480Z"/>
</vector>

View File

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal"
android:autoMirrored="true">
<path
android:fillColor="@android:color/white"
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L640,160Q673,160 696.5,183.5Q720,207 720,240L720,420L880,260L880,700L720,540L720,720Q720,753 696.5,776.5Q673,800 640,800L160,800ZM160,720L640,720Q640,720 640,720Q640,720 640,720L640,240Q640,240 640,240Q640,240 640,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720ZM160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240L160,240Q160,240 160,240Q160,240 160,240L160,720Q160,720 160,720Q160,720 160,720Z"/>
</vector>

View File

@ -593,6 +593,17 @@
<string name="emoji_picker_search_results_header">Search results</string>
<string name="emoji_picker_clear_search">Clear search</string>
<string name="voice_join_offering">Join the voice channel</string>
<string name="voice_join_offering_description_zero">Start the call</string>
<string name="voice_join_offering_description_one">with %1$s</string>
<string name="voice_join_offering_description_other">with %1$d others</string>
<string name="voice_join_permission_rationale_heading">We need your permission</string>
<string name="voice_join_permission_rationale_description">To join a voice channel, you need to grant the following permissions:</string>
<string name="voice_join_permission_rationale_permission_mic">Your microphone, so others can hear you when unmuted</string>
<string name="voice_join_permission_rationale_permission_camera">Your camera, so others can see you if you want to share video</string>
<string name="voice_join_permission_rationale_assurance">Don\'t worry, we won\'t use your microphone or camera without your permission.</string>
<string name="voice_join_permission_rationale_cta">Grant permissions</string>
<string name="spark_notifications_rationale">Stay in the loop</string>
<string name="spark_notifications_rationale_description">Enable notifications to be kept up to date with messages and mentions.</string>
<string name="spark_notifications_rationale_cta">Enable notifications</string>
@ -752,7 +763,7 @@
<string name="share_target_invalid_intent">This is not a valid share intent.</string>
<string name="share_target_attachment_too_large">This attachment is too large for Revolt (max. $1$s).</string>
<string name="share_target_search_channels">Search channels</string>
<string name="share_target_select_channel">m select a channel to share to.</string>
<string name="share_target_select_channel">Select a channel to share to.</string>
<string name="notification_channel_group_conversations">Conversations</string>
<string name="notification_channel_group_social">Friends and Social</string>