Release IKAR 1.0.8: fix incoming calls, cancellation and call history
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР">
|
<img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР">
|
||||||
|
|
||||||
Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия исходников и опубликованного выпуска — `1.0.7+8`, вариант сборки — `ikar`.
|
Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия исходников и опубликованного выпуска — `1.0.8+9`, вариант сборки — `ikar`.
|
||||||
|
|
||||||
Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Основная иконка содержит рисунок Икара без надписей. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
|
Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Основная иконка содержит рисунок Икара без надписей. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
|
||||||
|
|
||||||
@@ -18,6 +18,17 @@
|
|||||||
|
|
||||||
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников.
|
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников.
|
||||||
|
|
||||||
|
## Выпуск 1.0.8
|
||||||
|
|
||||||
|
- Входящий звонок показывает отдельный нативный экран поверх блокировки и включает экран телефона. Он работает до запуска Flutter и исключён из недавних приложений. Разрешение полноэкранных звонков доступно в настройках уведомлений.
|
||||||
|
- Отмена отправляется через SignalR и FCM даже до принятия звонка получателем. Дополнительный запрос ИКАР `1001` завершает звонок без готового WS2; сервер проверяет участника. Завершённое приглашение нельзя принять повторно, события старого вызова не отменяют новый. Задержанный push учитывает оставшееся время приглашения.
|
||||||
|
- Локальное видео показывается без зеркалирования.
|
||||||
|
- Сервер сохраняет аудио- и видеозвонки в SQLite для обоих участников: длительность, тип и результат (завершён, отменён, отклонён, пропущен). Запись создаётся один раз; открытый журнал обновляется после сохранения. Неотвеченный вызов истекает автоматически.
|
||||||
|
|
||||||
|
Проверены 1211 тестов клиента (3 пропущены), 168 тестов сервера, 9 тестов Android, 7 сценариев жизненного цикла звонка и 16 общих сценариев публичного HTTPS API. Анализ изменённых Dart-файлов прошёл без замечаний. Телефон и эмулятор для этого выпуска не использовались; отображение экрана блокировки на конкретном устройстве не проверялось.
|
||||||
|
|
||||||
|
APK подписан прежним сертификатом ИКАР, содержит актуальные Flutter-библиотеки всех трёх ABI и объявленный экран входящего вызова. SHA-256: `948c1e5f4b8de0fab21cc136c58c64f1bb19a2ac05f4d280200dcb5b108b2e09`. Сервер развёрнут в `releases/20261008-calls`; резервные копии и целостность баз проверены, соседние сервисы сохранены. Отчёты: `artifacts/apk-verification-1.0.8.json`, `artifacts/native-call-apk-1.0.8.json`, `artifacts/server-deployment-1.0.8.json`, `artifacts/live-call-lifecycle-1.0.8.json`, `artifacts/argus-publication-1.0.8.json`, `artifacts/full-tests-1.0.8-final.log`.
|
||||||
|
|
||||||
## Выпуск 1.0.7
|
## Выпуск 1.0.7
|
||||||
|
|
||||||
В [Аргусе](https://argus.kusoft.xyz/#app=ikar) опубликована [ИКАР 1.0.7, сборка 8](https://argus.kusoft.xyz/api/apps/ikar/releases/c3a069b6-1f5f-409a-a67e-59e6e0345ded/download). Пакет `ru.ikar.app` и подпись предыдущего выпуска сохранены; обновление доступно также через собственный сервер ИКАР.
|
В [Аргусе](https://argus.kusoft.xyz/#app=ikar) опубликована [ИКАР 1.0.7, сборка 8](https://argus.kusoft.xyz/api/apps/ikar/releases/c3a069b6-1f5f-409a-a67e-59e6e0345ded/download). Пакет `ru.ikar.app` и подпись предыдущего выпуска сохранены; обновление доступно также через собственный сервер ИКАР.
|
||||||
|
|||||||
@@ -71,6 +71,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
<activity
|
||||||
|
android:name="ru.ikar.app.IncomingCallActivity"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:exported="false"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:showWhenLocked="true"
|
||||||
|
android:turnScreenOn="true"
|
||||||
|
android:theme="@android:style/Theme.Material.NoActionBar" />
|
||||||
<activity-alias
|
<activity-alias
|
||||||
android:name="ru.ikar.app.MinimalIcon"
|
android:name="ru.ikar.app.MinimalIcon"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ object CallEvents {
|
|||||||
|
|
||||||
private val main = Handler(Looper.getMainLooper())
|
private val main = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
fun emit(action: String) {
|
fun emit(action: String, conversationId: String? = null) {
|
||||||
val s = sink ?: return
|
val s = sink ?: return
|
||||||
main.post {
|
main.post {
|
||||||
try {
|
try {
|
||||||
s.success(mapOf("action" to action))
|
s.success(mapOf("action" to action, "conversationId" to conversationId))
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,14 +72,18 @@ object CallConst {
|
|||||||
|
|
||||||
object CallNotifier {
|
object CallNotifier {
|
||||||
|
|
||||||
fun showIncoming(ctx: Context, data: Map<String, String>) {
|
@Synchronized fun showIncoming(ctx: Context, data: Map<String, String>) {
|
||||||
if (AppState.resumed) {
|
val locked = (ctx.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager).isKeyguardLocked
|
||||||
|
if (AppState.resumed && !locked) {
|
||||||
Log.d("QlyraFcm", "call push suppressed (app foreground)")
|
Log.d("QlyraFcm", "call push suppressed (app foreground)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val name = data["userName"] ?: data["title"] ?: "Неизвестный"
|
val name = data["userName"] ?: data["title"] ?: "Неизвестный"
|
||||||
val callerId = data["suid"] ?: data["callerId"] ?: ""
|
val callerId = data["suid"] ?: data["callerId"] ?: ""
|
||||||
val conversationId = data["conversationId"] ?: data["vcId"] ?: ""
|
val conversationId = data["conversationId"] ?: data["vcId"] ?: ""
|
||||||
|
val ringMillis = IncomingCallPolicy.remainingMillis(data["expiresAt"]?.toLongOrNull(), System.currentTimeMillis())
|
||||||
|
if (ringMillis == 0L) return
|
||||||
|
if (!IncomingCallRegistry.begin(ctx, conversationId, data["expiresAt"]?.toLongOrNull())) return
|
||||||
val vcp = data["vcp"] ?: ""
|
val vcp = data["vcp"] ?: ""
|
||||||
val account = data["c"] ?: ""
|
val account = data["c"] ?: ""
|
||||||
val callJson = JSONObject(data as Map<*, *>).toString()
|
val callJson = JSONObject(data as Map<*, *>).toString()
|
||||||
@@ -88,7 +92,7 @@ object CallNotifier {
|
|||||||
|
|
||||||
ensureChannel(ctx)
|
ensureChannel(ctx)
|
||||||
|
|
||||||
val fullScreen = launchIntent(ctx, callJson, CallConst.ACTION_RING, name, 1)
|
val fullScreen = incomingIntent(ctx, callJson, conversationId)
|
||||||
val answer = launchIntent(ctx, callJson, CallConst.ACTION_ANSWER, name, 2)
|
val answer = launchIntent(ctx, callJson, CallConst.ACTION_ANSWER, name, 2)
|
||||||
val decline = declineIntent(ctx, vcp, conversationId, account)
|
val decline = declineIntent(ctx, vcp, conversationId, account)
|
||||||
val video = isVideo(data)
|
val video = isVideo(data)
|
||||||
@@ -104,6 +108,7 @@ object CallNotifier {
|
|||||||
.setColor(CallConst.ACCENT)
|
.setColor(CallConst.ACCENT)
|
||||||
.setColorized(true)
|
.setColorized(true)
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
.setTimeoutAfter(ringMillis)
|
||||||
.setOnlyAlertOnce(update)
|
.setOnlyAlertOnce(update)
|
||||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
.setPriority(NotificationCompat.PRIORITY_MAX)
|
.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
@@ -119,7 +124,7 @@ object CallNotifier {
|
|||||||
|
|
||||||
val manager = NotificationManagerCompat.from(ctx)
|
val manager = NotificationManagerCompat.from(ctx)
|
||||||
manager.notify(CallConst.NOTIF_ID, notification(NotifAvatars.initials(name), update = false))
|
manager.notify(CallConst.NOTIF_ID, notification(NotifAvatars.initials(name), update = false))
|
||||||
CallRinger.start(ctx, conversationId)
|
CallRinger.start(ctx, conversationId, ringMillis)
|
||||||
|
|
||||||
val photo = NotifAvatars.photo(ctx, callerId) ?: return
|
val photo = NotifAvatars.photo(ctx, callerId) ?: return
|
||||||
if (!isIncomingShowing(ctx)) return
|
if (!isIncomingShowing(ctx)) return
|
||||||
@@ -134,11 +139,24 @@ object CallNotifier {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
fun finishCall(ctx: Context, data: Map<String, String>) {
|
@Synchronized fun finishCall(ctx: Context, data: Map<String, String>, emitEvent: Boolean = true) {
|
||||||
Log.d("QlyraFcm", "finishCall keys=${data.keys}")
|
Log.d("QlyraFcm", "finishCall keys=${data.keys}")
|
||||||
|
val id = data["conversationId"] ?: data["vcId"] ?: return
|
||||||
|
if (IncomingCallRegistry.finish(ctx, id)) {
|
||||||
CallRinger.stop()
|
CallRinger.stop()
|
||||||
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
|
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
|
||||||
CallEvents.emit("ended")
|
IncomingCallActivity.close(id)
|
||||||
|
}
|
||||||
|
if (emitEvent) CallEvents.emit("ended", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun incomingIntent(ctx: Context, json: String, id: String): PendingIntent {
|
||||||
|
val intent = Intent(ctx, IncomingCallActivity::class.java).apply {
|
||||||
|
data = android.net.Uri.parse("ikar://incoming-call/$id")
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
putExtra(CallConst.EXTRA_CALL, json)
|
||||||
|
}
|
||||||
|
return PendingIntent.getActivity(ctx, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isVideo(data: Map<String, String>): Boolean {
|
private fun isVideo(data: Map<String, String>): Boolean {
|
||||||
@@ -156,6 +174,7 @@ object CallNotifier {
|
|||||||
requestCode: Int,
|
requestCode: Int,
|
||||||
): PendingIntent {
|
): PendingIntent {
|
||||||
val intent = LaunchIntents.app(ctx).apply {
|
val intent = LaunchIntents.app(ctx).apply {
|
||||||
|
data = android.net.Uri.parse("ikar://answer-call/${JSONObject(callJson).optString("conversationId")}")
|
||||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
putExtra(CallConst.EXTRA_CALL, callJson)
|
putExtra(CallConst.EXTRA_CALL, callJson)
|
||||||
putExtra(CallConst.EXTRA_ACTION, action)
|
putExtra(CallConst.EXTRA_ACTION, action)
|
||||||
@@ -168,7 +187,7 @@ object CallNotifier {
|
|||||||
return PendingIntent.getActivity(ctx, requestCode, intent, flags)
|
return PendingIntent.getActivity(ctx, requestCode, intent, flags)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun declineIntent(
|
fun declineIntent(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
vcp: String,
|
vcp: String,
|
||||||
conversationId: String,
|
conversationId: String,
|
||||||
@@ -180,6 +199,7 @@ object CallNotifier {
|
|||||||
.put("c", account.toLongOrNull() ?: account)
|
.put("c", account.toLongOrNull() ?: account)
|
||||||
.toString()
|
.toString()
|
||||||
val intent = Intent(ctx, CallActionReceiver::class.java).apply {
|
val intent = Intent(ctx, CallActionReceiver::class.java).apply {
|
||||||
|
data = android.net.Uri.parse("ikar://decline-call/$conversationId")
|
||||||
action = CallConst.ACTION_DECLINE
|
action = CallConst.ACTION_DECLINE
|
||||||
putExtra(CallConst.EXTRA_DECLINE_PAYLOAD, payload)
|
putExtra(CallConst.EXTRA_DECLINE_PAYLOAD, payload)
|
||||||
putExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
|
putExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
|
||||||
@@ -221,7 +241,7 @@ object CallRinger {
|
|||||||
var activeConversationId: String? = null
|
var activeConversationId: String? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
fun start(ctx: Context, conversationId: String) {
|
@Synchronized fun start(ctx: Context, conversationId: String, ringMillis: Long = 45_000L) {
|
||||||
stop()
|
stop()
|
||||||
activeConversationId = conversationId
|
activeConversationId = conversationId
|
||||||
|
|
||||||
@@ -240,12 +260,12 @@ object CallRinger {
|
|||||||
vibrate(ctx)
|
vibrate(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
val t = Runnable { onTimeout(ctx) }
|
val t = Runnable { onTimeout(ctx, conversationId) }
|
||||||
timeout = t
|
timeout = t
|
||||||
handler.postDelayed(t, 45_000L)
|
handler.postDelayed(t, ringMillis)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun stop() {
|
@Synchronized fun stop() {
|
||||||
timeout?.let { handler.removeCallbacks(it) }
|
timeout?.let { handler.removeCallbacks(it) }
|
||||||
timeout = null
|
timeout = null
|
||||||
try {
|
try {
|
||||||
@@ -266,7 +286,13 @@ object CallRinger {
|
|||||||
activeConversationId = null
|
activeConversationId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onTimeout(ctx: Context) {
|
@Synchronized private fun onTimeout(ctx: Context, expectedId: String) {
|
||||||
|
if (activeConversationId != expectedId) return
|
||||||
|
activeConversationId?.let {
|
||||||
|
IncomingCallRegistry.finish(ctx, it)
|
||||||
|
IncomingCallActivity.close(it)
|
||||||
|
CallEvents.emit("ended", it)
|
||||||
|
}
|
||||||
stop()
|
stop()
|
||||||
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
|
NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID)
|
||||||
}
|
}
|
||||||
@@ -318,11 +344,11 @@ class CallActionReceiver : BroadcastReceiver() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (intent.action != CallConst.ACTION_DECLINE) return
|
if (intent.action != CallConst.ACTION_DECLINE) return
|
||||||
CallRinger.stop()
|
|
||||||
val notifId = intent.getIntExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
|
|
||||||
NotificationManagerCompat.from(ctx).cancel(notifId)
|
|
||||||
|
|
||||||
val payload = intent.getStringExtra(CallConst.EXTRA_DECLINE_PAYLOAD) ?: return
|
val payload = intent.getStringExtra(CallConst.EXTRA_DECLINE_PAYLOAD) ?: return
|
||||||
|
val callId = try { JSONObject(payload).optString("conversationId") } catch (_: Exception) { return }
|
||||||
|
if (!IncomingCallRegistry.isActive(ctx, callId)) return
|
||||||
|
val notifId = intent.getIntExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID)
|
||||||
|
CallNotifier.finishCall(ctx, mapOf("conversationId" to callId))
|
||||||
val fln = Intent(CallConst.FLN_ACTION_TAPPED).apply {
|
val fln = Intent(CallConst.FLN_ACTION_TAPPED).apply {
|
||||||
setClassName(ctx, CallConst.FLN_RECEIVER)
|
setClassName(ctx, CallConst.FLN_RECEIVER)
|
||||||
putExtra("notificationId", notifId)
|
putExtra("notificationId", notifId)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package ru.ikar.app
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.WindowManager
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
|
||||||
|
/** A native incoming screen works before Flutter starts and keeps chats behind the keyguard. */
|
||||||
|
class IncomingCallActivity : Activity() {
|
||||||
|
companion object {
|
||||||
|
private var shown: WeakReference<IncomingCallActivity>? = null
|
||||||
|
fun close(id: String?) {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
shown?.get()?.let { if (id == null || it.callId == id) it.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private var callId = ""
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
|
||||||
|
}
|
||||||
|
renderCall()
|
||||||
|
}
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
renderCall()
|
||||||
|
}
|
||||||
|
private fun renderCall() {
|
||||||
|
val raw = intent.getStringExtra(CallConst.EXTRA_CALL) ?: run { finish(); return }
|
||||||
|
val call = try { JSONObject(raw) } catch (_: Exception) { finish(); return }
|
||||||
|
callId = call.optString("conversationId")
|
||||||
|
if (!IncomingCallRegistry.isActive(this, callId)) { finish(); return }
|
||||||
|
shown = WeakReference(this)
|
||||||
|
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||||
|
val name = call.optString("userName").ifEmpty { call.optString("senderName").ifEmpty { call.optString("title").ifEmpty { "Неизвестный" } } }
|
||||||
|
val layout = LinearLayout(this).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
setPadding((32 * density).toInt(), (48 * density).toInt(), (32 * density).toInt(), (48 * density).toInt())
|
||||||
|
setBackgroundColor(Color.rgb(24, 27, 38))
|
||||||
|
}
|
||||||
|
layout.addView(TextView(this).apply { text = name; textSize = 30f; setTextColor(Color.WHITE); gravity = Gravity.CENTER })
|
||||||
|
layout.addView(TextView(this).apply {
|
||||||
|
text = if (call.optString("iv") == "true" || call.optString("type") == "VIDEO") "Входящий видеозвонок ИКАР" else "Входящий звонок ИКАР"
|
||||||
|
textSize = 18f; setTextColor(Color.LTGRAY); gravity = Gravity.CENTER; setPadding(0, 24, 0, 64)
|
||||||
|
})
|
||||||
|
layout.addView(Button(this).apply {
|
||||||
|
text = "Принять"
|
||||||
|
setOnClickListener {
|
||||||
|
if (IncomingCallRegistry.isActive(this@IncomingCallActivity, callId)) {
|
||||||
|
CallRinger.stop()
|
||||||
|
startActivity(LaunchIntents.app(this@IncomingCallActivity).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
putExtra(CallConst.EXTRA_CALL, raw)
|
||||||
|
putExtra(CallConst.EXTRA_ACTION, CallConst.ACTION_ANSWER)
|
||||||
|
putExtra(CallConst.EXTRA_CALLER, name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
layout.addView(Button(this).apply {
|
||||||
|
text = "Отклонить"
|
||||||
|
setOnClickListener {
|
||||||
|
CallNotifier.declineIntent(this@IncomingCallActivity, call.optString("vcp"), callId, call.optString("c")).send()
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setContentView(layout)
|
||||||
|
}
|
||||||
|
override fun onDestroy() {
|
||||||
|
if (shown?.get() === this) shown = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ru.ikar.app
|
||||||
|
|
||||||
|
/** A delayed push may ring only for the remaining server invitation lifetime. */
|
||||||
|
internal object IncomingCallPolicy {
|
||||||
|
fun remainingMillis(expiresAt: Long?, now: Long): Long =
|
||||||
|
((expiresAt ?: (now + 45_000L)) - now).coerceIn(0L, 45_000L)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.ikar.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
object IncomingCallRegistry {
|
||||||
|
private fun prefs(ctx: Context) = ctx.getSharedPreferences("ikar_incoming_calls", Context.MODE_PRIVATE)
|
||||||
|
private fun ended(ctx: Context) = try { JSONObject(prefs(ctx).getString("ended", "{}") ?: "{}") } catch (_: Exception) { JSONObject() }
|
||||||
|
@Synchronized fun begin(ctx: Context, id: String, expiresAt: Long?): Boolean {
|
||||||
|
if (!isAvailable(ctx, id) || expiresAt?.let { it <= System.currentTimeMillis() } == true) return false
|
||||||
|
val state = prefs(ctx)
|
||||||
|
if (state.getString("active", null) == id) return false
|
||||||
|
state.edit().putString("active", id).putLong("expires", expiresAt ?: System.currentTimeMillis() + 45_000).commit()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
@Synchronized fun isAvailable(ctx: Context, id: String): Boolean {
|
||||||
|
if (id.isEmpty()) return false
|
||||||
|
val ended = ended(ctx)
|
||||||
|
return !ended.has(id)
|
||||||
|
}
|
||||||
|
@Synchronized fun isActive(ctx: Context, id: String): Boolean =
|
||||||
|
prefs(ctx).getString("active", null) == id && isAvailable(ctx, id) && prefs(ctx).getLong("expires", 0) > System.currentTimeMillis()
|
||||||
|
@Synchronized fun finish(ctx: Context, id: String): Boolean {
|
||||||
|
if (id.isEmpty()) return false
|
||||||
|
val state = prefs(ctx)
|
||||||
|
val ended = ended(ctx)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
ended.keys().asSequence().toList().filter { ended.optLong(it) < now - 600_000 }.forEach { ended.remove(it) }
|
||||||
|
ended.put(id, now)
|
||||||
|
ended.keys().asSequence().toList().sortedBy { ended.optLong(it) }.dropLast(128).forEach { ended.remove(it) }
|
||||||
|
val matches = state.getString("active", null) == id
|
||||||
|
val editor = state.edit().putString("ended", ended.toString())
|
||||||
|
if (matches) editor.remove("active").remove("expires")
|
||||||
|
editor.commit()
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
fun activeId(ctx: Context): String? = prefs(ctx).getString("active", null)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
package ru.ikar.app
|
package ru.ikar.app
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.app.KeyguardManager
|
import org.json.JSONObject
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -362,13 +362,31 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
"ru.ikar.app/calls",
|
"ru.ikar.app/calls",
|
||||||
).setMethodCallHandler { call, result ->
|
).setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
"showIncoming" -> {
|
||||||
|
val raw = call.arguments as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||||
|
CallNotifier.showIncoming(applicationContext, raw.entries.associate { it.key.toString() to it.value.toString() })
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
"cancelConversation" -> {
|
||||||
|
val id = call.argument<String>("conversationId")
|
||||||
|
if (id != null) {
|
||||||
|
CallNotifier.finishCall(applicationContext, mapOf("conversationId" to id), emitEvent = false)
|
||||||
|
if (pendingCall?.get("data")?.toString()?.let { JSONObject(it).optString("conversationId") } == id) pendingCall = null
|
||||||
|
}
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
"consumeInitialCall" -> {
|
"consumeInitialCall" -> {
|
||||||
val p = pendingCall
|
val p = pendingCall
|
||||||
pendingCall = null
|
pendingCall = null
|
||||||
result.success(p)
|
val id = p?.get("data")?.toString()?.let { JSONObject(it).optString("conversationId") }
|
||||||
|
result.success(if (id != null && IncomingCallRegistry.isAvailable(applicationContext, id)) p else null)
|
||||||
}
|
}
|
||||||
"notifyAccepted" -> {
|
"notifyAccepted" -> {
|
||||||
val caller = call.argument<String>("caller") ?: "Звонок"
|
val caller = call.argument<String>("caller") ?: "Звонок"
|
||||||
|
call.argument<String>("conversationId")?.let {
|
||||||
|
// Ignore an incoming push delivered after this call was accepted.
|
||||||
|
CallNotifier.finishCall(applicationContext, mapOf("conversationId" to it), emitEvent = false)
|
||||||
|
}
|
||||||
CallRinger.stop()
|
CallRinger.stop()
|
||||||
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
|
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
|
||||||
CallForegroundService.start(applicationContext, caller)
|
CallForegroundService.start(applicationContext, caller)
|
||||||
@@ -394,8 +412,14 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"notifyEnded" -> {
|
"notifyEnded" -> {
|
||||||
|
val id = call.argument<String>("conversationId")
|
||||||
|
id?.let {
|
||||||
|
CallNotifier.finishCall(applicationContext, mapOf("conversationId" to it), emitEvent = false)
|
||||||
|
}
|
||||||
|
if (id == null) {
|
||||||
CallRinger.stop()
|
CallRinger.stop()
|
||||||
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
|
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
|
||||||
|
}
|
||||||
CallForegroundService.stop(applicationContext)
|
CallForegroundService.stop(applicationContext)
|
||||||
clearCallWindowFlags()
|
clearCallWindowFlags()
|
||||||
result.success(null)
|
result.success(null)
|
||||||
@@ -403,6 +427,7 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
"cancelIncoming" -> {
|
"cancelIncoming" -> {
|
||||||
CallRinger.stop()
|
CallRinger.stop()
|
||||||
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
|
NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID)
|
||||||
|
IncomingCallActivity.close(null)
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
"canUseFullScreenIntent" -> {
|
"canUseFullScreenIntent" -> {
|
||||||
@@ -673,6 +698,8 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
intent.removeExtra(CallConst.EXTRA_CALL)
|
intent.removeExtra(CallConst.EXTRA_CALL)
|
||||||
intent.removeExtra(CallConst.EXTRA_ACTION)
|
intent.removeExtra(CallConst.EXTRA_ACTION)
|
||||||
intent.removeExtra(CallConst.EXTRA_CALLER)
|
intent.removeExtra(CallConst.EXTRA_CALLER)
|
||||||
|
val callId = try { JSONObject(json).optString("conversationId") } catch (_: Exception) { clearCallWindowFlags(); return }
|
||||||
|
if (!IncomingCallRegistry.isActive(applicationContext, callId)) { clearCallWindowFlags(); return }
|
||||||
if (action == CallConst.ACTION_ANSWER) CallRinger.stop()
|
if (action == CallConst.ACTION_ANSWER) CallRinger.stop()
|
||||||
val map = mapOf<String, Any?>("data" to json, "action" to action)
|
val map = mapOf<String, Any?>("data" to json, "action" to action)
|
||||||
val sink = CallEvents.sink
|
val sink = CallEvents.sink
|
||||||
@@ -694,10 +721,6 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
|
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val km = getSystemService(Context.KEYGUARD_SERVICE) as? KeyguardManager
|
|
||||||
km?.requestDismissKeyguard(this, null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearCallWindowFlags() {
|
private fun clearCallWindowFlags() {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package ru.ikar.app
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class IncomingCallPolicyTest {
|
||||||
|
@Test fun delayedInvitationRingsOnlyUntilServerExpiry() {
|
||||||
|
assertEquals(15_000L, IncomingCallPolicy.remainingMillis(60_000L, 45_000L))
|
||||||
|
}
|
||||||
|
@Test fun alreadyExpiredInvitationDoesNotRing() {
|
||||||
|
assertEquals(0L, IncomingCallPolicy.remainingMillis(30_000L, 45_000L))
|
||||||
|
assertEquals(0L, IncomingCallPolicy.remainingMillis(45_000L, 45_000L))
|
||||||
|
}
|
||||||
|
@Test fun legacyPushAndFarFutureExpiryHaveFiniteRinging() {
|
||||||
|
assertEquals(45_000L, IncomingCallPolicy.remainingMillis(null, 45_000L))
|
||||||
|
assertEquals(45_000L, IncomingCallPolicy.remainingMillis(300_000L, 45_000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Ikar.Server.Controllers;
|
||||||
|
using Ikar.Server.Data;
|
||||||
|
using Ikar.Server.Data.Entities;
|
||||||
|
using Ikar.Server.Infrastructure.Calls;
|
||||||
|
using Ikar.Server.Infrastructure.Hubs;
|
||||||
|
using Ikar.Server.Infrastructure.Push;
|
||||||
|
using Ikar.Shared;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace Ikar.Server.Tests;
|
||||||
|
|
||||||
|
public sealed partial class QlyraCompatibilityControllerTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(false, false, "CANCELED")]
|
||||||
|
[InlineData(false, true, "REJECTED")]
|
||||||
|
[InlineData(true, false, "HUNGUP")]
|
||||||
|
public async Task FinishedDirectCall_IsDurableForBothParticipants_AndIdempotent(bool accepted, bool reject, string expected)
|
||||||
|
{
|
||||||
|
await using var database = new SqliteTestDatabase();
|
||||||
|
await using var db = await database.CreateContextAsync();
|
||||||
|
var caller = new User { Username = "call-a", NormalizedUsername = "CALL-A", DisplayName = "A", PasswordHash = "test" };
|
||||||
|
var callee = new User { Username = "call-b", NormalizedUsername = "CALL-B", DisplayName = "B", PasswordHash = "test" };
|
||||||
|
db.Users.AddRange(caller, callee);
|
||||||
|
db.PushDevices.AddRange(new PushDevice { UserId = caller.Id, Platform = PushPlatform.Android, InstallationId = "a", DeviceToken = "a", NotificationsEnabled = true },
|
||||||
|
new PushDevice { UserId = callee.Id, Platform = PushPlatform.Android, InstallationId = "b", DeviceToken = "b", NotificationsEnabled = true });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var push = new RecordingQlyraPushSender();
|
||||||
|
var options = new DbContextOptionsBuilder<IkarDbContext>().UseSqlite(db.Database.GetDbConnection()).Options;
|
||||||
|
await using var services = new ServiceCollection().AddScoped(_ => new IkarDbContext(options))
|
||||||
|
.AddSingleton<IPushNotificationSender>(push).BuildServiceProvider();
|
||||||
|
var lifecycle = new QlyraCallLifecycle(services.GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
new NoopHubContext<MessengerHub>(), NullLogger<QlyraCallLifecycle>.Instance);
|
||||||
|
var store = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions()), lifecycle);
|
||||||
|
var call = store.Create("durable-call", true, (caller.Id, 10), (callee.Id, 20));
|
||||||
|
if (accepted) call.AcceptedAt = DateTimeOffset.UtcNow.AddSeconds(-5);
|
||||||
|
|
||||||
|
// Caller also sends accept-call automatically; this must not count as an answer.
|
||||||
|
if (!reject && !accepted)
|
||||||
|
await store.HandleWebSocketAsync(CreateWebSocketContext(call.Participants[caller.Id].Token,
|
||||||
|
new ScriptedWebSocket("{\"command\":\"accept-call\",\"sequence\":1}", "{\"command\":\"hangup\",\"sequence\":2}")));
|
||||||
|
else Assert.True(await store.EndAsync(call.Id, reject ? callee.Id : caller.Id));
|
||||||
|
Assert.True(await store.EndAsync(call.Id, caller.Id));
|
||||||
|
Assert.Null(store.Find(call.Id));
|
||||||
|
Assert.Single(push.Finished);
|
||||||
|
Assert.Equal(new[] { caller.Id, callee.Id }.Order(), push.Finished[0].Devices.Select(d => d.UserId).Order());
|
||||||
|
|
||||||
|
// A fresh DB context/store must still return the history, not an in-memory log.
|
||||||
|
await using var fresh = new IkarDbContext(options);
|
||||||
|
var messages = await fresh.Messages.AsNoTracking().ToListAsync();
|
||||||
|
var message = Assert.Single(messages);
|
||||||
|
Assert.Equal(caller.Id, message.SenderId);
|
||||||
|
using var attach = JsonDocument.Parse(message.QlyraAttachesJson!);
|
||||||
|
Assert.Equal(expected, attach.RootElement[0].GetProperty("hangupType").GetString());
|
||||||
|
Assert.Equal("VIDEO", attach.RootElement[0].GetProperty("callType").GetString());
|
||||||
|
Assert.Equal(accepted, attach.RootElement[0].GetProperty("duration").GetInt64() > 0);
|
||||||
|
foreach (var participant in new[] { caller, callee })
|
||||||
|
{
|
||||||
|
var response = await CreateController(fresh, participant.Id).Dispatch(79,
|
||||||
|
new QlyraCompatibilityRequest(JsonSerializer.SerializeToElement(new { })), CancellationToken.None);
|
||||||
|
var payload = JsonSerializer.SerializeToElement(Assert.IsType<QlyraCompatibilityResponse>(Assert.IsType<OkObjectResult>(response).Value).Payload);
|
||||||
|
Assert.Single(payload.GetProperty("history").EnumerateArray());
|
||||||
|
}
|
||||||
|
var staleContext = CreateWebSocketContext(call.Participants[callee.Id].Token, new ScriptedWebSocket());
|
||||||
|
await store.HandleWebSocketAsync(staleContext);
|
||||||
|
Assert.Equal(404, staleContext.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Expiration_CreatesMissedCall_AndDoesNotCloseTwoMemberConference()
|
||||||
|
{
|
||||||
|
var lifecycle = new RecordingLifecycle();
|
||||||
|
var store = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions()), lifecycle);
|
||||||
|
var caller = Guid.NewGuid(); var callee = Guid.NewGuid();
|
||||||
|
var direct = store.Create("missed", false, (caller, 1), (callee, 2));
|
||||||
|
var conference = store.Create("conference", false, (caller, 1));
|
||||||
|
store.AddParticipant(conference, callee, 2);
|
||||||
|
await store.ExpireAsync(DateTimeOffset.UtcNow.AddMinutes(2));
|
||||||
|
Assert.Null(store.Find(direct.Id));
|
||||||
|
Assert.Equal("MISSED", Assert.Single(lifecycle.Calls).EndReason);
|
||||||
|
Assert.True(await store.EndAsync(conference.Id, caller));
|
||||||
|
Assert.Same(conference, store.Find(conference.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EndCall_RejectsNonParticipant_AndRetriesFailedPersistence()
|
||||||
|
{
|
||||||
|
var lifecycle = new RecordingLifecycle { FailNext = true };
|
||||||
|
var store = new QlyraCallCompatibilityStore(Options.Create(new WebRtcOptions()), lifecycle);
|
||||||
|
var caller = Guid.NewGuid();
|
||||||
|
var call = store.Create("retry-call", false, (caller, 1), (Guid.NewGuid(), 2));
|
||||||
|
Assert.False(await store.EndAsync(call.Id, Guid.NewGuid()));
|
||||||
|
Assert.Same(call, store.Find(call.Id));
|
||||||
|
await store.EndAsync(call.Id, caller);
|
||||||
|
Assert.Empty(lifecycle.Calls);
|
||||||
|
await store.ExpireAsync(DateTimeOffset.UtcNow);
|
||||||
|
Assert.Same(call, Assert.Single(lifecycle.Calls));
|
||||||
|
await store.ExpireAsync(DateTimeOffset.UtcNow);
|
||||||
|
Assert.Single(lifecycle.Calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class RecordingLifecycle : IQlyraCallLifecycle
|
||||||
|
{
|
||||||
|
public bool FailNext { get; set; }
|
||||||
|
public List<QlyraCallConversation> Calls { get; } = [];
|
||||||
|
public Task CompleteAsync(QlyraCallConversation call)
|
||||||
|
{
|
||||||
|
if (FailNext) { FailNext = false; throw new IOException("Temporary database error"); }
|
||||||
|
Calls.Add(call); return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -911,6 +911,11 @@ public sealed partial class QlyraCompatibilityControllerTests
|
|||||||
private sealed class RecordingQlyraPushSender : IPushNotificationSender
|
private sealed class RecordingQlyraPushSender : IPushNotificationSender
|
||||||
{
|
{
|
||||||
public List<(QlyraIncomingCallPushDto Call, IReadOnlyList<PushDevice> Devices)> Calls { get; } = [];
|
public List<(QlyraIncomingCallPushDto Call, IReadOnlyList<PushDevice> Devices)> Calls { get; } = [];
|
||||||
|
public List<(string Id, IReadOnlyList<PushDevice> Devices)> Finished { get; } = [];
|
||||||
|
public Task SendQlyraCallFinishedAsync(string conversationId, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Finished.Add((conversationId, devices.ToList())); return Task.CompletedTask;
|
||||||
|
}
|
||||||
public Task SendMessageAsync(Chat chat, Message message, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public Task SendMessageAsync(Chat chat, Message message, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
public Task SendMessageReactionAsync(Chat chat, Message message, User reactor, string emoji, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public Task SendMessageReactionAsync(Chat chat, Message message, User reactor, string emoji, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
public Task SendIncomingCallAsync(IncomingCallDto call, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
public Task SendIncomingCallAsync(IncomingCallDto call, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
|||||||
76 or 78 or 84 or 166 => await CallFeatureAsync(opcode, request.Payload, cancellationToken),
|
76 or 78 or 84 or 166 => await CallFeatureAsync(opcode, request.Payload, cancellationToken),
|
||||||
77 => await UpdateChatMembersAsync(opcode, request.Payload, cancellationToken),
|
77 => await UpdateChatMembersAsync(opcode, request.Payload, cancellationToken),
|
||||||
79 or 164 => await CallHistoryAsync(opcode, request.Payload, cancellationToken),
|
79 or 164 => await CallHistoryAsync(opcode, request.Payload, cancellationToken),
|
||||||
|
1001 => await EndCallAsync(opcode, request.Payload, cancellationToken),
|
||||||
80 or 82 or 87 => await RequestUploadAsync(opcode, request.Payload, cancellationToken),
|
80 or 82 or 87 => await RequestUploadAsync(opcode, request.Payload, cancellationToken),
|
||||||
83 or 88 => await ResolveMediaAsync(opcode, request.Payload, cancellationToken),
|
83 or 88 => await ResolveMediaAsync(opcode, request.Payload, cancellationToken),
|
||||||
89 => await GetLinkInfoAsync(opcode, request.Payload, cancellationToken),
|
89 => await GetLinkInfoAsync(opcode, request.Payload, cancellationToken),
|
||||||
@@ -743,18 +744,20 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
|||||||
var calleeClientId = ReadIntList(payload, "calleeIds").FirstOrDefault();
|
var calleeClientId = ReadIntList(payload, "calleeIds").FirstOrDefault();
|
||||||
var calleeId = await clientIdMapper.FindEntityIdAsync("user", calleeClientId, cancellationToken);
|
var calleeId = await clientIdMapper.FindEntityIdAsync("user", calleeClientId, cancellationToken);
|
||||||
if (calleeId is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_USER_NOT_FOUND", "Call recipient was not found."));
|
if (calleeId is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_USER_NOT_FOUND", "Call recipient was not found."));
|
||||||
|
if (calleeId == user.Id || qlyraCallStore.Find(conversationId) is not null)
|
||||||
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALL_INVALID", "Invalid call recipient or conversation."));
|
||||||
conversation = qlyraCallStore.Create(conversationId, ReadBool(payload, "isVideo") == true, (user.Id, callerClientId), (calleeId.Value, calleeClientId));
|
conversation = qlyraCallStore.Create(conversationId, ReadBool(payload, "isVideo") == true, (user.Id, callerClientId), (calleeId.Value, calleeClientId));
|
||||||
participant = conversation.Participants[user.Id];
|
participant = conversation.Participants[user.Id];
|
||||||
var callee = conversation.Participants[calleeId.Value];
|
var callee = conversation.Participants[calleeId.Value];
|
||||||
var vcp = BuildCallVcp(callee.Token, conversation.IsVideo, callee.ClientId);
|
var vcp = BuildCallVcp(callee.Token, conversation.IsVideo, callee.ClientId);
|
||||||
await PublishPacketAsync([calleeId.Value], 137, new { vcp, conversationId, callerId = callerClientId, type = conversation.IsVideo ? "VIDEO" : "AUDIO", country = "RU", isContact = true }, cancellationToken);
|
await PublishPacketAsync([calleeId.Value], 137, new { vcp, conversationId, callerId = callerClientId, userName = user.DisplayName, expiresAt = conversation.ExpiresAt.ToUnixTimeMilliseconds(), type = conversation.IsVideo ? "VIDEO" : "AUDIO", country = "RU", isContact = true }, cancellationToken);
|
||||||
if (pushNotificationSender is not null)
|
if (pushNotificationSender is not null)
|
||||||
{
|
{
|
||||||
var devices = await dbContext.PushDevices.AsNoTracking()
|
var devices = await dbContext.PushDevices.AsNoTracking()
|
||||||
.Where(device => device.UserId == calleeId.Value && device.NotificationsEnabled)
|
.Where(device => device.UserId == calleeId.Value && device.NotificationsEnabled)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
await pushNotificationSender.SendQlyraIncomingCallAsync(
|
await pushNotificationSender.SendQlyraIncomingCallAsync(
|
||||||
new QlyraIncomingCallPushDto(vcp, conversationId, callerClientId, user.DisplayName, callee.ClientId, conversation.IsVideo),
|
new QlyraIncomingCallPushDto(vcp, conversationId, callerClientId, user.DisplayName, callee.ClientId, conversation.IsVideo, conversation.ExpiresAt.ToUnixTimeMilliseconds()),
|
||||||
devices,
|
devices,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
@@ -782,6 +785,17 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
|||||||
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId = conversation.Id, internalCallerParams = internalParams, internalParams, joinLink = conversation.JoinToken }));
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId = conversation.Id, internalCallerParams = internalParams, internalParams, joinLink = conversation.JoinToken }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<IActionResult> EndCallAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
||||||
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
||||||
|
if (qlyraCallStore is null) return StatusCode(503);
|
||||||
|
var id = ReadString(payload, "conversationId");
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return BadRequest();
|
||||||
|
if (!await qlyraCallStore.EndAsync(id, user.Id, ReadString(payload, "reason"))) return Forbid();
|
||||||
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<IActionResult> CallHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
private async Task<IActionResult> CallHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var user = await RequireCurrentUserAsync(cancellationToken);
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace Ikar.Server.Infrastructure.Calls;
|
|||||||
|
|
||||||
public sealed class CallSessionCleanupService(
|
public sealed class CallSessionCleanupService(
|
||||||
CallSessionStore store,
|
CallSessionStore store,
|
||||||
|
QlyraCallCompatibilityStore qlyraStore,
|
||||||
IHubContext<MessengerHub> hubContext,
|
IHubContext<MessengerHub> hubContext,
|
||||||
IOptions<WebRtcOptions> options,
|
IOptions<WebRtcOptions> options,
|
||||||
ILogger<CallSessionCleanupService> logger) : BackgroundService
|
ILogger<CallSessionCleanupService> logger) : BackgroundService
|
||||||
@@ -16,6 +17,7 @@ public sealed class CallSessionCleanupService(
|
|||||||
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
|
||||||
while (await timer.WaitForNextTickAsync(stoppingToken))
|
while (await timer.WaitForNextTickAsync(stoppingToken))
|
||||||
{
|
{
|
||||||
|
await qlyraStore.ExpireAsync(DateTimeOffset.UtcNow);
|
||||||
var expired = store.DrainExpired(
|
var expired = store.DrainExpired(
|
||||||
DateTimeOffset.UtcNow,
|
DateTimeOffset.UtcNow,
|
||||||
TimeSpan.FromSeconds(Math.Clamp(options.Value.RingingTimeoutSeconds, 15, 300)),
|
TimeSpan.FromSeconds(Math.Clamp(options.Value.RingingTimeoutSeconds, 15, 300)),
|
||||||
|
|||||||
@@ -6,22 +6,31 @@ using Microsoft.Extensions.Options;
|
|||||||
|
|
||||||
namespace Ikar.Server.Infrastructure.Calls;
|
namespace Ikar.Server.Infrastructure.Calls;
|
||||||
|
|
||||||
public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOptions)
|
public sealed class QlyraCallCompatibilityStore(
|
||||||
|
IOptions<WebRtcOptions> webRtcOptions,
|
||||||
|
IQlyraCallLifecycle? lifecycle = null,
|
||||||
|
ILogger<QlyraCallCompatibilityStore>? logger = null)
|
||||||
{
|
{
|
||||||
|
private readonly ConcurrentDictionary<string, QlyraCallConversation> _pendingHistory = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, QlyraCallConversation> _conversations = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, QlyraCallConversation> _conversations = new(StringComparer.Ordinal);
|
||||||
private readonly ConcurrentDictionary<string, (QlyraCallConversation Conversation, QlyraCallParticipant Participant)> _tokens = new(StringComparer.Ordinal);
|
private readonly ConcurrentDictionary<string, (QlyraCallConversation Conversation, QlyraCallParticipant Participant)> _tokens = new(StringComparer.Ordinal);
|
||||||
public IReadOnlyList<Ikar.Shared.CallIceServerDto> IceServers => webRtcOptions.Value.ToDto();
|
public IReadOnlyList<Ikar.Shared.CallIceServerDto> IceServers => webRtcOptions.Value.ToDto();
|
||||||
|
|
||||||
public QlyraCallConversation Create(string conversationId, bool isVideo, params (Guid UserId, int ClientId)[] users)
|
public QlyraCallConversation Create(string conversationId, bool isVideo, params (Guid UserId, int ClientId)[] users)
|
||||||
{
|
{
|
||||||
var conversation = new QlyraCallConversation(conversationId, isVideo);
|
var conversation = new QlyraCallConversation(conversationId, isVideo)
|
||||||
|
{
|
||||||
|
CallerId = users[0].UserId,
|
||||||
|
IsDirect = users.Length == 2,
|
||||||
|
ExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(webRtcOptions.Value.RingingTimeoutSeconds, 15, 45))
|
||||||
|
};
|
||||||
|
if (!_conversations.TryAdd(conversationId, conversation)) throw new InvalidOperationException("Call already exists.");
|
||||||
foreach (var user in users)
|
foreach (var user in users)
|
||||||
{
|
{
|
||||||
var participant = new QlyraCallParticipant(user.UserId, user.ClientId);
|
var participant = new QlyraCallParticipant(user.UserId, user.ClientId);
|
||||||
conversation.Participants[user.UserId] = participant;
|
conversation.Participants[user.UserId] = participant;
|
||||||
_tokens[participant.Token] = (conversation, participant);
|
_tokens[participant.Token] = (conversation, participant);
|
||||||
}
|
}
|
||||||
_conversations[conversationId] = conversation;
|
|
||||||
return conversation;
|
return conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +83,11 @@ public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOp
|
|||||||
}
|
}
|
||||||
else if (command == "accept-call")
|
else if (command == "accept-call")
|
||||||
{
|
{
|
||||||
|
if (resolved.Participant.UserId != resolved.Conversation.CallerId)
|
||||||
|
{
|
||||||
|
lock (resolved.Conversation)
|
||||||
|
resolved.Conversation.AcceptedAt ??= DateTimeOffset.UtcNow;
|
||||||
|
}
|
||||||
await SendAsync(resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
await SendAsync(resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
||||||
await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "accepted-call", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
||||||
}
|
}
|
||||||
@@ -84,8 +98,8 @@ public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOp
|
|||||||
}
|
}
|
||||||
else if (command == "hangup")
|
else if (command == "hangup")
|
||||||
{
|
{
|
||||||
await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "hungup", participantId = resolved.Participant.ClientId }, context.RequestAborted);
|
await EndAsync(resolved.Conversation.Id, resolved.Participant.UserId,
|
||||||
await FinishDirectAsync(resolved.Conversation, resolved.Participant);
|
root.TryGetProperty("reason", out var reason) ? reason.GetString() : null);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,11 +112,57 @@ public sealed class QlyraCallCompatibilityStore(IOptions<WebRtcOptions> webRtcOp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task FinishDirectAsync(QlyraCallConversation conversation, QlyraCallParticipant sender)
|
public async Task<bool> EndAsync(string conversationId, Guid senderId, string? reason = null)
|
||||||
{
|
{
|
||||||
if (conversation.Participants.Count > 2 || !_conversations.TryRemove(conversation.Id, out _)) return;
|
if (!_conversations.TryGetValue(conversationId, out var conversation)) return true;
|
||||||
|
if (!conversation.Participants.TryGetValue(senderId, out var sender)) return false;
|
||||||
|
if (!conversation.IsDirect)
|
||||||
|
{
|
||||||
|
await BroadcastAsync(conversation, sender, new { type = "notification", notification = "hungup", participantId = sender.ClientId }, CancellationToken.None);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await FinishAsync(conversation, senderId == conversation.CallerId ? "CANCELED" : "REJECTED");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ExpireAsync(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
foreach (var conversation in _conversations.Values.Where(c => c.IsDirect))
|
||||||
|
{
|
||||||
|
var expires = conversation.AcceptedAt?.AddHours(Math.Clamp(webRtcOptions.Value.MaximumCallDurationHours, 1, 24)) ?? conversation.ExpiresAt;
|
||||||
|
if (expires <= now) await FinishAsync(conversation, "MISSED");
|
||||||
|
}
|
||||||
|
foreach (var conversation in _pendingHistory.Values) await CompleteAsync(conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinishAsync(QlyraCallConversation conversation, string reason)
|
||||||
|
{
|
||||||
|
if (!_conversations.TryRemove(conversation.Id, out _)) return;
|
||||||
|
conversation.EndedAt = DateTimeOffset.UtcNow;
|
||||||
|
conversation.EndReason = conversation.AcceptedAt is null ? reason : "HUNGUP";
|
||||||
foreach (var participant in conversation.Participants.Values) _tokens.TryRemove(participant.Token, out _);
|
foreach (var participant in conversation.Participants.Values) _tokens.TryRemove(participant.Token, out _);
|
||||||
await BroadcastAsync(conversation, sender, new { type = "notification", notification = "closed-conversation" }, CancellationToken.None);
|
_pendingHistory[conversation.Id] = conversation;
|
||||||
|
// The independent lifecycle reaches phones that have not opened WS2 yet.
|
||||||
|
await CompleteAsync(conversation);
|
||||||
|
foreach (var participant in conversation.Participants.Values)
|
||||||
|
{
|
||||||
|
try { await SendAsync(participant, new { type = "notification", notification = "closed-conversation" }, CancellationToken.None); }
|
||||||
|
catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException or InvalidOperationException) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteAsync(QlyraCallConversation conversation)
|
||||||
|
{
|
||||||
|
if (lifecycle is null) { _pendingHistory.TryRemove(conversation.Id, out _); return; }
|
||||||
|
await conversation.CompletionLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!_pendingHistory.ContainsKey(conversation.Id)) return;
|
||||||
|
await lifecycle.CompleteAsync(conversation);
|
||||||
|
_pendingHistory.TryRemove(conversation.Id, out _);
|
||||||
|
}
|
||||||
|
catch (Exception exception) { logger?.LogError(exception, "Unable to complete call {ConversationId}; will retry.", conversation.Id); }
|
||||||
|
finally { conversation.CompletionLock.Release(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private object ConnectionNotification(QlyraCallConversation conversation)
|
private object ConnectionNotification(QlyraCallConversation conversation)
|
||||||
@@ -189,6 +249,15 @@ public sealed class QlyraCallConversation(string id, bool isVideo)
|
|||||||
{
|
{
|
||||||
public string Id { get; } = id;
|
public string Id { get; } = id;
|
||||||
public bool IsVideo { get; } = isVideo;
|
public bool IsVideo { get; } = isVideo;
|
||||||
|
public bool IsDirect { get; init; }
|
||||||
|
public Guid CallerId { get; init; }
|
||||||
|
public Guid HistoryId { get; } = Guid.NewGuid();
|
||||||
|
public DateTimeOffset CreatedAt { get; } = DateTimeOffset.UtcNow;
|
||||||
|
public DateTimeOffset ExpiresAt { get; init; }
|
||||||
|
public DateTimeOffset? AcceptedAt { get; set; }
|
||||||
|
public DateTimeOffset? EndedAt { get; set; }
|
||||||
|
public string EndReason { get; set; } = "CANCELED";
|
||||||
|
public SemaphoreSlim CompletionLock { get; } = new(1, 1);
|
||||||
public string JoinToken { get; } = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18)).ToLowerInvariant();
|
public string JoinToken { get; } = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(18)).ToLowerInvariant();
|
||||||
public ConcurrentDictionary<Guid, QlyraCallParticipant> Participants { get; } = new();
|
public ConcurrentDictionary<Guid, QlyraCallParticipant> Participants { get; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Ikar.Server.Data;
|
||||||
|
using Ikar.Server.Data.Entities;
|
||||||
|
using Ikar.Server.Infrastructure.Hubs;
|
||||||
|
using Ikar.Server.Infrastructure.Push;
|
||||||
|
using Ikar.Shared;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Ikar.Server.Infrastructure.Calls;
|
||||||
|
|
||||||
|
public interface IQlyraCallLifecycle
|
||||||
|
{
|
||||||
|
Task CompleteAsync(QlyraCallConversation conversation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class QlyraCallLifecycle(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IHubContext<MessengerHub> hub,
|
||||||
|
ILogger<QlyraCallLifecycle> logger) : IQlyraCallLifecycle
|
||||||
|
{
|
||||||
|
// Serialize direct-chat creation and retry with a stable message ID.
|
||||||
|
private readonly SemaphoreSlim _historyLock = new(1, 1);
|
||||||
|
|
||||||
|
public async Task CompleteAsync(QlyraCallConversation call)
|
||||||
|
{
|
||||||
|
var recipients = call.Participants.Keys.Select(id => id.ToString()).ToArray();
|
||||||
|
await PublishAsync(recipients, new { conversationId = call.Id, @event = "ended" });
|
||||||
|
using var scope = scopes.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
|
||||||
|
var push = scope.ServiceProvider.GetRequiredService<IPushNotificationSender>();
|
||||||
|
var devices = await db.PushDevices.AsNoTracking()
|
||||||
|
.Where(d => call.Participants.Keys.Contains(d.UserId) && d.NotificationsEnabled).ToListAsync();
|
||||||
|
// Push failures must not prevent the durable call record.
|
||||||
|
var pushTask = FinishPushAsync(push, call.Id, devices);
|
||||||
|
await _historyLock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!await db.Messages.AnyAsync(m => m.Id == call.HistoryId))
|
||||||
|
{
|
||||||
|
var caller = call.Participants[call.CallerId];
|
||||||
|
var callee = call.Participants.Values.Single(p => p.UserId != call.CallerId);
|
||||||
|
var chat = await db.Chats.Include(c => c.Members).FirstOrDefaultAsync(c =>
|
||||||
|
c.Type == ChatType.Direct && c.Members.Count == 2 &&
|
||||||
|
c.Members.Any(m => m.UserId == caller.UserId) && c.Members.Any(m => m.UserId == callee.UserId));
|
||||||
|
if (chat is null)
|
||||||
|
{
|
||||||
|
chat = new Chat { Type = ChatType.Direct, CreatedById = caller.UserId,
|
||||||
|
Title = await db.Users.Where(u => u.Id == callee.UserId).Select(u => u.DisplayName).SingleAsync() };
|
||||||
|
chat.Members.Add(new ChatMember { UserId = caller.UserId, IsOwner = true, Role = ChatMemberRole.Owner,
|
||||||
|
CanPostMessages = true, CanDeleteMessages = true, CanPinMessages = true });
|
||||||
|
chat.Members.Add(new ChatMember { UserId = callee.UserId, Role = ChatMemberRole.Member,
|
||||||
|
CanPostMessages = true, CanPinMessages = true });
|
||||||
|
db.Chats.Add(chat);
|
||||||
|
}
|
||||||
|
var duration = call.AcceptedAt is { } accepted
|
||||||
|
? Math.Max(1L, (long)((call.EndedAt ?? DateTimeOffset.UtcNow) - accepted).TotalMilliseconds) : 0L;
|
||||||
|
chat.LastActivityAt = call.EndedAt ?? DateTimeOffset.UtcNow;
|
||||||
|
db.Messages.Add(new Message { Id = call.HistoryId, Chat = chat, SenderId = caller.UserId,
|
||||||
|
SentAt = call.CreatedAt, QlyraAttachesJson = JsonSerializer.Serialize(new[] { new {
|
||||||
|
_type = "CALL", callType = call.IsVideo ? "VIDEO" : "AUDIO", conversationId = call.Id,
|
||||||
|
duration, hangupType = call.EndReason, contactIds = new[] { callee.ClientId }
|
||||||
|
} }) });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally { _historyLock.Release(); }
|
||||||
|
// This event follows the commit, so an open Calls tab fetches the new record.
|
||||||
|
await PublishAsync(recipients, new { conversationId = call.Id, @event = "history" });
|
||||||
|
await pushTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PublishAsync(string[] recipients, object payload)
|
||||||
|
{
|
||||||
|
try { await hub.Clients.Users(recipients).SendAsync("QlyraPacket", new { opcode = 137, payload }); }
|
||||||
|
catch (Exception ex) { logger.LogWarning(ex, "Unable to send call lifecycle event."); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinishPushAsync(IPushNotificationSender push, string id, IReadOnlyCollection<PushDevice> devices)
|
||||||
|
{
|
||||||
|
try { await push.SendQlyraCallFinishedAsync(id, devices); }
|
||||||
|
catch (Exception ex) { logger.LogWarning(ex, "Unable to send call finished push for {ConversationId}.", id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -317,12 +317,31 @@ public sealed class FirebasePushNotificationSender(
|
|||||||
[PushDataKeys.AccountId] = call.CalleeClientId.ToString(),
|
[PushDataKeys.AccountId] = call.CalleeClientId.ToString(),
|
||||||
[PushDataKeys.IsVideo] = call.IsVideo ? "true" : "false"
|
[PushDataKeys.IsVideo] = call.IsVideo ? "true" : "false"
|
||||||
};
|
};
|
||||||
|
if (call.ExpiresAt is { } expiresAt) data["expiresAt"] = expiresAt.ToString();
|
||||||
foreach (var device in androidDevices)
|
foreach (var device in androidDevices)
|
||||||
{
|
{
|
||||||
await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken);
|
await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendQlyraCallFinishedAsync(string conversationId, IReadOnlyCollection<PushDevice> devices, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var androidDevices = GetAndroidDevices(devices);
|
||||||
|
if (androidDevices.Count == 0) return;
|
||||||
|
var credentials = LoadCredentials();
|
||||||
|
if (credentials is null) return;
|
||||||
|
var projectId = ResolveProjectId(credentials);
|
||||||
|
if (string.IsNullOrWhiteSpace(projectId)) return;
|
||||||
|
var accessToken = await GetAccessTokenAsync(credentials, cancellationToken);
|
||||||
|
var data = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[PushDataKeys.Type] = "CallFinished",
|
||||||
|
[PushDataKeys.ConversationId] = conversationId
|
||||||
|
};
|
||||||
|
foreach (var device in androidDevices)
|
||||||
|
await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<bool> SendLoginCodeAsync(
|
public async Task<bool> SendLoginCodeAsync(
|
||||||
string code,
|
string code,
|
||||||
string challengeId,
|
string challengeId,
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ public interface IPushNotificationSender
|
|||||||
IReadOnlyCollection<PushDevice> devices,
|
IReadOnlyCollection<PushDevice> devices,
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task SendQlyraCallFinishedAsync(
|
||||||
|
string conversationId,
|
||||||
|
IReadOnlyCollection<PushDevice> devices,
|
||||||
|
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||||
|
|
||||||
Task<bool> SendLoginCodeAsync(
|
Task<bool> SendLoginCodeAsync(
|
||||||
string code,
|
string code,
|
||||||
string challengeId,
|
string challengeId,
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ builder.Services.AddAuthorization(options =>
|
|||||||
builder.Services.AddSingleton<PresenceTracker>();
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
builder.Services.AddSingleton<CallSessionStore>();
|
builder.Services.AddSingleton<CallSessionStore>();
|
||||||
builder.Services.AddSingleton<QlyraCallCompatibilityStore>();
|
builder.Services.AddSingleton<QlyraCallCompatibilityStore>();
|
||||||
|
builder.Services.AddSingleton<IQlyraCallLifecycle, QlyraCallLifecycle>();
|
||||||
builder.Services.AddSingleton<QlyraWebPushSessionStore>();
|
builder.Services.AddSingleton<QlyraWebPushSessionStore>();
|
||||||
builder.Services.AddHostedService<CallSessionCleanupService>();
|
builder.Services.AddHostedService<CallSessionCleanupService>();
|
||||||
builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow));
|
builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow));
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ public sealed record QlyraIncomingCallPushDto(
|
|||||||
int CallerClientId,
|
int CallerClientId,
|
||||||
string CallerName,
|
string CallerName,
|
||||||
int CalleeClientId,
|
int CalleeClientId,
|
||||||
bool IsVideo);
|
bool IsVideo,
|
||||||
|
long? ExpiresAt = null);
|
||||||
|
|
||||||
public static class PushDataKeys
|
public static class PushDataKeys
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,6 +99,17 @@ class CallsModule {
|
|||||||
|
|
||||||
CallsModule(this._api);
|
CallsModule(this._api);
|
||||||
|
|
||||||
|
Future<void> endCall(String conversationId, {required String reason}) async {
|
||||||
|
try {
|
||||||
|
await _api.sendRequest(Opcode.ikarEndCall, {
|
||||||
|
'conversationId': conversationId,
|
||||||
|
'reason': reason,
|
||||||
|
}, silent: true);
|
||||||
|
} catch (error) {
|
||||||
|
logger.w('CallsModule.endCall: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_CallerEndpoint _parseCallerEndpoint(
|
_CallerEndpoint _parseCallerEndpoint(
|
||||||
Map payload,
|
Map payload,
|
||||||
List<String> keys, {
|
List<String> keys, {
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ class CallBridge {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action == 'ended') {
|
if (action == 'ended') {
|
||||||
CallController.instance.dismissIncoming();
|
final id = event['conversationId']?.toString();
|
||||||
|
if (id != null) CallController.instance.cancelConversation(id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final dataStr = event['data'];
|
final dataStr = event['data'];
|
||||||
@@ -69,10 +70,38 @@ class CallBridge {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> notifyAccepted({String? caller}) async {
|
Future<void> showIncoming(Map<dynamic, dynamic> payload) async {
|
||||||
if (!_android) return;
|
if (!_android) return;
|
||||||
try {
|
try {
|
||||||
await _method.invokeMethod<void>('notifyAccepted', {'caller': caller});
|
await _method.invokeMethod<void>('showIncoming', {
|
||||||
|
for (final entry in payload.entries)
|
||||||
|
entry.key.toString(): entry.value?.toString() ?? '',
|
||||||
|
'type': 'InboundCall',
|
||||||
|
'iv': payload['type'] == 'VIDEO' ? 'true' : 'false',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.w('CallBridge.showIncoming: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancelConversation(String id) async {
|
||||||
|
if (!_android) return;
|
||||||
|
try {
|
||||||
|
await _method.invokeMethod<void>('cancelConversation', {
|
||||||
|
'conversationId': id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.w('CallBridge.cancelConversation: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> notifyAccepted({String? caller, String? conversationId}) async {
|
||||||
|
if (!_android) return;
|
||||||
|
try {
|
||||||
|
await _method.invokeMethod<void>('notifyAccepted', {
|
||||||
|
'caller': caller,
|
||||||
|
'conversationId': conversationId,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.w('CallBridge.notifyAccepted: caller=$caller $e');
|
logger.w('CallBridge.notifyAccepted: caller=$caller $e');
|
||||||
}
|
}
|
||||||
@@ -108,10 +137,12 @@ class CallBridge {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> notifyEnded() async {
|
Future<void> notifyEnded({String? conversationId}) async {
|
||||||
if (!_android) return;
|
if (!_android) return;
|
||||||
try {
|
try {
|
||||||
await _method.invokeMethod<void>('notifyEnded');
|
await _method.invokeMethod<void>('notifyEnded', {
|
||||||
|
'conversationId': conversationId,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.w('CallBridge.notifyEnded: $e');
|
logger.w('CallBridge.notifyEnded: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||||
|
|
||||||
import '../../backend/api.dart';
|
import '../../backend/api.dart';
|
||||||
import '../../backend/modules/calls.dart';
|
import '../../backend/modules/calls.dart';
|
||||||
import '../protocol/opcode_map.dart';
|
import '../protocol/opcode_map.dart';
|
||||||
import '../push/fkm_controller.dart';
|
|
||||||
import '../protocol/packet.dart';
|
import '../protocol/packet.dart';
|
||||||
import '../utils/parse.dart';
|
import '../utils/parse.dart';
|
||||||
import 'call_bridge.dart';
|
import 'call_bridge.dart';
|
||||||
@@ -38,6 +38,8 @@ class IncomingCall {
|
|||||||
|
|
||||||
class CallController {
|
class CallController {
|
||||||
CallController._();
|
CallController._();
|
||||||
|
@visibleForTesting
|
||||||
|
CallController.forTesting();
|
||||||
static final CallController instance = CallController._();
|
static final CallController instance = CallController._();
|
||||||
|
|
||||||
Api? _api;
|
Api? _api;
|
||||||
@@ -66,6 +68,7 @@ class CallController {
|
|||||||
|
|
||||||
IncomingCall? _pending;
|
IncomingCall? _pending;
|
||||||
IncomingCall? get pendingIncoming => _pending;
|
IncomingCall? get pendingIncoming => _pending;
|
||||||
|
final _finishedIncoming = <String>{};
|
||||||
|
|
||||||
bool get isBusy => _active != null || _starting;
|
bool get isBusy => _active != null || _starting;
|
||||||
|
|
||||||
@@ -80,16 +83,30 @@ class CallController {
|
|||||||
if (packet.opcode != Opcode.notifCallStart) return;
|
if (packet.opcode != Opcode.notifCallStart) return;
|
||||||
final payload = packet.payload;
|
final payload = packet.payload;
|
||||||
if (payload is! Map) return;
|
if (payload is! Map) return;
|
||||||
|
final id = payload['conversationId']?.toString();
|
||||||
|
if (payload['event'] == 'ended') {
|
||||||
|
if (id != null) cancelConversation(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload['event'] == 'history') {
|
||||||
|
_ended.add(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final vcp = payload['vcp'] as String?;
|
final vcp = payload['vcp'] as String?;
|
||||||
final conversationId = payload['conversationId'] as String?;
|
final conversationId = payload['conversationId'] as String?;
|
||||||
final callerId = payload['callerId'] as int?;
|
final callerId = payload['callerId'] as int?;
|
||||||
if (vcp == null || conversationId == null || callerId == null) return;
|
if (vcp == null || conversationId == null || callerId == null) return;
|
||||||
|
if (_finishedIncoming.contains(conversationId)) return;
|
||||||
|
final expiresAt = parseIntOrNull(payload['expiresAt']);
|
||||||
|
if (expiresAt != null &&
|
||||||
|
expiresAt <= DateTime.now().millisecondsSinceEpoch) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Приложение свёрнуто — звонок показывает FKM отдельным уведомлением,
|
// The native call screen works while Flutter is in the background.
|
||||||
// приём оттуда вернётся через injectFromNative.
|
|
||||||
if (!appResumed) {
|
if (!appResumed) {
|
||||||
unawaited(FkmController.instance.showIncomingCall(payload));
|
unawaited(CallBridge.instance.showIncoming(payload));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +121,7 @@ class CallController {
|
|||||||
params: params,
|
params: params,
|
||||||
country: payload['country'] as String?,
|
country: payload['country'] as String?,
|
||||||
isContact: payload['isContact'] as bool?,
|
isContact: payload['isContact'] as bool?,
|
||||||
|
callerName: payload['userName']?.toString(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,13 +152,15 @@ class CallController {
|
|||||||
params: params,
|
params: params,
|
||||||
country: data['country']?.toString(),
|
country: data['country']?.toString(),
|
||||||
isContact: data['isContact'] is bool ? data['isContact'] as bool : null,
|
isContact: data['isContact'] is bool ? data['isContact'] as bool : null,
|
||||||
callerName: data['userName']?.toString(),
|
callerName: (data['userName'] ?? data['senderName'] ?? data['title'])
|
||||||
|
?.toString(),
|
||||||
autoAccept: autoAccept,
|
autoAccept: autoAccept,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _emitIncoming(IncomingCall incoming) {
|
void _emitIncoming(IncomingCall incoming) {
|
||||||
|
if (_finishedIncoming.contains(incoming.conversationId)) return;
|
||||||
if (isBusy) return;
|
if (isBusy) return;
|
||||||
if (_pending?.conversationId == incoming.conversationId) return;
|
if (_pending?.conversationId == incoming.conversationId) return;
|
||||||
_pending = incoming;
|
_pending = incoming;
|
||||||
@@ -153,6 +173,21 @@ class CallController {
|
|||||||
_canceled.add(null);
|
_canceled.add(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void cancelConversation(String conversationId) {
|
||||||
|
_finishedIncoming.add(conversationId);
|
||||||
|
if (_finishedIncoming.length > 128) {
|
||||||
|
_finishedIncoming.remove(_finishedIncoming.first);
|
||||||
|
}
|
||||||
|
unawaited(CallBridge.instance.cancelConversation(conversationId));
|
||||||
|
if (_pending?.conversationId == conversationId) dismissIncoming();
|
||||||
|
if ((_active?.info.conversationId ??
|
||||||
|
_active?.ws2Config.uri.queryParameters['conversationId']) ==
|
||||||
|
conversationId) {
|
||||||
|
unawaited(endActive());
|
||||||
|
}
|
||||||
|
_ended.add(null);
|
||||||
|
}
|
||||||
|
|
||||||
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) =>
|
Future<CallSession> startOutgoing(int calleeId, {bool isVideo = false}) =>
|
||||||
_startExclusive(() async {
|
_startExclusive(() async {
|
||||||
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
|
final out = await _calls!.initiateCall(calleeId, isVideo: isVideo);
|
||||||
@@ -162,7 +197,12 @@ class CallController {
|
|||||||
device: _api?.callsDevice,
|
device: _api?.callsDevice,
|
||||||
osVersion: _api?.callsOsVersion,
|
osVersion: _api?.callsOsVersion,
|
||||||
);
|
);
|
||||||
final session = CallSession(ws2Config: config, role: CallRole.caller);
|
final session = CallSession(
|
||||||
|
ws2Config: config,
|
||||||
|
role: CallRole.caller,
|
||||||
|
endOnServer: (reason) =>
|
||||||
|
_calls!.endCall(out.conversationId, reason: reason),
|
||||||
|
);
|
||||||
return _launch(session, session.start);
|
return _launch(session, session.start);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -206,6 +246,9 @@ class CallController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<CallSession> acceptIncoming(IncomingCall call) async {
|
Future<CallSession> acceptIncoming(IncomingCall call) async {
|
||||||
|
if (_finishedIncoming.contains(call.conversationId) || isBusy) {
|
||||||
|
throw StateError('Звонок уже завершён или занят');
|
||||||
|
}
|
||||||
_pending = null;
|
_pending = null;
|
||||||
CallBridge.instance.cancelIncoming();
|
CallBridge.instance.cancelIncoming();
|
||||||
final config = Ws2Config.fromVcp(
|
final config = Ws2Config.fromVcp(
|
||||||
@@ -218,6 +261,8 @@ class CallController {
|
|||||||
ws2Config: config,
|
ws2Config: config,
|
||||||
params: call.params,
|
params: call.params,
|
||||||
role: CallRole.callee,
|
role: CallRole.callee,
|
||||||
|
endOnServer: (reason) =>
|
||||||
|
_calls!.endCall(call.conversationId, reason: reason),
|
||||||
);
|
);
|
||||||
return _launch(session, () async {
|
return _launch(session, () async {
|
||||||
await session.start();
|
await session.start();
|
||||||
@@ -227,7 +272,7 @@ class CallController {
|
|||||||
|
|
||||||
Future<void> rejectIncoming(IncomingCall call) async {
|
Future<void> rejectIncoming(IncomingCall call) async {
|
||||||
_pending = null;
|
_pending = null;
|
||||||
CallBridge.instance.notifyEnded();
|
CallBridge.instance.notifyEnded(conversationId: call.conversationId);
|
||||||
final config = Ws2Config.fromVcp(
|
final config = Ws2Config.fromVcp(
|
||||||
call.params,
|
call.params,
|
||||||
conversationId: call.conversationId,
|
conversationId: call.conversationId,
|
||||||
@@ -235,12 +280,17 @@ class CallController {
|
|||||||
osVersion: _api?.callsOsVersion,
|
osVersion: _api?.callsOsVersion,
|
||||||
);
|
);
|
||||||
final signaling = Ws2Signaling(config);
|
final signaling = Ws2Signaling(config);
|
||||||
|
final endOnServer = _calls!.endCall(
|
||||||
|
call.conversationId,
|
||||||
|
reason: 'REJECTED',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
await signaling.connect();
|
await signaling.connect();
|
||||||
await signaling.hangup(reason: 'REJECTED');
|
await signaling.hangup(reason: 'REJECTED');
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
} finally {
|
} finally {
|
||||||
await signaling.close();
|
await signaling.close();
|
||||||
|
await endOnServer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,7 +318,10 @@ class CallController {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
CallBridge.instance.notifyAccepted(caller: caller);
|
CallBridge.instance.notifyAccepted(
|
||||||
|
caller: caller,
|
||||||
|
conversationId: session.ws2Config.uri.queryParameters['conversationId'],
|
||||||
|
);
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +340,9 @@ class CallController {
|
|||||||
_activeJoinLink = null;
|
_activeJoinLink = null;
|
||||||
await _activeSub?.cancel();
|
await _activeSub?.cancel();
|
||||||
_activeSub = null;
|
_activeSub = null;
|
||||||
CallBridge.instance.notifyEnded();
|
CallBridge.instance.notifyEnded(
|
||||||
|
conversationId: session.ws2Config.uri.queryParameters['conversationId'],
|
||||||
|
);
|
||||||
_ended.add(null);
|
_ended.add(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,12 +67,14 @@ class CallSession {
|
|||||||
final ConversationParams? params;
|
final ConversationParams? params;
|
||||||
final CallRole role;
|
final CallRole role;
|
||||||
final bool isGroup;
|
final bool isGroup;
|
||||||
|
final Future<void> Function(String reason)? endOnServer;
|
||||||
|
|
||||||
CallSession({
|
CallSession({
|
||||||
required this.ws2Config,
|
required this.ws2Config,
|
||||||
required this.role,
|
required this.role,
|
||||||
this.params,
|
this.params,
|
||||||
this.isGroup = false,
|
this.isGroup = false,
|
||||||
|
this.endOnServer,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ws2Signaling? _signaling;
|
Ws2Signaling? _signaling;
|
||||||
@@ -2367,12 +2369,23 @@ class CallSession {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _hangingUp = false;
|
||||||
Future<void> hangup({String? reason}) async {
|
Future<void> hangup({String? reason}) async {
|
||||||
|
if (_ended || _hangingUp) return;
|
||||||
|
_hangingUp = true;
|
||||||
final r = reason ?? _autoHangupReason();
|
final r = reason ?? _autoHangupReason();
|
||||||
|
final serverEnd = endOnServer?.call(r).catchError((Object error) {
|
||||||
|
logger.w('[call] server termination failed: $error');
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await _signaling?.hangup(reason: r);
|
await _signaling?.hangup(reason: r);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_end();
|
_end();
|
||||||
|
try {
|
||||||
|
await serverEnd;
|
||||||
|
} catch (error) {
|
||||||
|
logger.w('[call] server termination failed: $error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _autoHangupReason() {
|
String _autoHangupReason() {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
///
|
///
|
||||||
/// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения)
|
/// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения)
|
||||||
abstract class Opcode {
|
abstract class Opcode {
|
||||||
|
// IKAR extension: terminate even before the recipient connects to WS2.
|
||||||
|
static const int ikarEndCall = 1001;
|
||||||
// ── Session ────────────────────────────────────────────────────────
|
// ── Session ────────────────────────────────────────────────────────
|
||||||
static const int ping = 1; // Пинг
|
static const int ping = 1; // Пинг
|
||||||
static const int debug = 2; // Отладка / понг
|
static const int debug = 2; // Отладка / понг
|
||||||
@@ -235,7 +237,6 @@ abstract class Opcode {
|
|||||||
static const int loginByQr = 291; // Вход по QR
|
static const int loginByQr = 291; // Вход по QR
|
||||||
static const int msgDelivery = 303; // Подтверждение доставки сообщения
|
static const int msgDelivery = 303; // Подтверждение доставки сообщения
|
||||||
|
|
||||||
|
|
||||||
static const int storiesGetByStoryId = 220; // Истории по ID
|
static const int storiesGetByStoryId = 220; // Истории по ID
|
||||||
|
|
||||||
// ── Human-readable names ───────────────────────────────────────────
|
// ── Human-readable names ───────────────────────────────────────────
|
||||||
@@ -424,6 +425,5 @@ abstract class Opcode {
|
|||||||
getQrStatus: 'GET_QR_STATUS',
|
getQrStatus: 'GET_QR_STATUS',
|
||||||
loginByQr: 'LOGIN_BY_QR',
|
loginByQr: 'LOGIN_BY_QR',
|
||||||
msgDelivery: 'MSG_DELIVERY',
|
msgDelivery: 'MSG_DELIVERY',
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -563,7 +563,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
|||||||
child: _localRendererReady && _localRenderer.srcObject != null
|
child: _localRendererReady && _localRenderer.srcObject != null
|
||||||
? CallVideoView(
|
? CallVideoView(
|
||||||
renderer: _localRenderer,
|
renderer: _localRenderer,
|
||||||
mirror: _session?.localScreen != true,
|
mirror: false,
|
||||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||||
placeholder: _localPreviewIcon(cs),
|
placeholder: _localPreviewIcon(cs),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -36,10 +36,15 @@ class _CallsTabState extends State<CallsTab>
|
|||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
|
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
|
||||||
StreamSubscription<LoginStatus>? _loginSub;
|
StreamSubscription<LoginStatus>? _loginSub;
|
||||||
|
StreamSubscription<void>? _endedSub;
|
||||||
|
int _historyGeneration = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_endedSub = CallController.instance.callEnded.listen((_) {
|
||||||
|
if (accountModule.isLoggedIn) _loadHistory();
|
||||||
|
});
|
||||||
if (accountModule.isLoggedIn) {
|
if (accountModule.isLoggedIn) {
|
||||||
_loadHistory();
|
_loadHistory();
|
||||||
} else {
|
} else {
|
||||||
@@ -56,6 +61,7 @@ class _CallsTabState extends State<CallsTab>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_loginSub?.cancel();
|
_loginSub?.cancel();
|
||||||
|
_endedSub?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +71,9 @@ class _CallsTabState extends State<CallsTab>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory() async {
|
Future<void> _loadHistory() async {
|
||||||
|
final generation = ++_historyGeneration;
|
||||||
final p = await AppDatabase.loadActiveProfile();
|
final p = await AppDatabase.loadActiveProfile();
|
||||||
|
if (!mounted || generation != _historyGeneration) return;
|
||||||
if (p == null) {
|
if (p == null) {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted) setState(() => _isLoading = false);
|
||||||
return;
|
return;
|
||||||
@@ -76,9 +84,12 @@ class _CallsTabState extends State<CallsTab>
|
|||||||
try {
|
try {
|
||||||
calls = await callsModule.fetchHistory(p.id, p.id);
|
calls = await callsModule.fetchHistory(p.id, p.id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) setState(() => _isLoading = false);
|
if (mounted && generation == _historyGeneration) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!mounted || generation != _historyGeneration) return;
|
||||||
|
|
||||||
final List<CallLogEntry> grouped = [];
|
final List<CallLogEntry> grouped = [];
|
||||||
for (final call in calls) {
|
for (final call in calls) {
|
||||||
|
|||||||
@@ -227,6 +227,13 @@ class _NotificationsScreenState extends State<NotificationsScreen>
|
|||||||
value: _callNotifications,
|
value: _callNotifications,
|
||||||
onChanged: _onCallNotificationsChanged,
|
onChanged: _onCallNotificationsChanged,
|
||||||
),
|
),
|
||||||
|
if (Platform.isAndroid)
|
||||||
|
SettingsNavTile(
|
||||||
|
icon: Symbols.lock,
|
||||||
|
label: l10n.notificationsCallPermissionTitle,
|
||||||
|
onTap: () => CallBridge.instance
|
||||||
|
.openFullScreenIntentSettings(),
|
||||||
|
),
|
||||||
SettingsToggleTile(
|
SettingsToggleTile(
|
||||||
icon: Symbols.person_add,
|
icon: Symbols.person_add,
|
||||||
label: l10n.notificationsNewContactsLabel,
|
label: l10n.notificationsNewContactsLabel,
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.7+8
|
version: 1.0.8+9
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:ikar/core/security/app_lock.dart';
|
import 'package:ikar/core/security/app_lock.dart';
|
||||||
import 'package:ikar/frontend/screens/lock/app_lock_layer.dart';
|
import 'package:ikar/frontend/screens/lock/app_lock_layer.dart';
|
||||||
|
import 'package:ikar/frontend/screens/lock/passcode_pad.dart';
|
||||||
import 'package:ikar/l10n/app_localizations.dart';
|
import 'package:ikar/l10n/app_localizations.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -23,9 +24,18 @@ Future<void> _enter(WidgetTester tester, String pin) async {
|
|||||||
await tester.tap(find.text(digit));
|
await tester.tap(find.text(digit));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
}
|
}
|
||||||
|
// Wait for password verification, which can take longer under build load.
|
||||||
|
for (var attempt = 0; attempt < 100; attempt++) {
|
||||||
|
final keypad = find.byType(PasscodeKeypad);
|
||||||
|
if (keypad.evaluate().isEmpty ||
|
||||||
|
tester.widget<PasscodeKeypad>(keypad).enabled) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
await tester.runAsync(
|
await tester.runAsync(
|
||||||
() => Future<void>.delayed(const Duration(milliseconds: 400)),
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
);
|
);
|
||||||
|
await tester.pump();
|
||||||
|
}
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ class BrokenSignaling extends Ws2Signaling {
|
|||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
test('hangup reaches IKAR even before WS2 connects', () async {
|
||||||
|
final reasons = <String>[];
|
||||||
|
final session = CallSession(
|
||||||
|
ws2Config: config,
|
||||||
|
role: CallRole.caller,
|
||||||
|
endOnServer: (reason) async {
|
||||||
|
reasons.add(reason);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await session.hangup();
|
||||||
|
await session.hangup();
|
||||||
|
expect(reasons, ['CANCELED']);
|
||||||
|
expect(session.currentState, CallSessionState.ended);
|
||||||
|
});
|
||||||
test(
|
test(
|
||||||
'ICE send failure is handled and late candidates are ignored after hangup',
|
'ICE send failure is handled and late candidates are ignored after hangup',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ikar/backend/api.dart';
|
||||||
|
import 'package:ikar/core/calls/call_controller.dart';
|
||||||
|
import 'package:ikar/core/protocol/packet.dart';
|
||||||
|
import 'package:kusoft/src/rust/api.dart' show CallParams;
|
||||||
|
import 'package:kusoft/src/rust/frb_generated.dart' show RustLib, RustLibApi;
|
||||||
|
|
||||||
|
class _PushApi extends Api {
|
||||||
|
final packets = StreamController<Packet>.broadcast();
|
||||||
|
@override
|
||||||
|
Stream<Packet> get pushStream => packets.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallDecoder implements RustLibApi {
|
||||||
|
@override
|
||||||
|
CallParams? crateApiDecodeVcp({
|
||||||
|
required String vcp,
|
||||||
|
required String conversationId,
|
||||||
|
}) => const CallParams(
|
||||||
|
token: 'synthetic',
|
||||||
|
wsEndpoint: 'wss://calls.invalid/ws2',
|
||||||
|
turn: [],
|
||||||
|
isVideo: false,
|
||||||
|
);
|
||||||
|
@override
|
||||||
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
setUpAll(() => RustLib.initMock(api: _CallDecoder()));
|
||||||
|
|
||||||
|
late _PushApi api;
|
||||||
|
late CallController controller;
|
||||||
|
setUp(() {
|
||||||
|
api = _PushApi();
|
||||||
|
controller = CallController.forTesting()..appResumed = true;
|
||||||
|
controller.init(api);
|
||||||
|
});
|
||||||
|
tearDown(() async {
|
||||||
|
controller.dispose();
|
||||||
|
await api.packets.close();
|
||||||
|
await api.dispose();
|
||||||
|
});
|
||||||
|
Future<void> push(Map<String, dynamic> data) async {
|
||||||
|
api.packets.add(Packet(opcode: 137, payload: data));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> invite(String id) => {
|
||||||
|
'conversationId': id,
|
||||||
|
'callerId': 2,
|
||||||
|
'vcp': 'synthetic-incoming',
|
||||||
|
'type': 'AUDIO',
|
||||||
|
};
|
||||||
|
|
||||||
|
test(
|
||||||
|
'caller cancellation dismisses a ringing recipient without WS2',
|
||||||
|
() async {
|
||||||
|
var canceled = 0;
|
||||||
|
final sub = controller.incomingCanceled.listen((_) => canceled++);
|
||||||
|
await push(invite('first'));
|
||||||
|
expect(controller.pendingIncoming?.conversationId, 'first');
|
||||||
|
await push({'conversationId': 'first', 'event': 'ended'});
|
||||||
|
expect(controller.pendingIncoming, isNull);
|
||||||
|
expect(canceled, 1);
|
||||||
|
await sub.cancel();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test('late invite after cancellation does not start ringing', () async {
|
||||||
|
await push({'conversationId': 'first', 'event': 'ended'});
|
||||||
|
await push(invite('first'));
|
||||||
|
controller.injectFromNative(invite('first'), autoAccept: true);
|
||||||
|
expect(controller.pendingIncoming, isNull);
|
||||||
|
});
|
||||||
|
test('old cancellation cannot dismiss a newer incoming call', () async {
|
||||||
|
await push(invite('new'));
|
||||||
|
await push({'conversationId': 'old', 'event': 'ended'});
|
||||||
|
expect(controller.pendingIncoming?.conversationId, 'new');
|
||||||
|
});
|
||||||
|
test(
|
||||||
|
'expired invite is ignored and committed history refreshes listeners',
|
||||||
|
() async {
|
||||||
|
await push({
|
||||||
|
...invite('expired'),
|
||||||
|
'expiresAt': DateTime.now().millisecondsSinceEpoch - 1000,
|
||||||
|
});
|
||||||
|
expect(controller.pendingIncoming, isNull);
|
||||||
|
var refreshed = 0;
|
||||||
|
final sub = controller.callEnded.listen((_) => refreshed++);
|
||||||
|
await push({'conversationId': 'expired', 'event': 'history'});
|
||||||
|
expect(refreshed, 1);
|
||||||
|
await sub.cancel();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import '../lib/core/transport/ikar_network.dart';
|
||||||
|
import 'live_ikar_smoke.dart' as smoke;
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
final base = Uri.parse('https://ikar.kusoft.xyz');
|
||||||
|
final node = Platform.environment['IKAR_TEST_NODE'] ?? 'node';
|
||||||
|
final stamp = DateTime.now().millisecondsSinceEpoch.toString().substring(5);
|
||||||
|
final devices = <smoke.Device>[];
|
||||||
|
final checks = <String>[];
|
||||||
|
final sockets = <WebSocket>[];
|
||||||
|
try {
|
||||||
|
for (var i = 0; i < 3; i++) {
|
||||||
|
devices.add(
|
||||||
|
await smoke.Device.create(
|
||||||
|
base,
|
||||||
|
node,
|
||||||
|
'+999$stamp$i',
|
||||||
|
'Call test ${i + 1}',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final a = devices[0], b = devices[1], outsider = devices[2];
|
||||||
|
Future<Map> invite(String id, bool video) async {
|
||||||
|
final incoming = b.transport.events
|
||||||
|
.firstWhere(
|
||||||
|
(e) =>
|
||||||
|
e['opcode'] == 137 &&
|
||||||
|
e['payload']?['conversationId'] == id &&
|
||||||
|
e['payload']?['vcp'] != null,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
final result = await a.ok(78, {
|
||||||
|
'calleeIds': [b.id],
|
||||||
|
'conversationId': id,
|
||||||
|
'isVideo': video,
|
||||||
|
});
|
||||||
|
final payload = (await incoming)['payload'] as Map;
|
||||||
|
smoke.check(
|
||||||
|
payload['expiresAt'] > DateTime.now().millisecondsSinceEpoch,
|
||||||
|
'Invitation already expired',
|
||||||
|
);
|
||||||
|
return {'result': result, 'incoming': payload};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> committed(String id) => b.transport.events
|
||||||
|
.firstWhere(
|
||||||
|
(e) =>
|
||||||
|
e['opcode'] == 137 &&
|
||||||
|
e['payload']?['conversationId'] == id &&
|
||||||
|
e['payload']?['event'] == 'history',
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 20));
|
||||||
|
Future<Map> record(smoke.Device device, String id) async {
|
||||||
|
final history = await device.ok(79, {});
|
||||||
|
final entries = (history['history'] as List)
|
||||||
|
.where(
|
||||||
|
(item) => item['message']['attaches'][0]['conversationId'] == id,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
smoke.check(entries.length == 1, 'Missing or duplicate call record');
|
||||||
|
return entries.single['message']['attaches'][0] as Map;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WebSocket> open(String endpoint) async {
|
||||||
|
final socket = await IkarNetwork.connectWebSocket(endpoint);
|
||||||
|
sockets.add(socket);
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> command(
|
||||||
|
WebSocket ws,
|
||||||
|
Stream<Map> stream,
|
||||||
|
String name,
|
||||||
|
int seq,
|
||||||
|
) async {
|
||||||
|
final ack = stream
|
||||||
|
.firstWhere((e) => e['type'] == 'response' && e['sequence'] == seq)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
ws.add(jsonEncode({'command': name, 'sequence': seq}));
|
||||||
|
await ack;
|
||||||
|
}
|
||||||
|
|
||||||
|
final canceledId = 'call-canceled-$stamp';
|
||||||
|
final call = await invite(canceledId, false);
|
||||||
|
var forbidden = false;
|
||||||
|
try {
|
||||||
|
forbidden =
|
||||||
|
(await outsider.transport.request(1001, {
|
||||||
|
'conversationId': canceledId,
|
||||||
|
}))['cmd'] !=
|
||||||
|
1;
|
||||||
|
} catch (_) {
|
||||||
|
forbidden = true;
|
||||||
|
}
|
||||||
|
smoke.check(forbidden, 'An outsider canceled another call');
|
||||||
|
checks.add('authenticated participant isolation');
|
||||||
|
final ended = b.transport.events
|
||||||
|
.firstWhere(
|
||||||
|
(e) =>
|
||||||
|
e['opcode'] == 137 &&
|
||||||
|
e['payload']?['event'] == 'ended' &&
|
||||||
|
e['payload']?['conversationId'] == canceledId,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
final saved = committed(canceledId);
|
||||||
|
final caller = await open(
|
||||||
|
jsonDecode(call['result']['internalCallerParams'])['endpoint'],
|
||||||
|
);
|
||||||
|
final callerEvents = caller
|
||||||
|
.map((frame) => jsonDecode(frame as String) as Map)
|
||||||
|
.asBroadcastStream();
|
||||||
|
final hold = callerEvents.listen((_) {});
|
||||||
|
await command(caller, callerEvents, 'accept-call', 1);
|
||||||
|
await command(caller, callerEvents, 'hangup', 2);
|
||||||
|
await ended;
|
||||||
|
await saved;
|
||||||
|
for (final device in [a, b]) {
|
||||||
|
final entry = await record(device, canceledId);
|
||||||
|
smoke.check(
|
||||||
|
entry['hangupType'] == 'CANCELED' && entry['duration'] == 0,
|
||||||
|
'Unanswered call counted as connected',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await a.ok(1001, {'conversationId': canceledId});
|
||||||
|
await record(b, canceledId);
|
||||||
|
await hold.cancel();
|
||||||
|
await caller.close();
|
||||||
|
checks.add(
|
||||||
|
'caller cancellation reaches recipient without any recipient WS2 connection',
|
||||||
|
);
|
||||||
|
checks.add('durable history for both sides and idempotent cancellation');
|
||||||
|
final spent = smoke.decodeCall(call['incoming']['vcp']);
|
||||||
|
var revoked = false;
|
||||||
|
try {
|
||||||
|
final ws = await open(
|
||||||
|
Uri.parse(
|
||||||
|
spent['wse'],
|
||||||
|
).replace(queryParameters: {'token': spent['tkn']}).toString(),
|
||||||
|
);
|
||||||
|
await ws.close();
|
||||||
|
} catch (_) {
|
||||||
|
revoked = true;
|
||||||
|
}
|
||||||
|
smoke.check(revoked, 'Canceled invitation remained joinable');
|
||||||
|
checks.add('canceled call tokens revoked');
|
||||||
|
|
||||||
|
final videoId = 'call-video-$stamp';
|
||||||
|
final video = await invite(videoId, true);
|
||||||
|
final params = smoke.decodeCall(video['incoming']['vcp']);
|
||||||
|
final callee = await open(
|
||||||
|
Uri.parse(
|
||||||
|
params['wse'],
|
||||||
|
).replace(queryParameters: {'token': params['tkn']}).toString(),
|
||||||
|
);
|
||||||
|
final calleeEvents = callee
|
||||||
|
.map((frame) => jsonDecode(frame as String) as Map)
|
||||||
|
.asBroadcastStream();
|
||||||
|
final calleeHold = calleeEvents.listen((_) {});
|
||||||
|
await command(callee, calleeEvents, 'accept-call', 1);
|
||||||
|
final videoSaved = committed(videoId);
|
||||||
|
await command(callee, calleeEvents, 'hangup', 2);
|
||||||
|
await videoSaved;
|
||||||
|
final completed = await record(a, videoId);
|
||||||
|
smoke.check(
|
||||||
|
completed['callType'] == 'VIDEO' &&
|
||||||
|
completed['hangupType'] == 'HUNGUP' &&
|
||||||
|
completed['duration'] > 0,
|
||||||
|
'Connected video history incorrect',
|
||||||
|
);
|
||||||
|
await record(b, videoId);
|
||||||
|
await calleeHold.cancel();
|
||||||
|
await callee.close();
|
||||||
|
checks.add('accepted video call persists type and positive duration');
|
||||||
|
|
||||||
|
final rejectedId = 'call-rejected-$stamp';
|
||||||
|
await invite(rejectedId, false);
|
||||||
|
final rejectSaved = committed(rejectedId);
|
||||||
|
await b.ok(1001, {'conversationId': rejectedId, 'reason': 'REJECTED'});
|
||||||
|
await rejectSaved;
|
||||||
|
smoke.check(
|
||||||
|
(await record(a, rejectedId))['hangupType'] == 'REJECTED',
|
||||||
|
'Reject status not saved',
|
||||||
|
);
|
||||||
|
checks.add('recipient rejection via independent IKAR endpoint');
|
||||||
|
final missedId = 'call-missed-$stamp';
|
||||||
|
final missedSaved = b.transport.events
|
||||||
|
.firstWhere(
|
||||||
|
(e) =>
|
||||||
|
e['opcode'] == 137 &&
|
||||||
|
e['payload']?['event'] == 'history' &&
|
||||||
|
e['payload']?['conversationId'] == missedId,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 60));
|
||||||
|
await invite(missedId, false);
|
||||||
|
await missedSaved;
|
||||||
|
smoke.check(
|
||||||
|
(await record(b, missedId))['hangupType'] == 'MISSED',
|
||||||
|
'Ringing timeout not saved',
|
||||||
|
);
|
||||||
|
checks.add('unanswered ringing expires and becomes a missed call');
|
||||||
|
final report = {
|
||||||
|
'passed': checks.length,
|
||||||
|
'checks': checks,
|
||||||
|
'server': base.toString(),
|
||||||
|
'physicalDeviceTested': false,
|
||||||
|
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
};
|
||||||
|
await File(
|
||||||
|
'artifacts/live-call-lifecycle-1.0.8.json',
|
||||||
|
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
|
||||||
|
stdout.writeln(jsonEncode(report));
|
||||||
|
} finally {
|
||||||
|
for (final socket in sockets) {
|
||||||
|
try {
|
||||||
|
await socket.close();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
for (final device in devices) {
|
||||||
|
try {
|
||||||
|
await device.ok(199, {});
|
||||||
|
} catch (_) {}
|
||||||
|
await device.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user