Release IKAR 1.0.8: fix incoming calls, cancellation and call history
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
<img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР">
|
||||
|
||||
Самостоятельный 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` и подпись предыдущего выпуска сохранены; обновление доступно также через собственный сервер ИКАР.
|
||||
|
||||
@@ -71,6 +71,15 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</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
|
||||
android:name="ru.ikar.app.MinimalIcon"
|
||||
android:enabled="false"
|
||||
|
||||
@@ -36,11 +36,11 @@ object CallEvents {
|
||||
|
||||
private val main = Handler(Looper.getMainLooper())
|
||||
|
||||
fun emit(action: String) {
|
||||
fun emit(action: String, conversationId: String? = null) {
|
||||
val s = sink ?: return
|
||||
main.post {
|
||||
try {
|
||||
s.success(mapOf("action" to action))
|
||||
s.success(mapOf("action" to action, "conversationId" to conversationId))
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
@@ -72,14 +72,18 @@ object CallConst {
|
||||
|
||||
object CallNotifier {
|
||||
|
||||
fun showIncoming(ctx: Context, data: Map<String, String>) {
|
||||
if (AppState.resumed) {
|
||||
@Synchronized fun showIncoming(ctx: Context, data: Map<String, String>) {
|
||||
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<String, String>) {
|
||||
@Synchronized fun finishCall(ctx: Context, data: Map<String, String>, 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<String, String>): 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
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<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" -> {
|
||||
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<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()
|
||||
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<String>("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<String, Any?>("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() {
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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 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;
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
var user = await RequireCurrentUserAsync(cancellationToken);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Ikar.Server.Infrastructure.Calls;
|
||||
|
||||
public sealed class CallSessionCleanupService(
|
||||
CallSessionStore store,
|
||||
QlyraCallCompatibilityStore qlyraStore,
|
||||
IHubContext<MessengerHub> hubContext,
|
||||
IOptions<WebRtcOptions> options,
|
||||
ILogger<CallSessionCleanupService> 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)),
|
||||
|
||||
@@ -6,22 +6,31 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
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 Conversation, QlyraCallParticipant Participant)> _tokens = new(StringComparer.Ordinal);
|
||||
public IReadOnlyList<Ikar.Shared.CallIceServerDto> 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<WebRtcOptions> 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<WebRtcOptions> 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<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 _);
|
||||
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<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.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<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(
|
||||
string code,
|
||||
string challengeId,
|
||||
|
||||
@@ -39,6 +39,11 @@ public interface IPushNotificationSender
|
||||
IReadOnlyCollection<PushDevice> devices,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SendQlyraCallFinishedAsync(
|
||||
string conversationId,
|
||||
IReadOnlyCollection<PushDevice> devices,
|
||||
CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
Task<bool> SendLoginCodeAsync(
|
||||
string code,
|
||||
string challengeId,
|
||||
|
||||
@@ -207,6 +207,7 @@ builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddSingleton<PresenceTracker>();
|
||||
builder.Services.AddSingleton<CallSessionStore>();
|
||||
builder.Services.AddSingleton<QlyraCallCompatibilityStore>();
|
||||
builder.Services.AddSingleton<IQlyraCallLifecycle, QlyraCallLifecycle>();
|
||||
builder.Services.AddSingleton<QlyraWebPushSessionStore>();
|
||||
builder.Services.AddHostedService<CallSessionCleanupService>();
|
||||
builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -99,6 +99,17 @@ class CallsModule {
|
||||
|
||||
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(
|
||||
Map payload,
|
||||
List<String> keys, {
|
||||
|
||||
@@ -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<void> notifyAccepted({String? caller}) async {
|
||||
Future<void> showIncoming(Map<dynamic, dynamic> payload) async {
|
||||
if (!_android) return;
|
||||
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) {
|
||||
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;
|
||||
try {
|
||||
await _method.invokeMethod<void>('notifyEnded');
|
||||
await _method.invokeMethod<void>('notifyEnded', {
|
||||
'conversationId': conversationId,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.w('CallBridge.notifyEnded: $e');
|
||||
}
|
||||
|
||||
@@ -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 = <String>{};
|
||||
|
||||
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<CallSession> 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<CallSession> 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<void> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,12 +67,14 @@ class CallSession {
|
||||
final ConversationParams? params;
|
||||
final CallRole role;
|
||||
final bool isGroup;
|
||||
final Future<void> 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<void> 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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@@ -563,7 +563,7 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
||||
child: _localRendererReady && _localRenderer.srcObject != null
|
||||
? CallVideoView(
|
||||
renderer: _localRenderer,
|
||||
mirror: _session?.localScreen != true,
|
||||
mirror: false,
|
||||
objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
|
||||
placeholder: _localPreviewIcon(cs),
|
||||
)
|
||||
|
||||
@@ -36,10 +36,15 @@ class _CallsTabState extends State<CallsTab>
|
||||
bool _isLoading = true;
|
||||
int _selectedTabIndex = 0; // 0 for 'Все', 1 for 'Пропущенные'
|
||||
StreamSubscription<LoginStatus>? _loginSub;
|
||||
StreamSubscription<void>? _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<CallsTab>
|
||||
@override
|
||||
void dispose() {
|
||||
_loginSub?.cancel();
|
||||
_endedSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -65,7 +71,9 @@ class _CallsTabState extends State<CallsTab>
|
||||
}
|
||||
|
||||
Future<void> _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<CallsTab>
|
||||
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<CallLogEntry> grouped = [];
|
||||
for (final call in calls) {
|
||||
|
||||
@@ -227,6 +227,13 @@ class _NotificationsScreenState extends State<NotificationsScreen>
|
||||
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,
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# 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
|
||||
|
||||
@@ -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<void> _enter(WidgetTester tester, String pin) async {
|
||||
await tester.tap(find.text(digit));
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.runAsync(
|
||||
() => Future<void>.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<PasscodeKeypad>(keypad).enabled) {
|
||||
break;
|
||||
}
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,20 @@ class BrokenSignaling extends Ws2Signaling {
|
||||
|
||||
void main() {
|
||||
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(
|
||||
'ICE send failure is handled and late candidates are ignored after hangup',
|
||||
() async {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:ikar/backend/api.dart';
|
||||
import 'package:ikar/core/calls/call_controller.dart';
|
||||
import 'package:ikar/core/protocol/packet.dart';
|
||||
import 'package:kusoft/src/rust/api.dart' show CallParams;
|
||||
import 'package:kusoft/src/rust/frb_generated.dart' show RustLib, RustLibApi;
|
||||
|
||||
class _PushApi extends Api {
|
||||
final packets = StreamController<Packet>.broadcast();
|
||||
@override
|
||||
Stream<Packet> get pushStream => packets.stream;
|
||||
}
|
||||
|
||||
class _CallDecoder implements RustLibApi {
|
||||
@override
|
||||
CallParams? crateApiDecodeVcp({
|
||||
required String vcp,
|
||||
required String conversationId,
|
||||
}) => const CallParams(
|
||||
token: 'synthetic',
|
||||
wsEndpoint: 'wss://calls.invalid/ws2',
|
||||
turn: [],
|
||||
isVideo: false,
|
||||
);
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUpAll(() => RustLib.initMock(api: _CallDecoder()));
|
||||
|
||||
late _PushApi api;
|
||||
late CallController controller;
|
||||
setUp(() {
|
||||
api = _PushApi();
|
||||
controller = CallController.forTesting()..appResumed = true;
|
||||
controller.init(api);
|
||||
});
|
||||
tearDown(() async {
|
||||
controller.dispose();
|
||||
await api.packets.close();
|
||||
await api.dispose();
|
||||
});
|
||||
Future<void> push(Map<String, dynamic> data) async {
|
||||
api.packets.add(Packet(opcode: 137, payload: data));
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
|
||||
Map<String, dynamic> invite(String id) => {
|
||||
'conversationId': id,
|
||||
'callerId': 2,
|
||||
'vcp': 'synthetic-incoming',
|
||||
'type': 'AUDIO',
|
||||
};
|
||||
|
||||
test(
|
||||
'caller cancellation dismisses a ringing recipient without WS2',
|
||||
() async {
|
||||
var canceled = 0;
|
||||
final sub = controller.incomingCanceled.listen((_) => canceled++);
|
||||
await push(invite('first'));
|
||||
expect(controller.pendingIncoming?.conversationId, 'first');
|
||||
await push({'conversationId': 'first', 'event': 'ended'});
|
||||
expect(controller.pendingIncoming, isNull);
|
||||
expect(canceled, 1);
|
||||
await sub.cancel();
|
||||
},
|
||||
);
|
||||
test('late invite after cancellation does not start ringing', () async {
|
||||
await push({'conversationId': 'first', 'event': 'ended'});
|
||||
await push(invite('first'));
|
||||
controller.injectFromNative(invite('first'), autoAccept: true);
|
||||
expect(controller.pendingIncoming, isNull);
|
||||
});
|
||||
test('old cancellation cannot dismiss a newer incoming call', () async {
|
||||
await push(invite('new'));
|
||||
await push({'conversationId': 'old', 'event': 'ended'});
|
||||
expect(controller.pendingIncoming?.conversationId, 'new');
|
||||
});
|
||||
test(
|
||||
'expired invite is ignored and committed history refreshes listeners',
|
||||
() async {
|
||||
await push({
|
||||
...invite('expired'),
|
||||
'expiresAt': DateTime.now().millisecondsSinceEpoch - 1000,
|
||||
});
|
||||
expect(controller.pendingIncoming, isNull);
|
||||
var refreshed = 0;
|
||||
final sub = controller.callEnded.listen((_) => refreshed++);
|
||||
await push({'conversationId': 'expired', 'event': 'history'});
|
||||
expect(refreshed, 1);
|
||||
await sub.cancel();
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import '../lib/core/transport/ikar_network.dart';
|
||||
import 'live_ikar_smoke.dart' as smoke;
|
||||
|
||||
Future<void> main() async {
|
||||
final base = Uri.parse('https://ikar.kusoft.xyz');
|
||||
final node = Platform.environment['IKAR_TEST_NODE'] ?? 'node';
|
||||
final stamp = DateTime.now().millisecondsSinceEpoch.toString().substring(5);
|
||||
final devices = <smoke.Device>[];
|
||||
final checks = <String>[];
|
||||
final sockets = <WebSocket>[];
|
||||
try {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
devices.add(
|
||||
await smoke.Device.create(
|
||||
base,
|
||||
node,
|
||||
'+999$stamp$i',
|
||||
'Call test ${i + 1}',
|
||||
),
|
||||
);
|
||||
}
|
||||
final a = devices[0], b = devices[1], outsider = devices[2];
|
||||
Future<Map> invite(String id, bool video) async {
|
||||
final incoming = b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['conversationId'] == id &&
|
||||
e['payload']?['vcp'] != null,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final result = await a.ok(78, {
|
||||
'calleeIds': [b.id],
|
||||
'conversationId': id,
|
||||
'isVideo': video,
|
||||
});
|
||||
final payload = (await incoming)['payload'] as Map;
|
||||
smoke.check(
|
||||
payload['expiresAt'] > DateTime.now().millisecondsSinceEpoch,
|
||||
'Invitation already expired',
|
||||
);
|
||||
return {'result': result, 'incoming': payload};
|
||||
}
|
||||
|
||||
Future<void> committed(String id) => b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['conversationId'] == id &&
|
||||
e['payload']?['event'] == 'history',
|
||||
)
|
||||
.timeout(const Duration(seconds: 20));
|
||||
Future<Map> record(smoke.Device device, String id) async {
|
||||
final history = await device.ok(79, {});
|
||||
final entries = (history['history'] as List)
|
||||
.where(
|
||||
(item) => item['message']['attaches'][0]['conversationId'] == id,
|
||||
)
|
||||
.toList();
|
||||
smoke.check(entries.length == 1, 'Missing or duplicate call record');
|
||||
return entries.single['message']['attaches'][0] as Map;
|
||||
}
|
||||
|
||||
Future<WebSocket> open(String endpoint) async {
|
||||
final socket = await IkarNetwork.connectWebSocket(endpoint);
|
||||
sockets.add(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
Future<void> command(
|
||||
WebSocket ws,
|
||||
Stream<Map> stream,
|
||||
String name,
|
||||
int seq,
|
||||
) async {
|
||||
final ack = stream
|
||||
.firstWhere((e) => e['type'] == 'response' && e['sequence'] == seq)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
ws.add(jsonEncode({'command': name, 'sequence': seq}));
|
||||
await ack;
|
||||
}
|
||||
|
||||
final canceledId = 'call-canceled-$stamp';
|
||||
final call = await invite(canceledId, false);
|
||||
var forbidden = false;
|
||||
try {
|
||||
forbidden =
|
||||
(await outsider.transport.request(1001, {
|
||||
'conversationId': canceledId,
|
||||
}))['cmd'] !=
|
||||
1;
|
||||
} catch (_) {
|
||||
forbidden = true;
|
||||
}
|
||||
smoke.check(forbidden, 'An outsider canceled another call');
|
||||
checks.add('authenticated participant isolation');
|
||||
final ended = b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['event'] == 'ended' &&
|
||||
e['payload']?['conversationId'] == canceledId,
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
final saved = committed(canceledId);
|
||||
final caller = await open(
|
||||
jsonDecode(call['result']['internalCallerParams'])['endpoint'],
|
||||
);
|
||||
final callerEvents = caller
|
||||
.map((frame) => jsonDecode(frame as String) as Map)
|
||||
.asBroadcastStream();
|
||||
final hold = callerEvents.listen((_) {});
|
||||
await command(caller, callerEvents, 'accept-call', 1);
|
||||
await command(caller, callerEvents, 'hangup', 2);
|
||||
await ended;
|
||||
await saved;
|
||||
for (final device in [a, b]) {
|
||||
final entry = await record(device, canceledId);
|
||||
smoke.check(
|
||||
entry['hangupType'] == 'CANCELED' && entry['duration'] == 0,
|
||||
'Unanswered call counted as connected',
|
||||
);
|
||||
}
|
||||
await a.ok(1001, {'conversationId': canceledId});
|
||||
await record(b, canceledId);
|
||||
await hold.cancel();
|
||||
await caller.close();
|
||||
checks.add(
|
||||
'caller cancellation reaches recipient without any recipient WS2 connection',
|
||||
);
|
||||
checks.add('durable history for both sides and idempotent cancellation');
|
||||
final spent = smoke.decodeCall(call['incoming']['vcp']);
|
||||
var revoked = false;
|
||||
try {
|
||||
final ws = await open(
|
||||
Uri.parse(
|
||||
spent['wse'],
|
||||
).replace(queryParameters: {'token': spent['tkn']}).toString(),
|
||||
);
|
||||
await ws.close();
|
||||
} catch (_) {
|
||||
revoked = true;
|
||||
}
|
||||
smoke.check(revoked, 'Canceled invitation remained joinable');
|
||||
checks.add('canceled call tokens revoked');
|
||||
|
||||
final videoId = 'call-video-$stamp';
|
||||
final video = await invite(videoId, true);
|
||||
final params = smoke.decodeCall(video['incoming']['vcp']);
|
||||
final callee = await open(
|
||||
Uri.parse(
|
||||
params['wse'],
|
||||
).replace(queryParameters: {'token': params['tkn']}).toString(),
|
||||
);
|
||||
final calleeEvents = callee
|
||||
.map((frame) => jsonDecode(frame as String) as Map)
|
||||
.asBroadcastStream();
|
||||
final calleeHold = calleeEvents.listen((_) {});
|
||||
await command(callee, calleeEvents, 'accept-call', 1);
|
||||
final videoSaved = committed(videoId);
|
||||
await command(callee, calleeEvents, 'hangup', 2);
|
||||
await videoSaved;
|
||||
final completed = await record(a, videoId);
|
||||
smoke.check(
|
||||
completed['callType'] == 'VIDEO' &&
|
||||
completed['hangupType'] == 'HUNGUP' &&
|
||||
completed['duration'] > 0,
|
||||
'Connected video history incorrect',
|
||||
);
|
||||
await record(b, videoId);
|
||||
await calleeHold.cancel();
|
||||
await callee.close();
|
||||
checks.add('accepted video call persists type and positive duration');
|
||||
|
||||
final rejectedId = 'call-rejected-$stamp';
|
||||
await invite(rejectedId, false);
|
||||
final rejectSaved = committed(rejectedId);
|
||||
await b.ok(1001, {'conversationId': rejectedId, 'reason': 'REJECTED'});
|
||||
await rejectSaved;
|
||||
smoke.check(
|
||||
(await record(a, rejectedId))['hangupType'] == 'REJECTED',
|
||||
'Reject status not saved',
|
||||
);
|
||||
checks.add('recipient rejection via independent IKAR endpoint');
|
||||
final missedId = 'call-missed-$stamp';
|
||||
final missedSaved = b.transport.events
|
||||
.firstWhere(
|
||||
(e) =>
|
||||
e['opcode'] == 137 &&
|
||||
e['payload']?['event'] == 'history' &&
|
||||
e['payload']?['conversationId'] == missedId,
|
||||
)
|
||||
.timeout(const Duration(seconds: 60));
|
||||
await invite(missedId, false);
|
||||
await missedSaved;
|
||||
smoke.check(
|
||||
(await record(b, missedId))['hangupType'] == 'MISSED',
|
||||
'Ringing timeout not saved',
|
||||
);
|
||||
checks.add('unanswered ringing expires and becomes a missed call');
|
||||
final report = {
|
||||
'passed': checks.length,
|
||||
'checks': checks,
|
||||
'server': base.toString(),
|
||||
'physicalDeviceTested': false,
|
||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||
};
|
||||
await File(
|
||||
'artifacts/live-call-lifecycle-1.0.8.json',
|
||||
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
|
||||
stdout.writeln(jsonEncode(report));
|
||||
} finally {
|
||||
for (final socket in sockets) {
|
||||
try {
|
||||
await socket.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
for (final device in devices) {
|
||||
try {
|
||||
await device.ok(199, {});
|
||||
} catch (_) {}
|
||||
await device.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user