push: 30-с таймаут входящего звонка + уведомление «Пропущенный звонок»
Не отвеченный звонок звонит 30 секунд, после чего снимается сам: рингтон/уведомление гасятся, показывается «Пропущенный звонок» (на локскрине — обезличенно, после разблокировки — с названием комнаты; тап открывает комнату). Полноэкранный входящий закрывается по тому же событию. Страховочный alarm поднимает процесс и снимает звонок, даже если система убила приложение во время звонка. Принятые/отклонённые звонки «Пропущенный» не показывают. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,8 @@ import ru.forbion.f7cloud.feature.talknative.TalkNativeCallLauncher
|
|||||||
*/
|
*/
|
||||||
class CallIncomingActivity : ComponentActivity() {
|
class CallIncomingActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
private var callEndedListener: ((String) -> Unit)? = null
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
@@ -55,6 +57,20 @@ class CallIncomingActivity : ComponentActivity() {
|
|||||||
finish()
|
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 {
|
setContent {
|
||||||
F7Theme {
|
F7Theme {
|
||||||
IncomingCallScreen(
|
IncomingCallScreen(
|
||||||
@@ -74,6 +90,12 @@ class CallIncomingActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
callEndedListener?.let(F7IncomingCallQueue::removeCallEndedListener)
|
||||||
|
callEndedListener = null
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
override fun onNewIntent(intent: Intent) {
|
override fun onNewIntent(intent: Intent) {
|
||||||
super.onNewIntent(intent)
|
super.onNewIntent(intent)
|
||||||
setIntent(intent)
|
setIntent(intent)
|
||||||
|
|||||||
@@ -4,16 +4,19 @@ import android.content.BroadcastReceiver
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
|
||||||
/** Handles Decline on incoming Talk call notifications. */
|
/** Handles Decline and ring-timeout on incoming Talk call notifications. */
|
||||||
class F7CallActionReceiver : BroadcastReceiver() {
|
class F7CallActionReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
if (intent.action != ACTION_DECLINE) return
|
|
||||||
val roomToken = intent.getStringExtra(EXTRA_ROOM_TOKEN)
|
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 {
|
companion object {
|
||||||
const val ACTION_DECLINE = "ru.forbion.f7cloud.action.DECLINE_CALL"
|
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_NOTIFICATION_ID = "notificationId"
|
||||||
const val EXTRA_ROOM_TOKEN = "roomToken"
|
const val EXTRA_ROOM_TOKEN = "roomToken"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package ru.forbion.f7cloud.core.push
|
package ru.forbion.f7cloud.core.push
|
||||||
|
|
||||||
|
import android.app.AlarmManager
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.Person
|
import androidx.core.app.Person
|
||||||
@@ -21,10 +24,36 @@ object F7IncomingCallQueue {
|
|||||||
private const val KEY_QUEUE = "queue"
|
private const val KEY_QUEUE = "queue"
|
||||||
private const val KEY_ACTIVE_TOKEN = "active_token"
|
private const val KEY_ACTIVE_TOKEN = "active_token"
|
||||||
private const val KEY_ACTIVE_AT = "active_at"
|
private const val KEY_ACTIVE_AT = "active_at"
|
||||||
|
private const val KEY_ACTIVE_CALL = "active_call"
|
||||||
const val ACTIVE_NOTIFICATION_ID = 5000
|
const val ACTIVE_NOTIFICATION_ID = 5000
|
||||||
|
private const val MISSED_NOTIFICATION_BASE = 5100
|
||||||
private const val ACTIVE_RING_TTL_MS = 3 * 60 * 1000L
|
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 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(
|
fun enqueue(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -58,10 +87,10 @@ object F7IncomingCallQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (active.isEmpty()) {
|
if (active.isEmpty()) {
|
||||||
setActive(prefs, token)
|
|
||||||
val shown = showNotification(context, call, waiting = 0)
|
val shown = showNotification(context, call, waiting = 0)
|
||||||
if (!shown) {
|
if (shown) {
|
||||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
setActive(prefs, call)
|
||||||
|
scheduleTimeout(context, token)
|
||||||
}
|
}
|
||||||
return shown
|
return shown
|
||||||
}
|
}
|
||||||
@@ -74,6 +103,7 @@ object F7IncomingCallQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
fun dismissAndShowNext(context: Context, roomToken: String?) {
|
||||||
|
var ended: String? = null
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val prefs = prefs(context)
|
val prefs = prefs(context)
|
||||||
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
val active = prefs.getString(KEY_ACTIVE_TOKEN, "").orEmpty()
|
||||||
@@ -88,36 +118,74 @@ object F7IncomingCallQueue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancelTimeout(context)
|
||||||
cancelNotification(context)
|
cancelNotification(context)
|
||||||
prefs.edit().remove(KEY_ACTIVE_TOKEN).remove(KEY_ACTIVE_AT).apply()
|
clearActive(prefs)
|
||||||
|
ended = active.takeIf { it.isNotEmpty() }
|
||||||
if (queue.length() == 0) {
|
advanceQueueLocked(context, prefs, queue)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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) {
|
fun clearAll(context: Context) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
val prefs = prefs(context)
|
val prefs = prefs(context)
|
||||||
|
cancelTimeout(context)
|
||||||
cancelNotification(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) =
|
private fun prefs(context: Context) =
|
||||||
context.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
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()
|
prefs.edit()
|
||||||
.putString(KEY_ACTIVE_TOKEN, token)
|
.putString(KEY_ACTIVE_TOKEN, call.roomToken)
|
||||||
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
.putLong(KEY_ACTIVE_AT, System.currentTimeMillis())
|
||||||
|
// Персистим весь звонок: страховочный alarm после смерти процесса должен уметь
|
||||||
|
// показать «Пропущенный» с названием комнаты.
|
||||||
|
.putString(KEY_ACTIVE_CALL, call.toJson().toString())
|
||||||
.apply()
|
.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) {
|
private fun touchActive(prefs: android.content.SharedPreferences) {
|
||||||
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
prefs.edit().putLong(KEY_ACTIVE_AT, System.currentTimeMillis()).apply()
|
||||||
}
|
}
|
||||||
@@ -360,7 +521,7 @@ object F7IncomingCallQueue {
|
|||||||
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
|
val activeAt = prefs.getLong(KEY_ACTIVE_AT, 0L)
|
||||||
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
|
if (activeAt <= 0L || System.currentTimeMillis() - activeAt > ACTIVE_RING_TTL_MS) {
|
||||||
Log.w(TAG, "Clearing stale active call: $active")
|
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_accept">Принять</string>
|
||||||
<string name="call_action_decline">Отклонить</string>
|
<string name="call_action_decline">Отклонить</string>
|
||||||
<string name="incoming_call_subtitle">F7cloud звонок</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">
|
<plurals name="call_queue_waiting">
|
||||||
<item quantity="one">Ещё %d звонок в очереди</item>
|
<item quantity="one">Ещё %d звонок в очереди</item>
|
||||||
<item quantity="few">Ещё %d звонка в очереди</item>
|
<item quantity="few">Ещё %d звонка в очереди</item>
|
||||||
|
|||||||
Reference in New Issue
Block a user