mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Implement basic calls
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru> Made-with: Cursor
This commit is contained in:
@@ -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 Studio’s 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 Studio’s 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 project’s 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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 first‑party only; there is no public API for third‑party 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 you’re 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
@@ -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 Studio’s 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
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -33,6 +33,8 @@ dependencyResolutionManagement {
|
||||
}
|
||||
}
|
||||
mavenCentral()
|
||||
// LiveKit Android pulls com.github.davidliu:audioswitch from JitPack.
|
||||
maven("https://jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user