feat(telecom): init

Signed-off-by: Infi <infi@infi.sh>
This commit is contained in:
Infi 2025-09-24 00:24:05 +02:00
parent 14353c7f7c
commit c1730c3a17
7 changed files with 440 additions and 0 deletions

View File

@ -256,6 +256,7 @@ dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.core:core-splashscreen:1.2.0-beta02")
implementation("androidx.palette:palette-ktx:1.0.0")
implementation("androidx.core:core-telecom:1.0.0")
// Libraries used for legacy View-based UI
implementation("androidx.constraintlayout:constraintlayout:2.2.1")

View File

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<!-- Up to Android 10, we need the following to take photos from the camera. -->
<uses-permission
@ -138,6 +139,11 @@
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.Revolt" />
<activity
android:name=".activities.voice.IncomingActivity"
android:configChanges="orientation|screenSize"
android:theme="@style/Theme.Revolt" />
<!-- Backport photo picker via Google Play Services -->
<service
android:name="com.google.android.gms.metadata.ModuleDependencies"

View File

@ -0,0 +1,289 @@
package chat.revolt.activities.voice
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.animateColor
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.AnchoredDraggableDefaults
import androidx.compose.foundation.gestures.AnchoredDraggableState
import androidx.compose.foundation.gestures.DraggableAnchors
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.anchoredDraggable
import androidx.compose.foundation.gestures.animateTo
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.revolt.R
import chat.revolt.composables.generic.Presence
import chat.revolt.composables.generic.RemoteImage
import chat.revolt.composables.generic.presenceColour
import chat.revolt.ui.theme.RevoltTheme
import chat.revolt.ui.theme.Theme
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
class IncomingActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
IncomingCall()
}
}
}
@Composable
fun IncomingCall() {
RevoltTheme(
requestedTheme = if (isSystemInDarkTheme()) Theme.Revolt else Theme.Light
) {
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onBackground) {
IncomingCallInner()
}
}
}
private enum class CallSwiperState {
Initial,
Accept,
Decline
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun IncomingCallInner() {
val bgInfinite = rememberInfiniteTransition(label = "Background")
val bgColour by bgInfinite.animateColor(
initialValue = MaterialTheme.colorScheme.surfaceContainerHighest,
targetValue = MaterialTheme.colorScheme.surfaceContainerLowest,
label = "Background Colour",
animationSpec = infiniteRepeatable(
animation = tween(1000, 0, FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
)
)
var swiperLabelColourLerp by remember { mutableFloatStateOf(0f) }
val swiperLabelColourLerpAnim by animateFloatAsState(
targetValue = swiperLabelColourLerp,
animationSpec = tween(250),
label = "Swiper Label Colour Lerp state"
)
LaunchedEffect(Unit) {
while (true) {
swiperLabelColourLerp = 0f
delay(1000)
swiperLabelColourLerp = 1f
delay(4000)
}
}
val swiperState = remember { AnchoredDraggableState(CallSwiperState.Initial) }
val swiperWidth = 330.dp
val swiperWidthThird = swiperWidth / 3
val density = LocalDensity.current
val swiperWidthPx = with(density) { swiperWidth.toPx() }
val swiperThirdPx = with(density) { swiperWidthThird.toPx() }
SideEffect {
swiperState.updateAnchors(
DraggableAnchors {
CallSwiperState.Decline at 35f
CallSwiperState.Initial at (swiperWidthPx / 2f) - (swiperThirdPx / 2f)
CallSwiperState.Accept at swiperWidthPx - swiperThirdPx - 35f
}
)
}
LaunchedEffect(swiperState.currentValue) {
when (swiperState.currentValue) {
CallSwiperState.Accept -> {
swiperState.animateTo(CallSwiperState.Initial)
}
CallSwiperState.Decline -> {
swiperState.animateTo(CallSwiperState.Initial)
}
else -> {}
}
}
val swiperRotation = remember(swiperState.offset) {
if (swiperState.offset.isNaN()) 0f
else {
val declineAnchor = 35f
val initialAnchor = (swiperWidthPx / 2f) - (swiperThirdPx / 2f)
val acceptAnchor = swiperWidthPx - swiperThirdPx - 35f
when {
swiperState.offset <= initialAnchor -> {
// Moving towards decline: interpolate from 0° to -90°
val progress =
(initialAnchor - swiperState.offset) / (initialAnchor - declineAnchor)
-225f * progress.coerceIn(0f, 1f)
}
swiperState.offset >= initialAnchor -> {
// Moving towards accept: interpolate from 0° to 90°
val progress =
(swiperState.offset - initialAnchor) / (acceptAnchor - initialAnchor)
45f * progress.coerceIn(0f, 1f)
}
else -> 0f // At initial position
}
}
}
Box(
Modifier
.fillMaxSize()
.background(bgColour),
contentAlignment = Alignment.Center
) {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Incoming Call on Revolt",
style = MaterialTheme.typography.headlineMedium,
fontSize = 24.sp
)
Text(
"cat",
style = MaterialTheme.typography.displayLargeEmphasized,
fontWeight = FontWeight.SemiBold
)
with(density) {
RemoteImage(
url = "https://cdn.revoltusercontent.com/attachments/K1CDpnvORz2fzUhgq47mcL7N4gccWGqNYYeGaJVvyp/image.png",
description = null,
modifier = Modifier
.clip(CircleShape)
.fillMaxWidth(0.5f)
.aspectRatio(1f),
)
}
Spacer(Modifier.fillMaxHeight(.33f))
Box(
modifier = Modifier
.clip(CircleShape)
.height(84.dp)
.width(swiperWidth)
.then(
if (isSystemInDarkTheme()) Modifier
.background(MaterialTheme.colorScheme.surfaceVariant)
.background(
Brush.linearGradient(
listOf(
presenceColour(Presence.Dnd).copy(alpha = 0.05f),
presenceColour(Presence.Online).copy(alpha = 0.05f),
)
)
) else Modifier.background(MaterialTheme.colorScheme.surfaceBright)
),
) {
Text(
"Decline",
color = lerp(
presenceColour(Presence.Dnd),
MaterialTheme.colorScheme.onSurface,
swiperLabelColourLerpAnim
),
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 30.dp)
)
Text(
"Accept",
color = lerp(
presenceColour(Presence.Online),
MaterialTheme.colorScheme.onSurface,
swiperLabelColourLerpAnim
),
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 30.dp)
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.width(swiperWidthThird)
.height(64.dp)
.offset {
IntOffset(
x = swiperState.requireOffset().roundToInt(),
y = with(density) { (84.dp - 64.dp).toPx() / 2 }.roundToInt()
)
}
.anchoredDraggable(
swiperState,
Orientation.Horizontal,
flingBehavior =
AnchoredDraggableDefaults.flingBehavior(
swiperState,
positionalThreshold = { distance -> distance * 0.25f },
),
)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.tertiaryContainer)
) {
Icon(
painter = painterResource(R.drawable.icn_call_24dp__fill),
contentDescription = null,
tint = MaterialTheme.colorScheme.onTertiaryContainer,
modifier = Modifier
.rotate(swiperRotation)
)
}
}
}
}
}

