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 звонка в очереди