push: 30-с таймаут входящего звонка + уведомление «Пропущенный звонок»
Не отвеченный звонок звонит 30 секунд, после чего снимается сам: рингтон/уведомление гасятся, показывается «Пропущенный звонок» (на локскрине — обезличенно, после разблокировки — с названием комнаты; тап открывает комнату). Полноэкранный входящий закрывается по тому же событию. Страховочный alarm поднимает процесс и снимает звонок, даже если система убила приложение во время звонка. Принятые/отклонённые звонки «Пропущенный» не показывают. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<string name="call_action_accept">Принять</string>
|
||||
<string name="call_action_decline">Отклонить</string>
|
||||
<string name="incoming_call_subtitle">F7cloud звонок</string>
|
||||
<string name="call_missed_title">Пропущенный звонок</string>
|
||||
<string name="call_missed_public">Вы пропустили звонок</string>
|
||||
<plurals name="call_queue_waiting">
|
||||
<item quantity="one">Ещё %d звонок в очереди</item>
|
||||
<item quantity="few">Ещё %d звонка в очереди</item>
|
||||
|
||||
Reference in New Issue
Block a user