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;