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
+9
View File
@@ -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))
}
}