Separate phone status publication from incoming caller warning

This commit is contained in:
evgeny committed 2026-10-19 14:51:12 +02:00
1 parent b7aaf268af
commit 6a4a0f45d8
11 files changed
+210 -9

No files matched your search

+6 -5
View File
@@ -206,7 +206,7 @@ static void call_event_incoming(struct call_ctx* ctx, uint64_t group_id, uint64_
}
uint8_t call_phone_status(struct UTUN_INSTANCE* inst) {
if (!inst) return 0;
if (!inst || inst->phone_status_hidden) return 0;
if (inst->phone_state == 2 || call_busy(inst)) return TOPO_FLAG_PHONE_KNOWN | TOPO_FLAG_PHONE_BUSY;
return inst->phone_state == 1 ? TOPO_FLAG_PHONE_KNOWN : 0;
}
@@ -231,11 +231,12 @@ static void call_publish_phone_status(struct UTUN_INSTANCE* inst) {
}
}
void call_set_phone_state(struct UTUN_INSTANCE* inst, int state) {
void call_set_phone_state(struct UTUN_INSTANCE* inst, int state, int publish, int notify) {
if (!inst || state < 0 || state > 2) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "invalid phone state=%d", state); return; }
if (inst->phone_state == state) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "phone state old=%u new=%d", inst->phone_state, state);
if (inst->phone_state == state && inst->phone_status_hidden == !publish && inst->phone_notify_enabled == !!notify) return;
DEBUG_INFO(DEBUG_CATEGORY_CALL, "phone state old=%u new=%d publish=%d notify=%d", inst->phone_state, state, !!publish, !!notify);
inst->phone_state = (uint8_t)state;
inst->phone_status_hidden = !publish; inst->phone_notify_enabled = !!notify;
call_publish_phone_status(inst);
}
@@ -633,7 +634,7 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: session alloc failed id=%016llx", CALL_ID, (unsigned long long)call_id);
return;
}
s->state = ctx->inst->phone_state == 2 ? CALL_ST_WAIT_DECISION : CALL_ST_RINGING;
s->state = ctx->inst->phone_notify_enabled && ctx->inst->phone_state == 2 ? CALL_ST_WAIT_DECISION : CALL_ST_RINGING;
call_publish_phone_status(ctx->inst);
DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: INVITE id=%016llx from=0x%016llx group=0x%016llx (declared group=0x%016llx)",
CALL_ID, (unsigned long long)call_id, (unsigned long long)from,
+1 -1
View File
@@ -40,7 +40,7 @@ int call_busy(struct UTUN_INSTANCE* inst);
/* Продолжить исходящий вызов после предупреждения о телефонной занятости. Только uasync. */
void call_continue(struct UTUN_INSTANCE* inst, uint64_t call_id);
/* Живой статус ОС: 0=неизвестно, 1=свободен, 2=занят. Только uasync; публикует BGP при изменении. */
void call_set_phone_state(struct UTUN_INSTANCE* inst, int state);
void call_set_phone_state(struct UTUN_INSTANCE* inst, int state, int publish, int notify);
/* Флаги BGP PHONE_KNOWN/BUSY с учётом собственного обычного звонка. Только uasync. */
uint8_t call_phone_status(struct UTUN_INSTANCE* inst);
+2
View File
@@ -262,6 +262,8 @@ struct UTUN_INSTANCE {
uint8_t client_activity; // CLIENT_ACTIVITY_STANDBY/ACTIVE
uint8_t client_activity_requested; // режим frontend; stream owners могут временно удерживать эффективный ACTIVE
uint8_t phone_state; // 0=неизвестно, 1=свободен, 2=занят; живое состояние ОС, не сохраняется
uint8_t phone_status_hidden; // не публиковать телефонную занятость в BGP
uint8_t phone_notify_enabled; // предупреждать вызывающего о телефонной занятости
struct utun_activity_cbk_entry* activity_cbks; // подписки на смену client_activity
struct peer_sleep_cbk_entry* peer_sleep_cbks; // подписки на смену спячки пира (keepalive)
@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
@@ -31,6 +31,7 @@ object AppEventHandler : ChatEventCallback {
val events: SharedFlow<Pair<Int, ByteArray?>> = _events
override fun onEvent(type: Int, data: ByteArray?) {
if (type == 12) mainHandler.post { com.utun.chat.data.PhoneStateMonitor.refresh() }
if (type == 12 || type == 13 || type == 65)
mainHandler.post { com.utun.chat.data.RadioCallController.onEvent(type, data) }
if (type == 13 || type in 61..64) mainHandler.post { com.utun.chat.data.LocalDiscovery.onEvent(type, data) }
@@ -39,7 +40,7 @@ object AppEventHandler : ChatEventCallback {
if (type in listOf(4, 8, 9, 12, 13, 23, 24, 25, 31) || type in 49..53)
mainHandler.post { ChatApplication.instance.avatars.onEvent(type, data) }
if (type == 1 || type == 32) playMessageSound(type, data)
if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) {
if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44 || type == 66) {
mainHandler.post { CallController.onEvent(type, data) }
}
// Подтверждения отправки нельзя терять при заполнении буфера; native-поток не блокируем.
@@ -90,6 +91,7 @@ class ChatApplication : Application() {
/* Telecom self-managed call integration (system audio routing for calls) */
TelecomCallManager.init()
CallController.init()
com.utun.chat.data.PhoneStateMonitor.init()
}
@@ -297,7 +297,7 @@ class MainActivity : AppCompatActivity() {
if (phase == lastCallPhase) return@LaunchedEffect
lastCallPhase = phase
when (phase) {
CallPhase.OUTGOING, CallPhase.RINGING,
CallPhase.OUTGOING, CallPhase.RINGING, CallPhase.WAIT_DECISION,
CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen
screen = Screen.Call
@@ -361,6 +361,7 @@ class MainActivity : AppCompatActivity() {
val showCallBanner = screen != Screen.Call &&
(callPhase == CallPhase.OUTGOING ||
callPhase == CallPhase.RINGING ||
callPhase == CallPhase.WAIT_DECISION ||
callPhase == CallPhase.ACTIVE)
if (showCallBanner) {
ActiveCallBanner(
@@ -627,6 +628,7 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
com.utun.chat.data.PhoneStateMonitor.refresh()
RadioOverlayService.setActivityVisible(true)
}
@@ -85,6 +85,13 @@ class ConfigProvider(private val context: Context) {
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
private val startOnBootKey = booleanPreferencesKey("start_on_boot")
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val phoneStatusEnabledKey = booleanPreferencesKey("phone_status_enabled")
val phoneStatusEnabled: Flow<Boolean> = context.dataStore.data.map { it[phoneStatusEnabledKey] ?: false }.distinctUntilChanged()
suspend fun setPhoneStatusEnabled(enabled: Boolean) {
context.dataStore.edit { it[phoneStatusEnabledKey] = enabled }
LogManager.addLog("INFO", "PhoneState", "Phone status monitoring enabled=$enabled")
}
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val radioAutoEnabledKey = intPreferencesKey("radio_auto_enabled")
@@ -270,6 +270,8 @@ object NativeLib {
fun callAccept(callId: Long) { nativeCallAccept(callId) }
fun callDecline(callId: Long) { nativeCallDecline(callId) }
fun callHangup(callId: Long) { nativeCallHangup(callId) }
fun callContinue(callId: Long) { nativeCallContinue(callId) }
fun setPhoneState(state: Int) { nativeSetPhoneState(state) }
fun callIsOnline(channelId: String, peerNodeId: Long): Boolean = nativeCallIsOnline(channelId, peerNodeId)
/* action: radio_call_action, результат — CHAT_EVT_RADIO_CALL (65). */
fun radioCall(id: Long, action: Int, group: Long = 0, peer: Long = 0): Boolean = nativeRadioCall(id, group, peer, action)
@@ -416,6 +418,8 @@ object NativeLib {
private external fun nativeCallAccept(callId: Long)
private external fun nativeCallDecline(callId: Long)
private external fun nativeCallHangup(callId: Long)
private external fun nativeCallContinue(callId: Long)
private external fun nativeSetPhoneState(state: Int)
private external fun nativeCallIsOnline(channelId: String, peerNodeId: Long): Boolean
private external fun nativeCallAudioStart(callId: Long): Boolean
private external fun nativeCallAudioFeed(callId: Long, samples: ShortArray, capturePtsUs: Long): Boolean
@@ -0,0 +1,127 @@
package com.utun.chat.data
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.telephony.PhoneStateListener
import android.telephony.SubscriptionManager
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** Живое состояние SIM-звонков. Номера не читаются; отсутствие разрешения означает UNKNOWN. */
@Suppress("DEPRECATION")
object PhoneStateMonitor {
private val main = Handler(Looper.getMainLooper())
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private data class Watch(val manager: TelephonyManager, val callback: Any)
private val watches = mutableListOf<Watch>()
private val states = mutableMapOf<Int, Int>()
private var enabled = false
private var generation = 0
private var lastState = -1
private var started = false
private var listeningSubscriptions = false
private val subscriptions = object : SubscriptionManager.OnSubscriptionsChangedListener() {
override fun onSubscriptionsChanged() { main.post {
if (enabled && granted()) try {
val ids = ChatApplication.instance.getSystemService(SubscriptionManager::class.java)
?.activeSubscriptionInfoList.orEmpty().map { it.subscriptionId }.toSet()
if (ids != states.keys) refresh()
} catch (e: Exception) { LogManager.addLog("WARN", "PhoneState", "Cannot read changed SIM subscriptions: $e"); publish(0) }
} }
}
fun granted(): Boolean = ContextCompat.checkSelfPermission(ChatApplication.instance, Manifest.permission.READ_PHONE_STATE) ==
PackageManager.PERMISSION_GRANTED
fun init() {
if (started) return
started = true
val app = ChatApplication.instance
app.packageManager.addOnPermissionsChangeListener { uid ->
if (uid == app.applicationInfo.uid) main.post { refresh() }
}
scope.launch {
app.configProvider.phoneStatusEnabled.collect { value ->
enabled = value
refresh()
}
}
}
/** Вызывается также после старта ядра и возврата из системных настроек. */
fun refresh() {
check(Looper.myLooper() == Looper.getMainLooper())
val app = ChatApplication.instance
val subManager = app.getSystemService(SubscriptionManager::class.java)
generation++
for (watch in watches) try {
if (Build.VERSION.SDK_INT >= 31) watch.manager.unregisterTelephonyCallback(watch.callback as TelephonyCallback)
else watch.manager.listen(watch.callback as PhoneStateListener, PhoneStateListener.LISTEN_NONE)
} catch (e: Exception) { LogManager.addLog("WARN", "PhoneState", "Cannot unregister phone observer: $e") }
watches.clear(); states.clear()
if (!enabled || !granted()) {
if (listeningSubscriptions) subManager?.removeOnSubscriptionsChangedListener(subscriptions)
listeningSubscriptions = false
publish(0); return
}
val telephony = app.getSystemService(TelephonyManager::class.java)
if (telephony == null || subManager == null) {
LogManager.addLog("WARN", "PhoneState", "Telephony unavailable"); publish(0); return
}
val currentGeneration = generation
try {
if (!listeningSubscriptions) {
subManager.addOnSubscriptionsChangedListener(subscriptions)
listeningSubscriptions = true
}
val ids = subManager.activeSubscriptionInfoList.orEmpty().map { it.subscriptionId }
if (ids.isEmpty()) { publish(0); return }
for (id in ids) states[id] = -1
for (id in ids) {
val manager = telephony.createForSubscriptionId(id)
val callback: Any = if (Build.VERSION.SDK_INT >= 31) {
object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) = changed(currentGeneration, id, state)
}.also { manager.registerTelephonyCallback(ContextCompat.getMainExecutor(app), it) }
} else {
object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) = changed(currentGeneration, id, state)
}.also { manager.listen(it, PhoneStateListener.LISTEN_CALL_STATE) }
}
watches.add(Watch(manager, callback))
states[id] = if (Build.VERSION.SDK_INT >= 31) manager.callStateForSubscription else manager.callState
}
publishAggregate()
} catch (e: Exception) {
generation++ // Поздние callbacks частично зарегистрированного набора не публикуют ложную свободу.
LogManager.addLog("ERROR", "PhoneState", "Cannot observe phone state: $e")
publish(0)
}
}
private fun changed(token: Int, id: Int, state: Int) {
if (token != generation || !enabled || !states.containsKey(id)) return
states[id] = state
publishAggregate()
}
private fun publishAggregate() {
val busy = states.values.any { it == TelephonyManager.CALL_STATE_OFFHOOK || it == TelephonyManager.CALL_STATE_RINGING }
publish(if (busy) 2 else if (states.isNotEmpty() && states.values.all { it == TelephonyManager.CALL_STATE_IDLE }) 1 else 0)
}
private fun publish(state: Int) {
if (lastState != state) LogManager.addLog("INFO", "PhoneState", "State=$state enabled=$enabled subscriptions=${states.size}")
lastState = state
if (NativeLib.isReady()) NativeLib.setPhoneState(state)
}
}
@@ -20,6 +20,10 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import android.Manifest
import com.utun.chat.data.PhoneStateMonitor
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -1042,6 +1046,28 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
/* ── Входящие звонки поверх блокировки ── */
Text(stringResource(R.string.incoming_calls), style = MaterialTheme.typography.titleMedium)
val phoneStatusEnabled by provider.phoneStatusEnabled.collectAsState(initial = false)
var phonePermission by remember { mutableStateOf(PhoneStateMonitor.granted()) }
val phonePermissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
phonePermission = granted
LogManager.addLog(if (granted) "INFO" else "WARN", "PhoneState", "Phone permission result granted=$granted")
scope.launch { provider.setPhoneStatusEnabled(granted) }
}
LifecycleResumeEffect(Unit) {
phonePermission = PhoneStateMonitor.granted()
onPauseOrDispose { }
}
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.phone_status_setting))
Text(stringResource(if (phoneStatusEnabled && !phonePermission) R.string.phone_status_permission_missing
else R.string.phone_status_description), style = MaterialTheme.typography.bodySmall)
}
Switch(checked = phoneStatusEnabled && phonePermission, onCheckedChange = { value ->
if (value && !PhoneStateMonitor.granted()) phonePermissionLauncher.launch(Manifest.permission.READ_PHONE_STATE)
else scope.launch { provider.setPhoneStatusEnabled(value) }
})
}
var fsiEnabled by remember { mutableStateOf(IncomingCallManager.canFullScreenIntent()) }
LifecycleResumeEffect(Unit) {
fsiEnabled = IncomingCallManager.canFullScreenIntent()
@@ -2791,7 +2791,7 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCall(
(void)env; (void)thiz;
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
struct UASYNC* ua = instance_lite_get_uasync();
if (!inst || !ua || !uasync_is_running(ua) || !id || action < RADIO_CALL_START || action > RADIO_CALL_REJECT_BUSY) {
if (!inst || !ua || !uasync_is_running(ua) || !id || action < RADIO_CALL_START || action > RADIO_CALL_CONTINUE) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "radio call JNI rejected id=%lld action=%d core=%p", (long long)id, action, (void*)inst);
return JNI_FALSE;
}
@@ -2818,6 +2818,35 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallHangup(
utun_bridge_call_hangup((uint64_t)callId);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeCallContinue(
JNIEnv* env, jobject thiz, jlong callId) {
(void)env; (void)thiz;
bridge_call_id_action((uint64_t)callId, "continue", call_continue_trampoline);
}
struct phone_state_request { struct UTUN_INSTANCE* inst; int state; };
static void phone_state_execute(void* arg) {
struct phone_state_request* req = arg;
call_set_phone_state(req->inst, req->state); u_free(req);
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeSetPhoneState(
JNIEnv* env, jobject thiz, jint state) {
(void)env; (void)thiz;
struct UTUN_INSTANCE* inst = instance_lite_get_instance();
struct UASYNC* ua = instance_lite_get_uasync();
if (!inst || !ua || !uasync_is_running(ua) || state < 0 || state > 2) {
DEBUG_WARN(DEBUG_CATEGORY_CALL, "phone state JNI unavailable state=%d", state); return;
}
struct phone_state_request* req = u_calloc(1, sizeof(*req));
struct posted_task* task = u_calloc(1, sizeof(*task));
if (!req || !task) {
DEBUG_ERROR(DEBUG_CATEGORY_CALL, "phone state JNI allocation failed"); u_free(req); u_free(task); return;
}
req->inst = inst; req->state = state;
task->callback = phone_state_execute; task->arg = req; uasync_post_reserved(ua, task);
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeCallIsOnline(
JNIEnv* env, jobject thiz, jstring channelId, jlong peerNodeId) {
(void)thiz;