diff --git a/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt b/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt
index 8592ab0..e146619 100644
--- a/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt
+++ b/app/src/main/java/ru/forbion/f7cloud/mobile/CallIncomingActivity.kt
@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
*/
class CallIncomingActivity : ComponentActivity() {
+ private var callEndedListener: ((String) -> Unit)? = null
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
finish()
}
+ // Звонок сняли извне (30-с таймаут «не берут трубку», принят/отклонён из
+ // уведомления) — закрываем полноэкранный входящий.
+ launch.roomToken?.takeIf { it.isNotBlank() }?.let { token ->
+ val listener: (String) -> Unit = { ended ->
+ if (ended == token) {
+ runOnUiThread {
+ if (!isFinishing) finish()
+ }
+ }
+ }
+ callEndedListener = listener
+ F7IncomingCallQueue.addCallEndedListener(listener)
+ }
+
setContent {
F7Theme {
IncomingCallScreen(
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
}
}
+ override fun onDestroy() {
+ callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
+ callEndedListener = null
+ super.onDestroy()
+ }
+
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
diff --git a/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7CallActionReceiver.kt b/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7CallActionReceiver.kt
index b20361c..fc2ea8f 100644
--- a/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7CallActionReceiver.kt
+++ b/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7CallActionReceiver.kt
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
-/** Handles Decline on incoming Talk call notifications. */
+/** Handles Decline and ring-timeout on incoming Talk call notifications. */
class F7CallActionReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
- if (intent.action != ACTION_DECLINE) return
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
- F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
+ when (intent.action) {
+ ACTION_DECLINE -> F7IncomingCallQueue.dismissAndShowNext(context, roomToken)
+ ACTION_CALL_TIMEOUT -> F7IncomingCallQueue.timeoutActive(context, roomToken)
+ }
}
companion object {
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
+ const val ACTION_CALL_TIMEOUT = "ru.forbion.f7cloud.action.CALL_TIMEOUT"
const val EXTRA_NOTIFICATION_ID = "notificationId"
const val EXTRA_ROOM_TOKEN = "roomToken"
}
diff --git a/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7IncomingCallQueue.kt b/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7IncomingCallQueue.kt
index df1b34a..8d393bc 100644
--- a/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7IncomingCallQueue.kt
+++ b/core/push/src/main/java/ru/forbion/f7cloud/core/push/F7IncomingCallQueue.kt
@@ -1,10 +1,13 @@
package ru.forbion.f7cloud.core.push
+import android.app.AlarmManager
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
private const val KEY_QUEUE = "queue"
private const val KEY_ACTIVE_TOKEN = "active_token"
private const val KEY_ACTIVE_AT = "active_at"
+ private const val KEY_ACTIVE_CALL = "active_call"
const val ACTIVE_NOTIFICATION_ID = 5000
+ private const val MISSED_NOTIFICATION_BASE = 5100
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
+ /** Входящий звонит 30 с; дальше — авто-сброс и уведомление «Пропущенный звонок». */
+ private const val RING_TIMEOUT_MS = 30_000L
+
+ /** Запас страховочного alarm поверх основного in-process таймера. */
+ private const val TIMEOUT_ALARM_SLACK_MS = 5_000L
+
private val lock = Any()
+ private val handler = Handler(Looper.getMainLooper())
+ private var timeoutRunnable: Runnable? = null
+ private val callEndedListeners = java.util.concurrent.CopyOnWriteArraySet<(String) -> Unit>()
+
+ /** Уведомляет UI (полноэкранный входящий), что активный звонок снят: принят/отклонён/протух. */
+ fun addCallEndedListener(listener: (String) -> Unit) {
+ callEndedListeners.add(listener)
+ }
+
+ fun removeCallEndedListener(listener: (String) -> Unit) {
+ callEndedListeners.remove(listener)
+ }
+
+ private fun notifyCallEnded(token: String) {
+ callEndedListeners.forEach { listener ->
+ runCatching { listener(token) }
+ }
+ }
fun enqueue(
context: Context,
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
}
if (active.isEmpty()) {
- setActive(prefs, token)
val shown = showNotification(context, call, waiting = 0)
- if (!shown) {
- prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
+ if (shown) {
+ setActive(prefs, call)
+ scheduleTimeout(context, token)
}
return shown
}
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
}
fun dismissAndShowNext(context: Context, roomToken: String?) {
+ var ended: String? = null
synchronized(lock) {
val prefs = prefs(context)
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
@@ -88,36 +118,74 @@ object F7IncomingCallQueue {
}
}
+ cancelTimeout(context)
cancelNotification(context)
- prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
-
- if (queue.length() == 0) {
- prefs.edit().remove(KEY_QUEUE).apply()
- return
- }
-
- runCatching {
- val next = PendingCall.fromJson(queue.getJSONObject(0))
- val rest = JSONArray()
- for (i in 1 until queue.length()) {
- rest.put(queue.get(i))
- }
- prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
- if (showNotification(context, next, rest.length())) {
- setActive(prefs, next.roomToken)
- }
- }.onFailure {
- Log.w(TAG, "Failed to parse queued call", it)
- prefs.edit().remove(KEY_QUEUE).apply()
- }
+ clearActive(prefs)
+ ended = active.takeIf { it.isNotEmpty() }
+ advanceQueueLocked(context, prefs, queue)
}
+ ended?.let { notifyCallEnded(it) }
+ }
+
+ /**
+ * Авто-сброс не отвеченного звонка (истекли [RING_TIMEOUT_MS]): снять входящий,
+ * показать «Пропущенный звонок» и следующий звонок из очереди, если есть.
+ * Принятые/отклонённые звонки сюда не попадают — их снимает [dismissAndShowNext].
+ */
+ fun timeoutActive(context: Context, roomToken: String?) {
+ var ended: String? = null
+ synchronized(lock) {
+ val prefs = prefs(context)
+ val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
+ if (active.isEmpty()) return
+ if (!roomToken.isNullOrBlank() && roomToken.trim() != active) return
+
+ val call = readActiveCall(prefs)
+ cancelTimeout(context)
+ cancelNotification(context)
+ clearActive(prefs)
+ ended = active
+ Log.i(TAG, "Call timed out (unanswered): $active")
+ if (call != null) {
+ showMissedNotification(context, call)
+ }
+ advanceQueueLocked(context, prefs, readQueue(prefs))
+ }
+ ended?.let { notifyCallEnded(it) }
}
fun clearAll(context: Context) {
synchronized(lock) {
val prefs = prefs(context)
+ cancelTimeout(context)
cancelNotification(context)
- prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).apply()
+ prefs.edit().remove(KEY_QUEUE).remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
+ }
+ }
+
+ private fun advanceQueueLocked(
+ context: Context,
+ prefs: android.content.SharedPreferences,
+ queue: JSONArray,
+ ) {
+ if (queue.length() == 0) {
+ prefs.edit().remove(KEY_QUEUE).apply()
+ return
+ }
+ runCatching {
+ val next = PendingCall.fromJson(queue.getJSONObject(0))
+ val rest = JSONArray()
+ for (i in 1 until queue.length()) {
+ rest.put(queue.get(i))
+ }
+ prefs.edit().putString(KEY_QUEUE, rest.toString()).apply()
+ if (showNotification(context, next, rest.length())) {
+ setActive(prefs, next)
+ scheduleTimeout(context, next.roomToken)
+ }
+ }.onFailure {
+ Log.w(TAG, "Failed to parse queued call", it)
+ prefs.edit().remove(KEY_QUEUE).apply()
}
}
@@ -343,13 +411,106 @@ object F7IncomingCallQueue {
private fun prefs(context: Context) =
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
- private fun setActive(prefs: android.content.SharedPreferences, token: String) {
+ private fun setActive(prefs: android.content.SharedPreferences, call: PendingCall) {
prefs.edit()
- .putString(KEY_ACTIVE_TOKEN, token)
+ .putString(KEY_ACTIVE_TOKEN, call.roomToken)
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
+ // Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
+ // показать «Пропущенный» с названием комнаты.
+ .putString(KEY_ACTIVE_CALL, call.toJson().toString())
.apply()
}
+ private fun clearActive(prefs: android.content.SharedPreferences) {
+ prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).remove(KEY_ACTIVE_CALL).apply()
+ }
+
+ private fun readActiveCall(prefs: android.content.SharedPreferences): PendingCall? {
+ val raw = prefs.getString(KEY_ACTIVE_CALL, null) ?: return null
+ return runCatching { PendingCall.fromJson(JSONObject(raw)) }.getOrNull()
+ }
+
+ private fun scheduleTimeout(context: Context, token: String) {
+ val app = context.applicationContext
+ timeoutRunnable?.let(handler::removeCallbacks)
+ val runnable = Runnable { timeoutActive(app, token) }
+ timeoutRunnable = runnable
+ handler.postDelayed(runnable, RING_TIMEOUT_MS)
+ // Страховка: если процесс умрёт до срабатывания таймера, alarm поднимет его и
+ // заменит зависшее уведомление звонка на «Пропущенный». timeoutActive идемпотентен.
+ runCatching {
+ app.getSystemService(AlarmManager::class.java)?.setAndAllowWhileIdle(
+ AlarmManager.RTC_WAKEUP,
+ System.currentTimeMillis() + RING_TIMEOUT_MS + TIMEOUT_ALARM_SLACK_MS,
+ timeoutAlarmIntent(app, token),
+ )
+ }.onFailure { Log.w(TAG, "Timeout alarm schedule failed", it) }
+ }
+
+ private fun cancelTimeout(context: Context) {
+ timeoutRunnable?.let(handler::removeCallbacks)
+ timeoutRunnable = null
+ runCatching {
+ context.applicationContext.getSystemService(AlarmManager::class.java)
+ ?.cancel(timeoutAlarmIntent(context.applicationContext, null))
+ }
+ }
+
+ private fun timeoutAlarmIntent(context: Context, token: String?): PendingIntent {
+ val intent = Intent(context, F7CallActionReceiver::class.java).apply {
+ action = F7CallActionReceiver.ACTION_CALL_TIMEOUT
+ if (token != null) {
+ putExtra(F7CallActionReceiver.EXTRA_ROOM_TOKEN, token)
+ }
+ }
+ return PendingIntent.getBroadcast(
+ context,
+ ACTIVE_NOTIFICATION_ID + 2,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun showMissedNotification(context: Context, call: PendingCall) {
+ F7NotificationChannels.ensureAll(context)
+ val manager = context.getSystemService(NotificationManager::class.java) ?: return
+ val roomName = call.displayName.ifBlank { call.title }
+ val notificationId = MISSED_NOTIFICATION_BASE + kotlin.math.abs(call.roomToken.hashCode() % 1000)
+ val pending = context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
+ PendingIntent.getActivity(
+ context,
+ notificationId,
+ Intent(launch).putExtra(PushIntents.EXTRA_ROOM_TOKEN, call.roomToken),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+ val iconRes = context.applicationInfo.icon.takeIf { it != 0 }
+ ?: android.R.drawable.sym_call_missed
+ // Локскрин — обезличенно (минимум информации, решение владельца); название комнаты
+ // видно после разблокировки.
+ val publicVersion = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
+ .setSmallIcon(iconRes)
+ .setContentTitle("F7cloud")
+ .setContentText(context.getString(R.string.call_missed_public))
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .apply { if (pending != null) setContentIntent(pending) }
+ .build()
+ val notification = NotificationCompat.Builder(context, F7NotificationChannels.MESSAGES)
+ .setSmallIcon(iconRes)
+ .setContentTitle(context.getString(R.string.call_missed_title))
+ .setContentText(roomName)
+ .setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
+ .setPublicVersion(publicVersion)
+ .apply { if (pending != null) setContentIntent(pending) }
+ .build()
+ runCatching { manager.notify(notificationId, notification) }
+ .onFailure { Log.w(TAG, "Missed-call notification failed", it) }
+ }
+
private fun touchActive(prefs: android.content.SharedPreferences) {
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
}
@@ -360,7 +521,7 @@ object F7IncomingCallQueue {
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
Log.w(TAG, "Clearing stale active call: $active")
- prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
+ clearActive(prefs)
}
}
diff --git a/core/push/src/main/res/values/strings.xml b/core/push/src/main/res/values/strings.xml
index 9332134..f668f4b 100644
--- a/core/push/src/main/res/values/strings.xml
+++ b/core/push/src/main/res/values/strings.xml
@@ -5,6 +5,8 @@
Принять
Отклонить
F7cloud звонок
+ Пропущенный звонок
+ Вы пропустили звонок
- Ещё %d звонок в очереди
- Ещё %d звонка в очереди