View File

@ -219,6 +219,15 @@ fun LabsHomeScreen(navController: NavController, topNav: NavController) {
}
)
HorizontalDivider()
ListItem(
headlineContent = {
Text("Telecom")
},
modifier = Modifier.clickable {
navController.navigate("sandboxes/telecom")
}
)
HorizontalDivider()
}
}
}

View File

@ -19,6 +19,7 @@ import chat.revolt.screens.labs.ui.sandbox.GradientEditorSandbox
import chat.revolt.screens.labs.ui.sandbox.JBMSandbox
import chat.revolt.screens.labs.ui.sandbox.NewCardSandboxScreen
import chat.revolt.screens.labs.ui.sandbox.SettingsDslSandbox
import chat.revolt.screens.labs.ui.sandbox.TelecomSandbox
annotation class LabsFeature
@ -87,6 +88,9 @@ fun LabsRootScreen(topNav: NavController) {
composable("sandboxes/newcard") {
NewCardSandboxScreen(labsNav)
}
composable("sandboxes/telecom") {
TelecomSandbox(labsNav)
}
}
}
}

View File

@ -0,0 +1,121 @@
package chat.revolt.screens.labs.ui.sandbox
import android.content.Intent
import android.os.Build
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.core.net.toUri
import androidx.core.telecom.CallAttributesCompat
import androidx.core.telecom.CallControlScope
import androidx.core.telecom.CallsManager
import androidx.navigation.NavController
import chat.revolt.activities.voice.IncomingActivity
import chat.revolt.settings.dsl.SettingsPage
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import logcat.LogPriority
import logcat.asLog
import logcat.logcat
@Composable
fun TelecomSandbox(navController: NavController) {
val context = LocalContext.current
val callsManager = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) CallsManager(context) else null
}
val didRegister = remember { mutableStateOf(false) }
val callControlScope = remember { mutableStateOf<CallControlScope?>(null) }
val scope = rememberCoroutineScope()
SettingsPage(
navController,
title = {
Text(
text = "Telecom Sandbox",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
) {
Column {
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val capabilities: @CallsManager.Companion.Capability Int =
(CallsManager.CAPABILITY_BASELINE or
CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING)
callsManager?.registerAppWithTelecom(capabilities)
didRegister.value = true
} else {
showSnackbar("Android version too old for Core-Telecom API")
}
}
) {
Text(if (didRegister.value) "Registered" else "Register Revolt for Calls")
}
Button(
onClick = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val address = "revolt:01F92C5ZXBQWQ8KY7J8KY917NM".toUri()
val attr = CallAttributesCompat(
displayName = "Jennifer",
address = address,
direction = CallAttributesCompat.DIRECTION_INCOMING,
callType = CallAttributesCompat.CALL_TYPE_AUDIO_CALL,
callCapabilities = 0
)
scope.launch {
delay(5000)
try {
callsManager?.addCall(
attr,
{ someInt ->
logcat { "Call answered with $someInt" }
// answer the call here, e.g. start an activity or service...
}, // Watch needs to know if it can answer the call.
{ cause ->
logcat { "Call ended with cause $cause" }
},
{
logcat { "Call state changed to active" }
},
{
logcat { "Call state changed to inactive (hold)" }
}
) {
// The call was successfully added once this scope runs.
callControlScope.value = this
}
} catch (addCallException: Exception) {
logcat(LogPriority.ERROR) { addCallException.asLog() }
}
}
} else {
showSnackbar("Android version too old for Core-Telecom API")
}
}
) {
Text("Place incoming call in 5 seconds")
}
Button(
onClick = {
val intent = Intent(context, IncomingActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
) {
Text("Just open incoming call activity")
}
}
}
}

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="M798,840Q673,840 551,785.5Q429,731 329,631Q229,531 174.5,409Q120,287 120,162Q120,144 132,132Q144,120 162,120L324,120Q338,120 349,129.5Q360,139 362,152L388,292Q390,308 387,319Q384,330 376,338L279,436Q299,473 326.5,507.5Q354,542 387,574Q418,605 452,631.5Q486,658 524,680L618,586Q627,577 641.5,572.5Q656,568 670,570L808,598Q822,602 831,612.5Q840,623 840,636L840,798Q840,816 828,828Q816,840 798,840Z"/>
</vector>