From 6a4a0f45d8839cdce466506a5a1f71f7184ed96e Mon Sep 17 00:00:00 2001 From: evgeny Date: Mon, 19 Oct 2026 14:51:12 +0200 Subject: [PATCH] Separate phone status publication from incoming caller warning --- src/call/call.c | 11 +- src/call/call.h | 2 +- src/utun_instance.h | 2 + .../app/src/main/AndroidManifest.xml | 1 + .../java/com/utun/chat/ChatApplication.kt | 4 +- .../main/java/com/utun/chat/MainActivity.kt | 4 +- .../java/com/utun/chat/data/ConfigProvider.kt | 7 + .../main/java/com/utun/chat/data/NativeLib.kt | 4 + .../com/utun/chat/data/PhoneStateMonitor.kt | 127 ++++++++++++++++++ .../utun/chat/ui/screens/SettingsScreen.kt | 26 ++++ .../jni_bridge/android_jni_bridge.c | 31 ++++- 11 files changed, 210 insertions(+), 9 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhoneStateMonitor.kt diff --git a/src/call/call.c b/src/call/call.c index e1ec779e..2605f9a1 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -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, diff --git a/src/call/call.h b/src/call/call.h index b46ffee8..6dc84f52 100644 --- a/src/call/call.h +++ b/src/call/call.h @@ -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); diff --git a/src/utun_instance.h b/src/utun_instance.h index ca7f6a0d..ea6ba8c1 100644 --- a/src/utun_instance.h +++ b/src/utun_instance.h @@ -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) diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml index e5795bf9..7dcc9357 100644 --- a/tools/chatgui-android/app/src/main/AndroidManifest.xml +++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 750fb3a4..3f54c532 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -31,6 +31,7 @@ object AppEventHandler : ChatEventCallback { val events: SharedFlow> = _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() } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index adf03d4a..5cf6726c 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -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) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 20557b81..2b0c6210 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -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 = 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") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 439d1780..e569763d 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -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 diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhoneStateMonitor.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhoneStateMonitor.kt new file mode 100644 index 00000000..12dfa2b4 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/PhoneStateMonitor.kt @@ -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() + private val states = mutableMapOf() + 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) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index da39083a..e7bf1a98 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -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() diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 67d6bc8e..f3b8b693 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -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;