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