From 0508b7073277b10e8869b105b4102bd565823e8a Mon Sep 17 00:00:00 2001 From: sevenhill Date: Thu, 8 Oct 2026 21:34:15 +0300 Subject: [PATCH] Release IKAR 1.0.8: fix incoming calls, cancellation and call history --- README.md | 13 +- android/app/src/main/AndroidManifest.xml | 9 + .../main/kotlin/ru/qlyra/app/CallNotifier.kt | 66 +++-- .../ru/qlyra/app/IncomingCallActivity.kt | 93 +++++++ .../kotlin/ru/qlyra/app/IncomingCallPolicy.kt | 7 + .../ru/qlyra/app/IncomingCallRegistry.kt | 38 +++ .../main/kotlin/ru/qlyra/app/MainActivity.kt | 39 ++- .../ru/ikar/app/IncomingCallPolicyTest.kt | 18 ++ .../QlyraCallLifecycleTests.cs | 117 +++++++++ .../QlyraCompatibilityControllerTests.cs | 5 + .../QlyraCompatibilityController.cs | 18 +- .../Calls/CallSessionCleanupService.cs | 2 + .../Calls/QlyraCallCompatibilityStore.cs | 85 ++++++- .../Calls/QlyraCallLifecycle.cs | 84 +++++++ .../Push/FirebasePushNotificationSender.cs | 19 ++ .../Push/IPushNotificationSender.cs | 5 + ikar_server/src/Ikar.Server/Program.cs | 1 + ikar_server/src/Ikar.Shared/PushContracts.cs | 3 +- lib/backend/modules/calls.dart | 11 + lib/core/calls/call_bridge.dart | 41 +++- lib/core/calls/call_controller.dart | 73 +++++- lib/core/calls/call_session.dart | 13 + lib/core/protocol/opcode_map.dart | 4 +- lib/frontend/screens/calls/call_screen.dart | 2 +- lib/frontend/screens/calls/calls_tab.dart | 13 +- .../screens/profile/notifications_screen.dart | 7 + pubspec.yaml | 2 +- test/app_lock_layer_test.dart | 16 +- test/call_network_failure_test.dart | 14 ++ test/incoming_call_lifecycle_test.dart | 98 ++++++++ tool/live_call_lifecycle_smoke.dart | 228 ++++++++++++++++++ 31 files changed, 1082 insertions(+), 62 deletions(-) create mode 100644 android/app/src/main/kotlin/ru/qlyra/app/IncomingCallActivity.kt create mode 100644 android/app/src/main/kotlin/ru/qlyra/app/IncomingCallPolicy.kt create mode 100644 android/app/src/main/kotlin/ru/qlyra/app/IncomingCallRegistry.kt create mode 100644 android/app/src/test/kotlin/ru/ikar/app/IncomingCallPolicyTest.kt create mode 100644 ikar_server/src/Ikar.Server.Tests/QlyraCallLifecycleTests.cs create mode 100644 ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallLifecycle.cs create mode 100644 test/incoming_call_lifecycle_test.dart create mode 100644 tool/live_call_lifecycle_smoke.dart diff --git a/README.md b/README.md index 2aebbbd..08f6d1d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Иконка ИКАР -Самостоятельный 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` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии. @@ -18,6 +18,17 @@ Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; 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 В [Аргусе](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` и подпись предыдущего выпуска сохранены; обновление доступно также через собственный сервер ИКАР. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 93549ec..288bf93 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -71,6 +71,15 @@ + ) { - if (AppState.resumed) { + @Synchronized fun showIncoming(ctx: Context, data: Map) { + val locked = (ctx.getSystemService(Context.KEYGUARD_SERVICE) as android.app.KeyguardManager).isKeyguardLocked + if (AppState.resumed && !locked) { Log.d("QlyraFcm", "call push suppressed (app foreground)") return } val name = data["userName"] ?: data["title"] ?: "Неизвестный" val callerId = data["suid"] ?: data["callerId"] ?: "" 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 account = data["c"] ?: "" val callJson = JSONObject(data as Map<*, *>).toString() @@ -88,7 +92,7 @@ object CallNotifier { 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 decline = declineIntent(ctx, vcp, conversationId, account) val video = isVideo(data) @@ -104,6 +108,7 @@ object CallNotifier { .setColor(CallConst.ACCENT) .setColorized(true) .setOngoing(true) + .setTimeoutAfter(ringMillis) .setOnlyAlertOnce(update) .setCategory(NotificationCompat.CATEGORY_CALL) .setPriority(NotificationCompat.PRIORITY_MAX) @@ -119,7 +124,7 @@ object CallNotifier { val manager = NotificationManagerCompat.from(ctx) 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 if (!isIncomingShowing(ctx)) return @@ -134,11 +139,24 @@ object CallNotifier { false } - fun finishCall(ctx: Context, data: Map) { + @Synchronized fun finishCall(ctx: Context, data: Map, emitEvent: Boolean = true) { Log.d("QlyraFcm", "finishCall keys=${data.keys}") - CallRinger.stop() - NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID) - CallEvents.emit("ended") + val id = data["conversationId"] ?: data["vcId"] ?: return + if (IncomingCallRegistry.finish(ctx, id)) { + CallRinger.stop() + NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID) + 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): Boolean { @@ -156,6 +174,7 @@ object CallNotifier { requestCode: Int, ): PendingIntent { 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) putExtra(CallConst.EXTRA_CALL, callJson) putExtra(CallConst.EXTRA_ACTION, action) @@ -168,7 +187,7 @@ object CallNotifier { return PendingIntent.getActivity(ctx, requestCode, intent, flags) } - private fun declineIntent( + fun declineIntent( ctx: Context, vcp: String, conversationId: String, @@ -180,6 +199,7 @@ object CallNotifier { .put("c", account.toLongOrNull() ?: account) .toString() val intent = Intent(ctx, CallActionReceiver::class.java).apply { + data = android.net.Uri.parse("ikar://decline-call/$conversationId") action = CallConst.ACTION_DECLINE putExtra(CallConst.EXTRA_DECLINE_PAYLOAD, payload) putExtra(CallConst.EXTRA_NOTIF_ID, CallConst.NOTIF_ID) @@ -221,7 +241,7 @@ object CallRinger { var activeConversationId: String? = null private set - fun start(ctx: Context, conversationId: String) { + @Synchronized fun start(ctx: Context, conversationId: String, ringMillis: Long = 45_000L) { stop() activeConversationId = conversationId @@ -240,12 +260,12 @@ object CallRinger { vibrate(ctx) } - val t = Runnable { onTimeout(ctx) } + val t = Runnable { onTimeout(ctx, conversationId) } timeout = t - handler.postDelayed(t, 45_000L) + handler.postDelayed(t, ringMillis) } - fun stop() { + @Synchronized fun stop() { timeout?.let { handler.removeCallbacks(it) } timeout = null try { @@ -266,7 +286,13 @@ object CallRinger { 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() NotificationManagerCompat.from(ctx).cancel(CallConst.NOTIF_ID) } @@ -318,11 +344,11 @@ class CallActionReceiver : BroadcastReceiver() { 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 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 { setClassName(ctx, CallConst.FLN_RECEIVER) putExtra("notificationId", notifId) diff --git a/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallActivity.kt b/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallActivity.kt new file mode 100644 index 0000000..b18a8b1 --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallActivity.kt @@ -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? = 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() + } +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallPolicy.kt b/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallPolicy.kt new file mode 100644 index 0000000..1cb2dce --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallPolicy.kt @@ -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) +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallRegistry.kt b/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallRegistry.kt new file mode 100644 index 0000000..09c638c --- /dev/null +++ b/android/app/src/main/kotlin/ru/qlyra/app/IncomingCallRegistry.kt @@ -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) +} diff --git a/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt index 7a5838a..b7bb7a5 100644 --- a/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt +++ b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt @@ -1,7 +1,7 @@ package ru.ikar.app import android.Manifest -import android.app.KeyguardManager +import org.json.JSONObject import android.content.ComponentName import android.content.Context import android.content.Intent @@ -362,13 +362,31 @@ class MainActivity : AudioServiceFragmentActivity() { "ru.ikar.app/calls", ).setMethodCallHandler { call, result -> when (call.method) { + "showIncoming" -> { + val raw = call.arguments as? Map<*, *> ?: emptyMap() + CallNotifier.showIncoming(applicationContext, raw.entries.associate { it.key.toString() to it.value.toString() }) + result.success(null) + } + "cancelConversation" -> { + val id = call.argument("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" -> { val p = pendingCall 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" -> { val caller = call.argument("caller") ?: "Звонок" + call.argument("conversationId")?.let { + // Ignore an incoming push delivered after this call was accepted. + CallNotifier.finishCall(applicationContext, mapOf("conversationId" to it), emitEvent = false) + } CallRinger.stop() NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) CallForegroundService.start(applicationContext, caller) @@ -394,8 +412,14 @@ class MainActivity : AudioServiceFragmentActivity() { result.success(null) } "notifyEnded" -> { - CallRinger.stop() - NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) + val id = call.argument("conversationId") + id?.let { + CallNotifier.finishCall(applicationContext, mapOf("conversationId" to it), emitEvent = false) + } + if (id == null) { + CallRinger.stop() + NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) + } CallForegroundService.stop(applicationContext) clearCallWindowFlags() result.success(null) @@ -403,6 +427,7 @@ class MainActivity : AudioServiceFragmentActivity() { "cancelIncoming" -> { CallRinger.stop() NotificationManagerCompat.from(this).cancel(CallConst.NOTIF_ID) + IncomingCallActivity.close(null) result.success(null) } "canUseFullScreenIntent" -> { @@ -673,6 +698,8 @@ class MainActivity : AudioServiceFragmentActivity() { intent.removeExtra(CallConst.EXTRA_CALL) intent.removeExtra(CallConst.EXTRA_ACTION) 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() val map = mapOf("data" to json, "action" to action) val sink = CallEvents.sink @@ -694,10 +721,6 @@ class MainActivity : AudioServiceFragmentActivity() { 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() { diff --git a/android/app/src/test/kotlin/ru/ikar/app/IncomingCallPolicyTest.kt b/android/app/src/test/kotlin/ru/ikar/app/IncomingCallPolicyTest.kt new file mode 100644 index 0000000..25d75f5 --- /dev/null +++ b/android/app/src/test/kotlin/ru/ikar/app/IncomingCallPolicyTest.kt @@ -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)) + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/QlyraCallLifecycleTests.cs b/ikar_server/src/Ikar.Server.Tests/QlyraCallLifecycleTests.cs new file mode 100644 index 0000000..da0198b --- /dev/null +++ b/ikar_server/src/Ikar.Server.Tests/QlyraCallLifecycleTests.cs @@ -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().UseSqlite(db.Database.GetDbConnection()).Options; + await using var services = new ServiceCollection().AddScoped(_ => new IkarDbContext(options)) + .AddSingleton(push).BuildServiceProvider(); + var lifecycle = new QlyraCallLifecycle(services.GetRequiredService(), + new NoopHubContext(), NullLogger.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(Assert.IsType(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 Calls { get; } = []; + public Task CompleteAsync(QlyraCallConversation call) + { + if (FailNext) { FailNext = false; throw new IOException("Temporary database error"); } + Calls.Add(call); return Task.CompletedTask; + } + } +} diff --git a/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs index 29b5ff7..524e842 100644 --- a/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs +++ b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs @@ -911,6 +911,11 @@ public sealed partial class QlyraCompatibilityControllerTests private sealed class RecordingQlyraPushSender : IPushNotificationSender { public List<(QlyraIncomingCallPushDto Call, IReadOnlyList Devices)> Calls { get; } = []; + public List<(string Id, IReadOnlyList Devices)> Finished { get; } = []; + public Task SendQlyraCallFinishedAsync(string conversationId, IReadOnlyCollection devices, CancellationToken cancellationToken = default) + { + Finished.Add((conversationId, devices.ToList())); return Task.CompletedTask; + } public Task SendMessageAsync(Chat chat, Message message, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SendMessageReactionAsync(Chat chat, Message message, User reactor, string emoji, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task SendIncomingCallAsync(IncomingCallDto call, IReadOnlyCollection devices, CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs index c874544..633afe9 100644 --- a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs +++ b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs @@ -95,6 +95,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase 76 or 78 or 84 or 166 => await CallFeatureAsync(opcode, request.Payload, cancellationToken), 77 => await UpdateChatMembersAsync(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), 83 or 88 => await ResolveMediaAsync(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 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 == 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)); participant = conversation.Participants[user.Id]; var callee = conversation.Participants[calleeId.Value]; 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) { var devices = await dbContext.PushDevices.AsNoTracking() .Where(device => device.UserId == calleeId.Value && device.NotificationsEnabled) .ToListAsync(cancellationToken); 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, cancellationToken); } @@ -782,6 +785,17 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId = conversation.Id, internalCallerParams = internalParams, internalParams, joinLink = conversation.JoinToken })); } + private async Task 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 CallHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken) { var user = await RequireCurrentUserAsync(cancellationToken); diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs b/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs index 6d3aa95..ea71cd7 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs @@ -7,6 +7,7 @@ namespace Ikar.Server.Infrastructure.Calls; public sealed class CallSessionCleanupService( CallSessionStore store, + QlyraCallCompatibilityStore qlyraStore, IHubContext hubContext, IOptions options, ILogger logger) : BackgroundService @@ -16,6 +17,7 @@ public sealed class CallSessionCleanupService( using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); while (await timer.WaitForNextTickAsync(stoppingToken)) { + await qlyraStore.ExpireAsync(DateTimeOffset.UtcNow); var expired = store.DrainExpired( DateTimeOffset.UtcNow, TimeSpan.FromSeconds(Math.Clamp(options.Value.RingingTimeoutSeconds, 15, 300)), diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallCompatibilityStore.cs b/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallCompatibilityStore.cs index 81f23d5..c4dc096 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallCompatibilityStore.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallCompatibilityStore.cs @@ -6,22 +6,31 @@ using Microsoft.Extensions.Options; namespace Ikar.Server.Infrastructure.Calls; -public sealed class QlyraCallCompatibilityStore(IOptions webRtcOptions) +public sealed class QlyraCallCompatibilityStore( + IOptions webRtcOptions, + IQlyraCallLifecycle? lifecycle = null, + ILogger? logger = null) { + private readonly ConcurrentDictionary _pendingHistory = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _conversations = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _tokens = new(StringComparer.Ordinal); public IReadOnlyList IceServers => webRtcOptions.Value.ToDto(); 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) { var participant = new QlyraCallParticipant(user.UserId, user.ClientId); conversation.Participants[user.UserId] = participant; _tokens[participant.Token] = (conversation, participant); } - _conversations[conversationId] = conversation; return conversation; } @@ -74,6 +83,11 @@ public sealed class QlyraCallCompatibilityStore(IOptions webRtcOp } 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 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 webRtcOp } else if (command == "hangup") { - await BroadcastAsync(resolved.Conversation, resolved.Participant, new { type = "notification", notification = "hungup", participantId = resolved.Participant.ClientId }, context.RequestAborted); - await FinishDirectAsync(resolved.Conversation, resolved.Participant); + await EndAsync(resolved.Conversation.Id, resolved.Participant.UserId, + root.TryGetProperty("reason", out var reason) ? reason.GetString() : null); break; } } @@ -98,11 +112,57 @@ public sealed class QlyraCallCompatibilityStore(IOptions webRtcOp } } - private async Task FinishDirectAsync(QlyraCallConversation conversation, QlyraCallParticipant sender) + public async Task 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 _); - 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) @@ -189,6 +249,15 @@ public sealed class QlyraCallConversation(string id, bool isVideo) { public string Id { get; } = id; 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 ConcurrentDictionary Participants { get; } = new(); } diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallLifecycle.cs b/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallLifecycle.cs new file mode 100644 index 0000000..3c46113 --- /dev/null +++ b/ikar_server/src/Ikar.Server/Infrastructure/Calls/QlyraCallLifecycle.cs @@ -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 hub, + ILogger 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(); + var push = scope.ServiceProvider.GetRequiredService(); + 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 devices) + { + try { await push.SendQlyraCallFinishedAsync(id, devices); } + catch (Exception ex) { logger.LogWarning(ex, "Unable to send call finished push for {ConversationId}.", id); } + } +} diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs index 7624f62..4c616e9 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/FirebasePushNotificationSender.cs @@ -317,12 +317,31 @@ public sealed class FirebasePushNotificationSender( [PushDataKeys.AccountId] = call.CalleeClientId.ToString(), [PushDataKeys.IsVideo] = call.IsVideo ? "true" : "false" }; + if (call.ExpiresAt is { } expiresAt) data["expiresAt"] = expiresAt.ToString(); foreach (var device in androidDevices) { await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken); } } + public async Task SendQlyraCallFinishedAsync(string conversationId, IReadOnlyCollection 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 + { + [PushDataKeys.Type] = "CallFinished", + [PushDataKeys.ConversationId] = conversationId + }; + foreach (var device in androidDevices) + await SendToAndroidAsync(projectId, accessToken, device, data, EphemeralCallTtl, cancellationToken); + } + public async Task SendLoginCodeAsync( string code, string challengeId, diff --git a/ikar_server/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs b/ikar_server/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs index d7690f3..b25ec81 100644 --- a/ikar_server/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs +++ b/ikar_server/src/Ikar.Server/Infrastructure/Push/IPushNotificationSender.cs @@ -39,6 +39,11 @@ public interface IPushNotificationSender IReadOnlyCollection devices, CancellationToken cancellationToken = default); + Task SendQlyraCallFinishedAsync( + string conversationId, + IReadOnlyCollection devices, + CancellationToken cancellationToken = default) => Task.CompletedTask; + Task SendLoginCodeAsync( string code, string challengeId, diff --git a/ikar_server/src/Ikar.Server/Program.cs b/ikar_server/src/Ikar.Server/Program.cs index 21f806b..c799fcd 100644 --- a/ikar_server/src/Ikar.Server/Program.cs +++ b/ikar_server/src/Ikar.Server/Program.cs @@ -207,6 +207,7 @@ builder.Services.AddAuthorization(options => builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow)); diff --git a/ikar_server/src/Ikar.Shared/PushContracts.cs b/ikar_server/src/Ikar.Shared/PushContracts.cs index d36ff68..477bef7 100644 --- a/ikar_server/src/Ikar.Shared/PushContracts.cs +++ b/ikar_server/src/Ikar.Shared/PushContracts.cs @@ -29,7 +29,8 @@ public sealed record QlyraIncomingCallPushDto( int CallerClientId, string CallerName, int CalleeClientId, - bool IsVideo); + bool IsVideo, + long? ExpiresAt = null); public static class PushDataKeys { diff --git a/lib/backend/modules/calls.dart b/lib/backend/modules/calls.dart index 1e33413..d1aca1e 100644 --- a/lib/backend/modules/calls.dart +++ b/lib/backend/modules/calls.dart @@ -99,6 +99,17 @@ class CallsModule { CallsModule(this._api); + Future 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( Map payload, List keys, { diff --git a/lib/core/calls/call_bridge.dart b/lib/core/calls/call_bridge.dart index 9afac18..da231a7 100644 --- a/lib/core/calls/call_bridge.dart +++ b/lib/core/calls/call_bridge.dart @@ -50,7 +50,8 @@ class CallBridge { return; } if (action == 'ended') { - CallController.instance.dismissIncoming(); + final id = event['conversationId']?.toString(); + if (id != null) CallController.instance.cancelConversation(id); return; } final dataStr = event['data']; @@ -69,10 +70,38 @@ class CallBridge { ); } - Future notifyAccepted({String? caller}) async { + Future showIncoming(Map payload) async { if (!_android) return; try { - await _method.invokeMethod('notifyAccepted', {'caller': caller}); + await _method.invokeMethod('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 cancelConversation(String id) async { + if (!_android) return; + try { + await _method.invokeMethod('cancelConversation', { + 'conversationId': id, + }); + } catch (error) { + logger.w('CallBridge.cancelConversation: $error'); + } + } + + Future notifyAccepted({String? caller, String? conversationId}) async { + if (!_android) return; + try { + await _method.invokeMethod('notifyAccepted', { + 'caller': caller, + 'conversationId': conversationId, + }); } catch (e) { logger.w('CallBridge.notifyAccepted: caller=$caller $e'); } @@ -108,10 +137,12 @@ class CallBridge { } } - Future notifyEnded() async { + Future notifyEnded({String? conversationId}) async { if (!_android) return; try { - await _method.invokeMethod('notifyEnded'); + await _method.invokeMethod('notifyEnded', { + 'conversationId': conversationId, + }); } catch (e) { logger.w('CallBridge.notifyEnded: $e'); } diff --git a/lib/core/calls/call_controller.dart b/lib/core/calls/call_controller.dart index 5ce9563..f38779c 100644 --- a/lib/core/calls/call_controller.dart +++ b/lib/core/calls/call_controller.dart @@ -1,9 +1,9 @@ import 'dart:async'; +import 'package:flutter/foundation.dart' show visibleForTesting; import '../../backend/api.dart'; import '../../backend/modules/calls.dart'; import '../protocol/opcode_map.dart'; -import '../push/fkm_controller.dart'; import '../protocol/packet.dart'; import '../utils/parse.dart'; import 'call_bridge.dart'; @@ -38,6 +38,8 @@ class IncomingCall { class CallController { CallController._(); + @visibleForTesting + CallController.forTesting(); static final CallController instance = CallController._(); Api? _api; @@ -66,6 +68,7 @@ class CallController { IncomingCall? _pending; IncomingCall? get pendingIncoming => _pending; + final _finishedIncoming = {}; bool get isBusy => _active != null || _starting; @@ -80,16 +83,30 @@ class CallController { if (packet.opcode != Opcode.notifCallStart) return; final payload = packet.payload; 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 conversationId = payload['conversationId'] as String?; final callerId = payload['callerId'] as int?; 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 отдельным уведомлением, - // приём оттуда вернётся через injectFromNative. + // The native call screen works while Flutter is in the background. if (!appResumed) { - unawaited(FkmController.instance.showIncomingCall(payload)); + unawaited(CallBridge.instance.showIncoming(payload)); return; } @@ -104,6 +121,7 @@ class CallController { params: params, country: payload['country'] as String?, isContact: payload['isContact'] as bool?, + callerName: payload['userName']?.toString(), ), ); } @@ -134,13 +152,15 @@ class CallController { params: params, country: data['country']?.toString(), isContact: data['isContact'] is bool ? data['isContact'] as bool : null, - callerName: data['userName']?.toString(), + callerName: (data['userName'] ?? data['senderName'] ?? data['title']) + ?.toString(), autoAccept: autoAccept, ), ); } void _emitIncoming(IncomingCall incoming) { + if (_finishedIncoming.contains(incoming.conversationId)) return; if (isBusy) return; if (_pending?.conversationId == incoming.conversationId) return; _pending = incoming; @@ -153,6 +173,21 @@ class CallController { _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 startOutgoing(int calleeId, {bool isVideo = false}) => _startExclusive(() async { final out = await _calls!.initiateCall(calleeId, isVideo: isVideo); @@ -162,7 +197,12 @@ class CallController { device: _api?.callsDevice, 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); }); @@ -206,6 +246,9 @@ class CallController { } Future acceptIncoming(IncomingCall call) async { + if (_finishedIncoming.contains(call.conversationId) || isBusy) { + throw StateError('Звонок уже завершён или занят'); + } _pending = null; CallBridge.instance.cancelIncoming(); final config = Ws2Config.fromVcp( @@ -218,6 +261,8 @@ class CallController { ws2Config: config, params: call.params, role: CallRole.callee, + endOnServer: (reason) => + _calls!.endCall(call.conversationId, reason: reason), ); return _launch(session, () async { await session.start(); @@ -227,7 +272,7 @@ class CallController { Future rejectIncoming(IncomingCall call) async { _pending = null; - CallBridge.instance.notifyEnded(); + CallBridge.instance.notifyEnded(conversationId: call.conversationId); final config = Ws2Config.fromVcp( call.params, conversationId: call.conversationId, @@ -235,12 +280,17 @@ class CallController { osVersion: _api?.callsOsVersion, ); final signaling = Ws2Signaling(config); + final endOnServer = _calls!.endCall( + call.conversationId, + reason: 'REJECTED', + ); try { await signaling.connect(); await signaling.hangup(reason: 'REJECTED'); } catch (_) { } finally { await signaling.close(); + await endOnServer; } } @@ -268,7 +318,10 @@ class CallController { } catch (_) {} rethrow; } - CallBridge.instance.notifyAccepted(caller: caller); + CallBridge.instance.notifyAccepted( + caller: caller, + conversationId: session.ws2Config.uri.queryParameters['conversationId'], + ); return session; } @@ -287,7 +340,9 @@ class CallController { _activeJoinLink = null; await _activeSub?.cancel(); _activeSub = null; - CallBridge.instance.notifyEnded(); + CallBridge.instance.notifyEnded( + conversationId: session.ws2Config.uri.queryParameters['conversationId'], + ); _ended.add(null); } diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart index 6c5882a..4f73f5a 100644 --- a/lib/core/calls/call_session.dart +++ b/lib/core/calls/call_session.dart @@ -67,12 +67,14 @@ class CallSession { final ConversationParams? params; final CallRole role; final bool isGroup; + final Future Function(String reason)? endOnServer; CallSession({ required this.ws2Config, required this.role, this.params, this.isGroup = false, + this.endOnServer, }); Ws2Signaling? _signaling; @@ -2367,12 +2369,23 @@ class CallSession { } catch (_) {} } + bool _hangingUp = false; Future hangup({String? reason}) async { + if (_ended || _hangingUp) return; + _hangingUp = true; final r = reason ?? _autoHangupReason(); + final serverEnd = endOnServer?.call(r).catchError((Object error) { + logger.w('[call] server termination failed: $error'); + }); try { await _signaling?.hangup(reason: r); } catch (_) {} _end(); + try { + await serverEnd; + } catch (error) { + logger.w('[call] server termination failed: $error'); + } } String _autoHangupReason() { diff --git a/lib/core/protocol/opcode_map.dart b/lib/core/protocol/opcode_map.dart index 29e3c51..2613729 100644 --- a/lib/core/protocol/opcode_map.dart +++ b/lib/core/protocol/opcode_map.dart @@ -5,6 +5,8 @@ /// /// файл писла нейронка (я че ебанутый чтоль чтобы вручную хуярить опкоды и их значения) abstract class Opcode { + // IKAR extension: terminate even before the recipient connects to WS2. + static const int ikarEndCall = 1001; // ── Session ──────────────────────────────────────────────────────── static const int ping = 1; // Пинг static const int debug = 2; // Отладка / понг @@ -235,7 +237,6 @@ abstract class Opcode { static const int loginByQr = 291; // Вход по QR static const int msgDelivery = 303; // Подтверждение доставки сообщения - static const int storiesGetByStoryId = 220; // Истории по ID // ── Human-readable names ─────────────────────────────────────────── @@ -424,6 +425,5 @@ abstract class Opcode { getQrStatus: 'GET_QR_STATUS', loginByQr: 'LOGIN_BY_QR', msgDelivery: 'MSG_DELIVERY', - }; } diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 928991e..a9d0692 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -563,7 +563,7 @@ class _CallScreenState extends State with TickerProviderStateMixin { child: _localRendererReady && _localRenderer.srcObject != null ? CallVideoView( renderer: _localRenderer, - mirror: _session?.localScreen != true, + mirror: false, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, placeholder: _localPreviewIcon(cs), ) diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 94b945a..6a8a5be 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -36,10 +36,15 @@ class _CallsTabState extends State bool _isLoading = true; int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные' StreamSubscription? _loginSub; + StreamSubscription? _endedSub; + int _historyGeneration = 0; @override void initState() { super.initState(); + _endedSub = CallController.instance.callEnded.listen((_) { + if (accountModule.isLoggedIn) _loadHistory(); + }); if (accountModule.isLoggedIn) { _loadHistory(); } else { @@ -56,6 +61,7 @@ class _CallsTabState extends State @override void dispose() { _loginSub?.cancel(); + _endedSub?.cancel(); super.dispose(); } @@ -65,7 +71,9 @@ class _CallsTabState extends State } Future _loadHistory() async { + final generation = ++_historyGeneration; final p = await AppDatabase.loadActiveProfile(); + if (!mounted || generation != _historyGeneration) return; if (p == null) { if (mounted) setState(() => _isLoading = false); return; @@ -76,9 +84,12 @@ class _CallsTabState extends State try { calls = await callsModule.fetchHistory(p.id, p.id); } catch (e) { - if (mounted) setState(() => _isLoading = false); + if (mounted && generation == _historyGeneration) { + setState(() => _isLoading = false); + } return; } + if (!mounted || generation != _historyGeneration) return; final List grouped = []; for (final call in calls) { diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index 904b8a9..8ae7ae7 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -227,6 +227,13 @@ class _NotificationsScreenState extends State value: _callNotifications, onChanged: _onCallNotificationsChanged, ), + if (Platform.isAndroid) + SettingsNavTile( + icon: Symbols.lock, + label: l10n.notificationsCallPermissionTitle, + onTap: () => CallBridge.instance + .openFullScreenIntentSettings(), + ), SettingsToggleTile( icon: Symbols.person_add, label: l10n.notificationsNewContactsLabel, diff --git a/pubspec.yaml b/pubspec.yaml index b4b48f9..b230dcc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 1.0.7+8 +version: 1.0.8+9 environment: sdk: ^3.10.4 diff --git a/test/app_lock_layer_test.dart b/test/app_lock_layer_test.dart index 164c657..b6f0b8d 100644 --- a/test/app_lock_layer_test.dart +++ b/test/app_lock_layer_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.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/passcode_pad.dart'; import 'package:ikar/l10n/app_localizations.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -23,9 +24,18 @@ Future _enter(WidgetTester tester, String pin) async { await tester.tap(find.text(digit)); await tester.pump(); } - await tester.runAsync( - () => Future.delayed(const Duration(milliseconds: 400)), - ); + // 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(keypad).enabled) { + break; + } + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50)), + ); + await tester.pump(); + } await tester.pumpAndSettle(); } diff --git a/test/call_network_failure_test.dart b/test/call_network_failure_test.dart index 6443aa7..9ac3fa2 100644 --- a/test/call_network_failure_test.dart +++ b/test/call_network_failure_test.dart @@ -34,6 +34,20 @@ class BrokenSignaling extends Ws2Signaling { void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test('hangup reaches IKAR even before WS2 connects', () async { + final reasons = []; + 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( 'ICE send failure is handled and late candidates are ignored after hangup', () async { diff --git a/test/incoming_call_lifecycle_test.dart b/test/incoming_call_lifecycle_test.dart new file mode 100644 index 0000000..d2aef97 --- /dev/null +++ b/test/incoming_call_lifecycle_test.dart @@ -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.broadcast(); + @override + Stream 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 push(Map data) async { + api.packets.add(Packet(opcode: 137, payload: data)); + await Future.delayed(Duration.zero); + } + + Map 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(); + }, + ); +} diff --git a/tool/live_call_lifecycle_smoke.dart b/tool/live_call_lifecycle_smoke.dart new file mode 100644 index 0000000..23aba6f --- /dev/null +++ b/tool/live_call_lifecycle_smoke.dart @@ -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 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 = []; + final checks = []; + final sockets = []; + 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 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 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 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 open(String endpoint) async { + final socket = await IkarNetwork.connectWebSocket(endpoint); + sockets.add(socket); + return socket; + } + + Future command( + WebSocket ws, + Stream 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(); + } + } +}