Release IKAR 1.0.8: fix incoming calls, cancellation and call history
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

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