Separate phone status publication from incoming caller warning
This commit is contained in:
1 parent
b7aaf268af
commit
6a4a0f45d8
11 files changed
+210
-9
No files matched your search
+6
-5
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user