From b36c685cfaefb9dde2e3457e24bc1543ce96cde6 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 30 Sep 2026 19:42:12 +0300 Subject: [PATCH] Show ongoing Android calls on the lock screen --- .../java/com/utun/chat/data/CallController.kt | 7 ++- .../java/com/utun/chat/data/CallService.kt | 48 +++++++++++++++---- .../main/java/com/utun/chat/data/CallState.kt | 3 +- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt index f48e725d..6649b385 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt @@ -42,7 +42,6 @@ object CallController : TelecomCallManager.Listener { private var answerWhenVisible = 0L private var activityVisible = false private var coreReady = false - private var startedAt = 0L private var incomingAt = 0L private var initialized = false @@ -57,7 +56,7 @@ object CallController : TelecomCallManager.Listener { val st = _state.value ?: continue val now = SystemClock.elapsedRealtime() if (st.phase == CallPhase.ACTIVE) { - _state.value = st.copy(durationSeconds = ((now - startedAt) / 1000).toInt()) + _state.value = st.copy(durationSeconds = ((now - st.startedAtElapsedRealtime) / 1000).toInt()) } else if (st.phase == CallPhase.INCOMING && now - incomingAt > 50_000) { LogManager.addLog("WARN", "Call", "incoming timeout id=${st.callId}; declining") end(st.callId) @@ -297,6 +296,7 @@ object CallController : TelecomCallManager.Listener { 35 -> if (st.phase == CallPhase.OUTGOING) { _state.value = st.copy(phase = CallPhase.RINGING) CallRingback.startRinging() + service?.updateNotification() } 36 -> { if (st.phase == CallPhase.ACTIVE) return @@ -306,12 +306,11 @@ object CallController : TelecomCallManager.Listener { end(id, 7) return } - startedAt = SystemClock.elapsedRealtime() accepting = false CallRingback.stop() IncomingCallManager.stopRinging() TelecomCallManager.setActive(id) - _state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0) + _state.value = st.copy(phase = CallPhase.ACTIVE, durationSeconds = 0, startedAtElapsedRealtime = SystemClock.elapsedRealtime()) owner.audio.router.onChanged = { route, headset -> routeChanged(route, headset) } _earpiece.value = owner.audio.router.hasEarpiece routeChanged(owner.audio.router.route, owner.audio.router.headsetAvailable) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt index 3ab4ebcd..94948969 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt @@ -14,7 +14,9 @@ import android.net.Uri import android.os.Build import android.os.IBinder import android.os.PowerManager +import android.os.SystemClock import androidx.core.app.NotificationCompat +import androidx.core.app.Person import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import com.utun.chat.ChatApplication @@ -27,7 +29,8 @@ import com.utun.chat.MainActivity */ class CallService : Service() { companion object { - private const val CHANNEL = "ongoing_call" + // У старого low-канала Android не позволяет повысить importance после создания. + private const val CHANNEL = "ongoing_call_lockscreen" private const val NOTIFICATION_ID = 1002 private const val EXTRA_ID = "call_id" private const val ACTION_END = "com.utun.chat.action.END_CALL" @@ -53,10 +56,18 @@ class CallService : Service() { override fun onCreate() { super.onCreate() - getSystemService(NotificationManager::class.java).createNotificationChannel( - NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_LOW).apply { + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_DEFAULT).apply { + description = "Собеседник, длительность и завершение звонка на экране блокировки" setSound(null, null) + enableVibration(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC }) + val channel = manager.getNotificationChannel(CHANNEL) + LogManager.addLog("INFO", "CallService", + "notification channel=$CHANNEL enabled=${manager.areNotificationsEnabled()} " + + "importance=${channel.importance} lockscreenVisibility=${channel.lockscreenVisibility}") audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) } LogManager.addLog("INFO", "CallService", "created") } @@ -122,6 +133,7 @@ class CallService : Service() { ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL or micType } else 0 ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types) + LogManager.addLog("INFO", "CallService", "foreground notification id=$callId phase=${CallController.state.value?.phase}") } fun startAudio(id: Long): Boolean { @@ -135,25 +147,43 @@ class CallService : Service() { fun updateNotification() { getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification()) + LogManager.addLog("INFO", "CallService", "notification updated id=$callId phase=${CallController.state.value?.phase}") } private fun notification(): Notification { - val st = CallController.state.value + val st = CallController.state.value?.takeIf { it.callId == callId } + val active = st?.phase == CallPhase.ACTIVE + val person = Person.Builder().setName(st?.peerName?.takeIf { it.isNotBlank() } ?: "vibechat") + .setKey("node:${st?.peerNodeId}").build() val open = PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) val end = PendingIntent.getService(this, 0, Intent(this, CallService::class.java).setAction(ACTION_END) .setData(Uri.parse("utun-call:$callId")).putExtra(EXTRA_ID, callId), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) - return NotificationCompat.Builder(this, CHANNEL) + val builder = NotificationCompat.Builder(this, CHANNEL) .setSmallIcon(android.R.drawable.ic_menu_call) - .setContentTitle(st?.peerName ?: "vibechat") - .setContentText(if (st?.phase == CallPhase.ACTIVE) "Разговор" else "Установка звонка") + .setContentTitle(person.name) + .setContentText(when (st?.phase) { + CallPhase.ACTIVE -> "Разговор" + CallPhase.RINGING -> "Звоним…" + else -> "Установка звонка" + }) .setCategory(NotificationCompat.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setOngoing(true) + .setOnlyAlertOnce(true) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setContentIntent(open) - .addAction(0, "Завершить", end) - .build() + .setStyle(NotificationCompat.CallStyle.forOngoingCall(person, end)) + .addPerson(person) + .setShowWhen(active) + .setUsesChronometer(active) + if (active && st != null) { + // Один источник начала разговора для UI и системного таймера; уведомление не обновляется каждую секунду. + builder.setWhen(System.currentTimeMillis() - (SystemClock.elapsedRealtime() - st.startedAtElapsedRealtime)) + } + return builder.build() } /** Идемпотентное освобождение до остановки сервиса; выполняется в main thread. */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt index 9cd2b303..c73edcba 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt @@ -17,7 +17,8 @@ data class CallState( val reason: Int = -1, val durationSeconds: Int = 0, val bgpPath: String = "", - val stats: CallStats? = null + val stats: CallStats? = null, + val startedAtElapsedRealtime: Long = 0 ) { val ongoing: Boolean get() = phase != CallPhase.NONE && phase != CallPhase.ENDED }