parent
14353c7f7c
commit
c1730c3a17
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -219,6 +219,15 @@ fun LabsHomeScreen(navController: NavController, topNav: NavController) {
|
|||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text("Telecom")
|
||||
},
|
||||
modifier = Modifier.clickable {
|
||||
navController.navigate("sandboxes/telecom")
|
||||
}
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue