Implement basic calls

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
Made-with: Cursor
This commit is contained in:
2026-04-23 16:49:40 +03:00
Unverified
parent ef840fbffe
commit 05e0e70f2a
28 changed files with 2002 additions and 58 deletions
@@ -7,10 +7,11 @@ alwaysApply: true
Whenever you change anything under **`app:android`**, **`app:shared`** (`commonMain` / `androidMain`), or **`utils:shared`** in ways that affect the Android app:
1. **Build** the debug APK: `./gradlew :app:android:assembleDebug` from the Android repo root. If `JAVA_HOME` pointing at Android Studios JBR fails, use a working JDK 17+ on the machine.
1. **Build** the debug APK: `./gradlew :app:android:assembleDebug` from the Android repo root. On macOS, use Android Studios bundled JBR (e.g. `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`, or unset `JAVA_HOME` so `gradlew` defaults to it). If that JBR is missing or fails, use another working JDK 21+ (`GRADLE_JAVA_HOME`).
2. **Read Mobile MCP tool schemas** under the projects MCP descriptors for `user-Mobile MCP` before calling tools (required).
3. **`mobile_list_available_devices`** — get all `id`s (and `name` / `type` if present).
4. **Use a real device by default.** Do **not** install or launch on an emulator **unless** the user **explicitly** asked to use the emulator, **or** no physical device is connected and **only** emulator(s) are available. Classify devices using `type != "emulator"` when reliable; if `type` is wrong or missing, treat as physical when the **name** does not look like an AVD (e.g. not `sdk_gphone`, not a generic emulator name). When one or more physical devices are available, install and launch **only** on those—**never** also push to emulators in the same step. When **no** physical device is connected, use every returned target (emulators).
- **All connected phones (e.g. two-device FromChat testing):** When the user asks to **always run on every / both connected devices** (or similar), repeat **`mobile_install_app`** and **`mobile_launch_app`** for **each** connected physical device ID returned by **`mobile_list_available_devices`**, not just one. If only one device is online, use that one; do not fail the step.
5. For **each** chosen device `id`: **`mobile_install_app`** with `path` = absolute path to
`app/android/build/outputs/apk/debug/android-debug.apk`
in this repo.
+2 -2
View File
@@ -56,12 +56,12 @@ val fixComposeResourcesStructure = tasks.register<FixComposeResTask>("fixCompose
extensions.configure<ApplicationExtension> {
namespace = "ru.fromchat"
compileSdk = 36
compileSdk = 37
defaultConfig {
applicationId = "ru.fromchat"
minSdk = 24
targetSdk = 36
targetSdk = 37
versionCode = 1
versionName = "1.0"
}
+3 -1
View File
@@ -89,13 +89,15 @@ kotlin {
implementation(libs.androidx.work.runtime.ktx)
implementation(libs.tweetnacl.java)
implementation(libs.sqldelight.driver.android)
implementation(libs.livekit.android)
implementation(libs.livekit.android.compose.components)
}
iosMain.dependencies {
implementation(libs.jetbrains.kotlinx.io.bytestring)
implementation(libs.jetbrains.kotlinx.coroutines.core)
implementation(libs.ktor.client.darwin)
implementation("com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings:0.9.5")
implementation(libs.multiplatform.crypto.libsodium.bindings)
implementation(libs.sqldelight.driver.native)
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application>
<service
android:name="ru.fromchat.calls.CallForegroundService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
</application>
</manifest>
@@ -0,0 +1,209 @@
package ru.fromchat.calls
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.graphics.Color
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
/**
* Foreground call session: keeps camera / mic eligible in background.
*
* Uses [NotificationCompat.CallStyle] for ongoing calls (API 31+) so the system treats this as an
* active call (priority in shade, better integration with OEM “live” / call surfaces). Samsung
* **Now Brief** is firstparty only; there is no public API for thirdparty apps to plug into it.
*/
class CallForegroundService : Service() {
override fun onBind(intent: Intent?) = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_HANG_UP -> {
CallStore.endCall()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
ACTION_START -> startAsForeground(intent!!)
ACTION_STOP -> {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
return START_STICKY
}
private fun startAsForeground(startIntent: Intent) {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val channelLabel = startIntent.getStringExtra(EXTRA_CHANNEL_NAME) ?: "Ongoing call"
val peerName = startIntent.getStringExtra(EXTRA_PEER_NAME)?.trim().orEmpty().ifBlank {
startIntent.getStringExtra(EXTRA_TITLE).orEmpty()
}
if (peerName.isBlank()) {
stopSelf()
return
}
val statusText = startIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty()
val startWallMs = startIntent.getLongExtra(EXTRA_START_WALL_MS, System.currentTimeMillis())
ensureActiveCallChannel(nm, channelLabel)
val smallIcon = try {
packageManager.getApplicationInfo(packageName, 0).icon
} catch (_: Exception) {
android.R.drawable.sym_call_outgoing
}
val hangUpPi = PendingIntent.getService(
this,
RC_HANG_UP,
Intent(this, CallForegroundService::class.java).apply { action = ACTION_HANG_UP },
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val openApp = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
val contentPi = if (openApp != null) {
PendingIntent.getActivity(
this,
RC_OPEN_APP,
openApp,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
} else {
null
}
val caller = Person.Builder()
.setName(peerName)
.setImportant(true)
.build()
val builder = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(smallIcon)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(Notification.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setWhen(startWallMs)
.setShowWhen(false)
.setUsesChronometer(true)
.setChronometerCountDown(false)
.setColorized(true)
.setColor(Color.rgb(0, 107, 94))
.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
}
}
if (contentPi != null) {
builder.setContentIntent(contentPi)
}
if (statusText.isNotBlank()) {
builder.setSubText(statusText)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setStyle(
NotificationCompat.CallStyle.forOngoingCall(
caller,
hangUpPi,
),
)
} else {
builder.setContentTitle(peerName)
.setContentText(statusText.ifBlank { channelLabel })
}
val notification = builder.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Camera + mic only. Do not use MEDIA_PROJECTION here: that type needs
// FOREGROUND_SERVICE_MEDIA_PROJECTION + capture privileges; screen share uses
// Activity-scoped MediaProjection (LiveKit), not this service.
val types = ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, types)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun ensureActiveCallChannel(nm: NotificationManager, label: String) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
nm.deleteNotificationChannel(LEGACY_CHANNEL_ID)
val existing = nm.getNotificationChannel(CHANNEL_ID)
if (existing != null && existing.importance < NotificationManager.IMPORTANCE_DEFAULT) {
nm.deleteNotificationChannel(CHANNEL_ID)
}
if (nm.getNotificationChannel(CHANNEL_ID) == null) {
val ch = NotificationChannel(
CHANNEL_ID,
label,
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = label
setBypassDnd(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
}
nm.createNotificationChannel(ch)
}
}
companion object {
private const val CHANNEL_ID = "fromchat_call_active"
private const val LEGACY_CHANNEL_ID = "fromchat_call_ongoing"
private const val NOTIFICATION_ID = 99101
private const val RC_HANG_UP = 1002
private const val RC_OPEN_APP = 1003
const val EXTRA_CHANNEL_NAME = "extra_channel_name"
const val EXTRA_PEER_NAME = "extra_peer_name"
const val EXTRA_TITLE = "extra_title"
const val EXTRA_STATUS_TEXT = "extra_status_text"
const val EXTRA_START_WALL_MS = "extra_start_wall_ms"
private const val ACTION_START = "ru.fromchat.calls.CallForegroundService.START"
private const val ACTION_STOP = "ru.fromchat.calls.CallForegroundService.STOP"
private const val ACTION_HANG_UP = "ru.fromchat.calls.CallForegroundService.HANG_UP"
fun start(
context: Context,
channelName: String,
peerDisplayName: String,
title: String,
statusText: String,
callStartWallMs: Long,
) {
val app = context.applicationContext
val intent = Intent(app, CallForegroundService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_CHANNEL_NAME, channelName)
putExtra(EXTRA_PEER_NAME, peerDisplayName)
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_STATUS_TEXT, statusText)
putExtra(EXTRA_START_WALL_MS, callStartWallMs)
}
ContextCompat.startForegroundService(app, intent)
}
fun stop(context: Context) {
val app = context.applicationContext
app.startService(
Intent(app, CallForegroundService::class.java).apply { action = ACTION_STOP },
)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
package ru.fromchat.calls
actual val UseInlineInCallChrome: Boolean = true
@@ -44,6 +44,24 @@
<string name="chat_group_label">Группа</string>
<string name="chat_members_count">%1$d человек</string>
<string name="cd_call">Позвонить</string>
<string name="cd_call_mic">Микрофон</string>
<string name="cd_call_camera">Камера</string>
<string name="cd_call_screenshare">Демонстрация экрана</string>
<string name="cd_call_end">Завершить</string>
<string name="call_status_starting">Запуск вызова…</string>
<string name="call_status_calling">Вызов…</string>
<string name="call_status_connecting">Подключение…</string>
<string name="call_status_in_call">Разговор</string>
<string name="call_incoming_subtitle">Входящий звонок</string>
<string name="call_accept">Принять</string>
<string name="call_decline">Отклонить</string>
<string name="call_failed_title">Не удалось соединить</string>
<string name="call_dismiss">ОК</string>
<string name="notif_screenshare_title">Демонстрация экрана</string>
<string name="notif_screenshare_text">Захват экрана активен для этого звонка</string>
<string name="notif_call_channel_name">Активный звонок</string>
<string name="notif_call_ongoing_title">Видеозвонок</string>
<string name="notif_call_ongoing_text">Камера и микрофон остаются включёнными, пока вы вне приложения</string>
<string name="message_sender_you">Вы</string>
<string name="user_fallback">Человек %1$d</string>
<string name="message_corrupted">Это сообщение не удалось показать.</string>
@@ -55,6 +55,24 @@
<string name="chat_group_label">Group</string>
<string name="chat_members_count">%1$d people</string>
<string name="cd_call">Call</string>
<string name="cd_call_mic">Microphone</string>
<string name="cd_call_camera">Camera</string>
<string name="cd_call_screenshare">Share screen</string>
<string name="cd_call_end">End call</string>
<string name="call_status_starting">Starting call…</string>
<string name="call_status_calling">Calling…</string>
<string name="call_status_connecting">Connecting…</string>
<string name="call_status_in_call">In call</string>
<string name="call_incoming_subtitle">Incoming call</string>
<string name="call_accept">Accept</string>
<string name="call_decline">Decline</string>
<string name="call_failed_title">Could not connect</string>
<string name="call_dismiss">OK</string>
<string name="notif_screenshare_title">Screen sharing</string>
<string name="notif_screenshare_text">Screen capture is active for this call</string>
<string name="notif_call_channel_name">Ongoing call</string>
<string name="notif_call_ongoing_title">Video call</string>
<string name="notif_call_ongoing_text">Camera and microphone stay active while youre away</string>
<!-- Messages -->
<string name="message_sender_you">You</string>
@@ -850,4 +850,56 @@ object ApiClient {
)
}
}
suspend fun fetchLiveKitToken(peerUserId: Int, roomName: String? = null): LiveKitTokenResponse {
if (_suspensionState.value.isSuspended) {
throw IllegalStateException("Suspended")
}
return http
.post("${Config.apiBaseUrl}/livekit/token") {
contentType(ContentType.Application.Json)
setBody(LiveKitTokenRequest(peerUserId = peerUserId, roomName = roomName))
}
.body()
}
suspend fun sendLiveKitInvite(toUserId: Int, roomName: String, serverUrl: String) {
if (_suspensionState.value.isSuspended) return
WebSocketManager.send(
WebSocketMessage(
type = "call_signaling",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = getTokenSafely(),
),
data = json.encodeToJsonElement(
CallSignalingLiveKitPayload(
toUserId = toUserId,
roomName = roomName,
serverUrl = serverUrl,
),
),
),
)
}
suspend fun sendLiveKitControl(toUserId: Int, kind: String, roomName: String? = null) {
if (_suspensionState.value.isSuspended) return
WebSocketManager.send(
WebSocketMessage(
type = "call_signaling",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = getTokenSafely(),
),
data = json.encodeToJsonElement(
CallSignalingLiveKitControl(
toUserId = toUserId,
kind = kind,
roomName = roomName,
),
),
),
)
}
}
@@ -419,4 +419,31 @@ data class VerifyResponse(
@Serializable
data class FcmTokenRequest(
val token: String
)
@Serializable
data class LiveKitTokenRequest(
@SerialName("peer_user_id") val peerUserId: Int,
@SerialName("room_name") val roomName: String? = null,
)
@Serializable
data class LiveKitTokenResponse(
@SerialName("server_url") val serverUrl: String,
val token: String,
@SerialName("room_name") val roomName: String,
)
@Serializable
data class CallSignalingLiveKitPayload(
val toUserId: Int,
val roomName: String,
val serverUrl: String,
)
@Serializable
data class CallSignalingLiveKitControl(
val toUserId: Int,
val kind: String,
val roomName: String? = null,
)
@@ -0,0 +1,13 @@
package ru.fromchat.calls
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
expect fun CallMediaLayer(
connect: LiveKitConnectSession?,
showDialingPlaceholder: Boolean,
/** Android: show mic/camera/share/end over video when in an active LiveKit session. */
showInCallControls: Boolean = false,
modifier: Modifier = Modifier,
)
@@ -0,0 +1,117 @@
package ru.fromchat.calls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.visibleDisplayName
@Composable
fun CallOverlay(modifier: Modifier = Modifier) {
val state by CallStore.ui.collectAsState()
when (val s = state) {
CallUiState.Idle -> return
is CallUiState.Failed -> {
AlertDialog(
onDismissRequest = { CallStore.dismissFailed() },
title = { Text(stringResource(Res.string.call_failed_title)) },
text = { Text(s.message) },
confirmButton = {
TextButton(onClick = { CallStore.dismissFailed() }) {
Text(stringResource(Res.string.call_dismiss))
}
},
)
}
is CallUiState.Connecting -> {
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.55f)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(Res.string.call_status_starting),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
is CallUiState.Incoming -> {
val me = ApiClient.user?.id
val cached = ProfileCache.get(s.fromUserId)
val title =
cached?.visibleDisplayName(me)?.takeIf { it.isNotBlank() }
?: cached?.username?.takeIf { it.isNotBlank() }
?: stringResource(Res.string.user_fallback, s.fromUserId)
Box(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.6f)),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(Res.string.call_incoming_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(24.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = { CallStore.declineIncoming() }) {
Text(stringResource(Res.string.call_decline))
}
Button(onClick = { CallStore.acceptIncoming() }) {
Text(stringResource(Res.string.call_accept))
}
}
}
}
}
is CallUiState.InCall -> {
Box(modifier = modifier.fillMaxSize()) {
CallMediaLayer(
connect = s.session,
showDialingPlaceholder = false,
showInCallControls = true,
modifier = Modifier.fillMaxSize(),
)
}
}
}
}
@@ -0,0 +1,200 @@
package ru.fromchat.calls
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.visibleDisplayName
import ru.fromchat.core.Logger
private const val TAG = "CallStore"
sealed class CallUiState {
data object Idle : CallUiState()
data class Connecting(
val peerUserId: Int,
) : CallUiState()
data class Incoming(
val fromUserId: Int,
val fromUsername: String,
val roomName: String,
val serverUrl: String,
) : CallUiState()
data class InCall(
val session: LiveKitConnectSession,
) : CallUiState()
data class Failed(
val message: String,
) : CallUiState()
}
object CallStore {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _ui = MutableStateFlow<CallUiState>(CallUiState.Idle)
val ui: StateFlow<CallUiState> = _ui.asStateFlow()
private fun peerLabel(peerUserId: Int): String {
val me = ApiClient.user?.id
val p = ProfileCache.get(peerUserId)
val label = p?.visibleDisplayName(me)?.orEmpty()?.ifBlank { null }
return label ?: p?.username?.takeIf { it.isNotBlank() } ?: "User $peerUserId"
}
fun onWebSocketMessage(message: WebSocketMessage) {
if (message.type != "call_signaling") return
val data = message.data ?: return
scope.launch {
runCatching { handleSignalingPayload(data) }
.onFailure { Logger.e(TAG, "call_signaling handling failed", it) }
}
}
private suspend fun handleSignalingPayload(data: JsonElement) {
val obj = data.jsonObject
val kind = obj["kind"]?.jsonPrimitive?.contentOrNull
val fromUserId = obj["fromUserId"]?.jsonPrimitive?.content?.toIntOrNull()
val currentId = ApiClient.user?.id ?: return
if (kind != null) {
if (fromUserId == null || fromUserId == currentId) return
when (kind) {
"decline", "end", "cancel" -> {
when (val s = _ui.value) {
is CallUiState.InCall ->
if (s.session.peerUserId == fromUserId) clearToIdle()
is CallUiState.Connecting ->
if (s.peerUserId == fromUserId) clearToIdle()
is CallUiState.Incoming ->
if (s.fromUserId == fromUserId) clearToIdle()
else -> {}
}
}
"accept" -> {}
else -> {}
}
return
}
val serverUrl = obj["serverUrl"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
val roomName = obj["roomName"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }
if (serverUrl == null || roomName == null || fromUserId == null) return
if (fromUserId == currentId) return
val fromUsername = obj["fromUsername"]?.jsonPrimitive?.contentOrNull.orEmpty()
if (_ui.value is CallUiState.InCall) return
_ui.value = CallUiState.Incoming(
fromUserId = fromUserId,
fromUsername = fromUsername,
roomName = roomName,
serverUrl = serverUrl,
)
}
fun startOutgoingCall(peerUserId: Int) {
if (peerUserId <= 0 || peerUserId == ApiClient.user?.id) return
scope.launch {
_ui.value = CallUiState.Connecting(peerUserId)
runCatching {
withContext(Dispatchers.Default) {
val tok = ApiClient.fetchLiveKitToken(peerUserId, null)
ApiClient.sendLiveKitInvite(peerUserId, tok.roomName, tok.serverUrl)
val label = peerLabel(peerUserId)
LiveKitConnectSession(
serverUrl = tok.serverUrl,
token = tok.token,
peerUserId = peerUserId,
peerDisplayName = label,
roomName = tok.roomName,
)
}
}.onSuccess { session ->
_ui.value = CallUiState.InCall(session)
}.onFailure { e ->
Logger.e(TAG, "startOutgoingCall failed", e)
_ui.value = CallUiState.Failed(e.message ?: "Error")
delay(2500)
if (_ui.value is CallUiState.Failed) clearToIdle()
}
}
}
fun acceptIncoming() {
val inc = _ui.value as? CallUiState.Incoming ?: return
scope.launch {
runCatching {
withContext(Dispatchers.Default) {
val tok = ApiClient.fetchLiveKitToken(inc.fromUserId, inc.roomName)
ApiClient.sendLiveKitControl(inc.fromUserId, "accept", inc.roomName)
val label = peerLabel(inc.fromUserId)
val display =
if (inc.fromUsername.isNotBlank()) inc.fromUsername else label
LiveKitConnectSession(
serverUrl = tok.serverUrl,
token = tok.token,
peerUserId = inc.fromUserId,
peerDisplayName = display,
roomName = tok.roomName,
)
}
}.onSuccess { session ->
_ui.value = CallUiState.InCall(session)
}.onFailure { e ->
Logger.e(TAG, "acceptIncoming failed", e)
clearToIdle()
_ui.value = CallUiState.Failed(e.message ?: "Error")
delay(2500)
if (_ui.value is CallUiState.Failed) clearToIdle()
}
}
}
fun declineIncoming(sendControl: Boolean = true) {
val inc = _ui.value as? CallUiState.Incoming ?: return
if (!sendControl) {
clearToIdle()
return
}
scope.launch {
runCatching { ApiClient.sendLiveKitControl(inc.fromUserId, "decline", inc.roomName) }
clearToIdle()
}
}
fun endCall() {
when (val s = _ui.value) {
is CallUiState.InCall -> {
val peer = s.session.peerUserId
val room = s.session.roomName
scope.launch {
runCatching { ApiClient.sendLiveKitControl(peer, "end", room) }
clearToIdle()
}
}
is CallUiState.Connecting,
is CallUiState.Incoming,
-> clearToIdle()
else -> {}
}
}
fun dismissFailed() {
if (_ui.value is CallUiState.Failed) clearToIdle()
}
private fun clearToIdle() {
_ui.value = CallUiState.Idle
}
}
@@ -0,0 +1,3 @@
package ru.fromchat.calls
expect val UseInlineInCallChrome: Boolean
@@ -0,0 +1,9 @@
package ru.fromchat.calls
data class LiveKitConnectSession(
val serverUrl: String,
val token: String,
val peerUserId: Int,
val peerDisplayName: String,
val roomName: String,
)
@@ -10,6 +10,8 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
@@ -21,6 +23,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.IntOffset
import androidx.lifecycle.Lifecycle
@@ -76,6 +79,8 @@ import ru.fromchat.ui.main.settings.SettingsSecurityHubScreen
import ru.fromchat.ui.main.settings.SettingsSecurityPasswordFlowScreen
import ru.fromchat.ui.main.settings.SettingsServerToolsScreen
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.calls.CallOverlay
import ru.fromchat.calls.CallStore
import ru.fromchat.ui.setup.ServerConfigScreen
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@@ -123,6 +128,7 @@ private fun handlePresenceEvent(message: WebSocketMessage) {
"suspended", "unsuspended", "account_deleted" -> handleAccountLifecycleEvent(message)
"statusUpdate" -> message.data?.jsonObject?.let(::handlePresenceStatus)
"dmTyping", "stopDmTyping" -> message.data?.jsonObject?.let { handlePresenceTyping(message.type, it) }
"call_signaling" -> CallStore.onWebSocketMessage(message)
"updates" -> {
val data = message.data ?: return
val updates = ApiClient.json.decodeFromJsonElement<WebSocketUpdatesData>(data)
@@ -321,50 +327,41 @@ fun App(
}
}
// Set up global auth error handler
LaunchedEffect(navController) {
ApiClient.onAuthError = {
Logger.d("App", "Global auth error handler triggered, navigating to login")
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}
}
CompositionLocalProvider(
LocalNavController provides navController,
LocalSystemBarsVisibility provides rememberSystemBarsController()
) {
if (startDestination != null) {
val animationSpec = tween<IntOffset>(400)
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
Box(Modifier.fillMaxSize()) {
NavHost(
navController = navController,
startDestination = startDestination!!,
enterTransition = {
slideIntoContainer(
Start,
animationSpec = animationSpec
)
},
exitTransition = {
slideOutOfContainer(
Start,
animationSpec = animationSpec
)
},
popEnterTransition = {
slideIntoContainer(
End,
animationSpec = animationSpec
)
},
popExitTransition = {
slideOutOfContainer(
End,
animationSpec = animationSpec
)
}
) {
composable("serverConfig") {
ServerConfigScreen()
}
@@ -613,6 +610,24 @@ fun App(
)
}
}
// Register only after NavHost has attached a graph; otherwise 401 during startup/call can crash.
DisposableEffect(navController) {
ApiClient.onAuthError = {
Logger.d("App", "Global auth error handler triggered, navigating to login")
runCatching {
navController.navigate("login") {
popUpTo("chat") { inclusive = true }
}
}.onFailure { e ->
Logger.w("App", "Auth navigation failed: ${e.message}", e)
}
}
onDispose {
ApiClient.onAuthError = null
}
}
CallOverlay(Modifier.fillMaxSize())
}
}
}
}
@@ -67,6 +67,7 @@ import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
import ru.fromchat.calls.CallStore
import ru.fromchat.core.Logger
import ru.fromchat.net.NetworkConnectivity
import ru.fromchat.ui.HapticFeedbackEvent
@@ -578,7 +579,12 @@ fun ChatScreen(
onBack = { navController.navigateUp() },
backContentDescription = stringResource(Res.string.back),
showCallButton = panel.showCallButton() && !isReadOnly,
onCallClick = { /* TODO: Handle call */ },
onCallClick = {
val peer = panelState.profileUserId
if (peer != null && peer > 0) {
scope.launch { CallStore.startOutgoingCall(peer) }
}
},
callContentDescription = cdCall,
titleChrome = {
ChatFloatingTitleChrome(
@@ -418,7 +418,7 @@ class DmPanel(
override suspend fun handleDeleteMessage(messageId: Int) {}
override fun showCallButton(): Boolean = false
override fun showCallButton(): Boolean = true
override fun getTypingHandler(): TypingHandler = typingHandler
@@ -39,9 +39,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.graphics.shapes.Morph
@@ -136,11 +136,11 @@ fun SettingsSecurityMorphedPasswordHero(
val usePredictive = predictiveProgress != null && predictiveFromStep != null && predictiveToStep != null
val fromStep = when {
usePredictive -> predictiveFromStep!!
usePredictive -> predictiveFromStep
else -> step
}
val toStep = when {
usePredictive -> predictiveToStep!!
usePredictive -> predictiveToStep
else -> step
}
@@ -4,22 +4,48 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import kotlinx.datetime.DatePeriod
import kotlinx.datetime.DayOfWeek
import kotlinx.datetime.Instant
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
import kotlinx.datetime.number
import kotlinx.datetime.toLocalDateTime
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.*
import ru.fromchat.month_apr
import ru.fromchat.month_aug
import ru.fromchat.month_dec
import ru.fromchat.month_feb
import ru.fromchat.month_jan
import ru.fromchat.month_jul
import ru.fromchat.month_jun
import ru.fromchat.month_mar
import ru.fromchat.month_may
import ru.fromchat.month_nov
import ru.fromchat.month_oct
import ru.fromchat.month_sep
import ru.fromchat.presence_date_full
import ru.fromchat.presence_date_this_year
import ru.fromchat.presence_online
import ru.fromchat.presence_recently
import ru.fromchat.presence_today_at
import ru.fromchat.presence_weekday_at
import ru.fromchat.presence_yesterday_at
import ru.fromchat.weekday_fri
import ru.fromchat.weekday_mon
import ru.fromchat.weekday_sat
import ru.fromchat.weekday_sun
import ru.fromchat.weekday_thu
import ru.fromchat.weekday_tue
import ru.fromchat.weekday_wed
import kotlin.time.Clock
import kotlin.time.ExperimentalTime
import kotlin.time.Instant
private fun formatFromXmlTemplate(template: String, vararg args: Any): String {
var result = template
args.forEachIndexed { index, arg ->
val n = index + 1
val text = arg.toString()
result = result.replace("%${n}\$s", text).replace("%${n}\$d", text)
result = result.replace($$"%$${n}$s", text).replace($$"%$${n}$d", text)
}
return result
}
@@ -124,12 +150,12 @@ fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrin
formatFromXmlTemplate(s.weekdayAt, label, timePart)
}
lastDate.year == nowDate.year -> {
val mon = s.monthShort(lastDate.monthNumber)
formatFromXmlTemplate(s.dateThisYear, lastDate.dayOfMonth, mon, timePart)
val mon = s.monthShort(lastDate.month.number)
formatFromXmlTemplate(s.dateThisYear, lastDate.day, mon, timePart)
}
else -> {
val mon = s.monthShort(lastDate.monthNumber)
formatFromXmlTemplate(s.dateFull, lastDate.dayOfMonth, mon, lastDate.year, timePart)
val mon = s.monthShort(lastDate.month.number)
formatFromXmlTemplate(s.dateFull, lastDate.day, mon, lastDate.year, timePart)
}
}
}
@@ -0,0 +1,16 @@
package ru.fromchat.calls
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
actual fun CallMediaLayer(
connect: LiveKitConnectSession?,
showDialingPlaceholder: Boolean,
showInCallControls: Boolean,
modifier: Modifier,
) {
Box(modifier = modifier.fillMaxSize())
}
@@ -0,0 +1,3 @@
package ru.fromchat.calls
actual val UseInlineInCallChrome: Boolean = false
+7 -2
View File
@@ -6,7 +6,11 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# If the Gradle client JVM crashes before the daemon starts (SIGBUS in CodeHeap::allocate), prefer
# Android Studios JBR: export GRADLE_JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
# (or leave JAVA_HOME unset on macOS: gradlew picks that path automatically). Otherwise use another JDK 21+.
# (see gradlew). Disabling class sharing can also help: add -Xshare:off below.
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -Xshare:off
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
@@ -25,4 +29,5 @@ android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
org.gradle.configuration-cache=true
# Configuration cache can surface noisy IDE/sync issues on some setups; turn on when stable.
org.gradle.configuration-cache=false
+6
View File
@@ -18,6 +18,7 @@ gson = "2.13.2"
kotlinxIoBytestring = "0.9.0"
kotlinxCoroutinesCore = "1.10.2"
kotlinxIoCore = "0.9.0"
multiplatformCryptoLibsodiumBindings = "0.9.5"
multiplatformSettings = "1.3.0"
serialization = "2.3.20"
serialization-json = "1.11.0"
@@ -40,6 +41,8 @@ androidxWork = "2.11.2"
cryptography-kotlin = "0.6.0"
krypto = "4.0.10"
sqldelight = "2.3.2"
livekitAndroid = "2.24.1"
livekitAndroidComposeComponents = "2.3.0"
[libraries]
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
@@ -65,6 +68,7 @@ kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.re
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization-json" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activityKtx" }
multiplatform-crypto-libsodium-bindings = { module = "com.ionspin.kotlin:multiplatform-crypto-libsodium-bindings", version.ref = "multiplatformCryptoLibsodiumBindings" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatformSettings" }
multiplatform-settings-coroutines = { module = "com.russhwolf:multiplatform-settings-coroutines", version.ref = "multiplatformSettings" }
multiplatform-settings-serialization = { module = "com.russhwolf:multiplatform-settings-serialization", version.ref = "multiplatformSettings" }
@@ -105,6 +109,8 @@ sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sq
sqldelight-coroutines-extensions = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" }
sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" }
sqldelight-driver-native = { module = "app.cash.sqldelight:native-driver", version.ref = "sqldelight" }
livekit-android = { module = "io.livekit:livekit-android", version.ref = "livekitAndroid" }
livekit-android-compose-components = { module = "io.livekit:livekit-android-compose-components", version.ref = "livekitAndroidComposeComponents" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
Vendored
+19 -1
View File
@@ -44,7 +44,8 @@ APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# 64m is too small for the wrapper client JVM on recent JDKs (VM init / code cache); keep this modest but safe.
DEFAULT_JVM_OPTS='"-Xmx512m" "-Xms128m" "-Xshare:off"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
@@ -82,6 +83,23 @@ esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# If the default JDK crashes on startup (e.g. SIGBUS in CodeHeap::allocate during VM init on some
# macOS + JDK combinations), point Gradle at another JDK 21+ without changing your global JAVA_HOME:
# export GRADLE_JAVA_HOME="/Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home"
if [ -n "$GRADLE_JAVA_HOME" ] && [ -x "$GRADLE_JAVA_HOME/bin/java" ]; then
JAVA_HOME="$GRADLE_JAVA_HOME"
export JAVA_HOME
fi
# macOS: when JAVA_HOME is still unset, use the JBR bundled with Android Studio.app (recommended on Apple Silicon).
if [ "$darwin" = "true" ] && [ -z "${JAVA_HOME:-}" ]; then
_STUDIO_JBR="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
if [ -x "$_STUDIO_JBR/bin/java" ]; then
JAVA_HOME="$_STUDIO_JBR"
export JAVA_HOME
fi
unset _STUDIO_JBR
fi
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
Vendored
+3 -1
View File
@@ -33,7 +33,9 @@ set APP_HOME=%DIRNAME%
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem If the JVM crashes on startup, set GRADLE_JAVA_HOME to Temurin/OpenJDK 21+ (see gradle.properties).
if defined GRADLE_JAVA_HOME if exist "%GRADLE_JAVA_HOME%\bin\java.exe" set "JAVA_HOME=%GRADLE_JAVA_HOME%"
set DEFAULT_JVM_OPTS="-Xmx512m" "-Xms128m" "-Xshare:off"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
+2
View File
@@ -33,6 +33,8 @@ dependencyResolutionManagement {
}
}
mavenCentral()
// LiveKit Android pulls com.github.davidliu:audioswitch from JitPack.
maven("https://jitpack.io")
}
}