Improve Android proximity settings and channel scanner layout

This commit is contained in:
evgeny committed 2026-10-31 18:29:09 +01:00
1 parent 2c89490794
commit fc0f36f848
12 files changed
+151 -18

No files matched your search

@@ -96,6 +96,8 @@ object CallController : TelecomCallManager.Listener {
val earpiece: StateFlow<Boolean> = _earpiece
private val proximity = ProximityManager(ChatApplication.instance)
val proximityNear: StateFlow<Boolean?> = proximity.near
private var proximityEnabled = false // До чтения сохранённой настройки датчик не запускаем.
private var proximityThresholdCm = 5f
private var service: CallService? = null
@@ -115,6 +117,14 @@ object CallController : TelecomCallManager.Listener {
if (initialized) return
initialized = true
TelecomCallManager.setListener(this)
scope.launch {
val provider = ChatApplication.instance.configProvider
provider.changes.observe("call proximity settings", provider.proximitySettings).collect { (enabled, threshold) ->
proximityEnabled = enabled; proximityThresholdCm = threshold
LogManager.addLog("INFO", "Proximity", "Settings applied enabled=$enabled threshold=$threshold cm")
syncProximity(); applyVideoSpeakerIfReady()
}
}
scope.launch { proximity.near.collect { applyVideoSpeakerIfReady() } }
scope.launch {
while (true) {
@@ -467,12 +477,13 @@ object CallController : TelecomCallManager.Listener {
}
private fun syncProximity() {
if (_state.value?.phase == CallPhase.ACTIVE && _route.value == AudioRoute.EARPIECE
&& (!_videoActive.value || videoSpeakerPending)) proximity.start() else proximity.stop()
if (proximityEnabled && _state.value?.phase == CallPhase.ACTIVE && _route.value == AudioRoute.EARPIECE
&& (!_videoActive.value || videoSpeakerPending)) proximity.start(proximityThresholdCm) else proximity.stop()
}
private fun applyVideoSpeakerIfReady() {
if (!videoSpeakerPending || !_videoActive.value || proximity.near.value != false || _route.value != AudioRoute.EARPIECE) return
if (!videoSpeakerPending || !_videoActive.value || _route.value != AudioRoute.EARPIECE) return
if (proximityEnabled && proximity.near.value != false) return
val st = _state.value ?: return
val audio = service?.audio ?: return
if (st.phase != CallPhase.ACTIVE) return
@@ -481,7 +492,7 @@ object CallController : TelecomCallManager.Listener {
audio.router.fallbackRoute = AudioRoute.SPEAKER
audio.router.setRoute(AudioRoute.SPEAKER, automatic = true)
syncProximity()
LogManager.addLog("INFO", "call", "video speaker requested after confirmed FAR id=${st.callId}")
LogManager.addLog("INFO", "call", "video speaker requested proximityEnabled=$proximityEnabled near=${proximity.near.value} id=${st.callId}")
}
/** Только живые события ядра, сериализованные AppEventHandler на main thread. */
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
@@ -138,6 +139,29 @@ class ConfigProvider(private val context: Context) {
}
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val proximityEnabledKey = booleanPreferencesKey("proximity_enabled")
private val proximityThresholdKey = floatPreferencesKey("proximity_threshold_cm")
/** Использование датчика и порог в сантиметрах; по умолчанию включён, 5 см. */
val proximitySettings: Flow<Pair<Boolean, Float>> = context.dataStore.data.map { prefs ->
val threshold = prefs[proximityThresholdKey] ?: 5f
val valid = threshold.isFinite() && threshold in 0.1f..10f
if (!valid) LogManager.addLog("WARN", "Proximity", "Invalid saved threshold=$threshold; using 5 cm")
(prefs[proximityEnabledKey] ?: true) to if (valid) threshold else 5f
}.distinctUntilChanged()
suspend fun setProximityEnabled(enabled: Boolean) {
context.dataStore.edit { it[proximityEnabledKey] = enabled }
LogManager.addLog("INFO", "Proximity", "Setting saved enabled=$enabled")
}
suspend fun setProximityThreshold(thresholdCm: Float) {
if (!thresholdCm.isFinite() || thresholdCm !in 0.1f..10f) {
LogManager.addLog("ERROR", "Proximity", "Invalid threshold=$thresholdCm cm")
throw IllegalArgumentException("Proximity threshold must be within 0.1..10 cm")
}
context.dataStore.edit { it[proximityThresholdKey] = thresholdCm }
LogManager.addLog("INFO", "Proximity", "Setting saved threshold=$thresholdCm cm")
}
private val mediaAudioProfileKey = stringPreferencesKey("media_audio_profile")
private val radioAutoEnabledKey = intPreferencesKey("radio_auto_enabled")
private val radioAutoModeKey = intPreferencesKey("radio_auto_mode")
@@ -14,7 +14,7 @@ import kotlinx.coroutines.flow.StateFlow
* Показания приближения для блокировки UI аудиозвонка и выбора громкой связи видеозвонка.
* start/stop вызываются на main; callbacks доставляются туда же. Повторные вызовы безопасны,
* события остановленной регистрации игнорируются, новый start ждёт нового показания.
* near: true — расстояние меньше maximumRange, false — FAR; null — показания ещё нет или монитор остановлен.
* near: true — расстояние меньше порога и maximumRange, false — FAR; null — показания ещё нет или монитор остановлен.
* Без датчика start публикует false (экран не блокируется). Ошибка доступа/регистрации оставляет null.
* Runtime-разрешения не нужны; отказы ОС логируются, после отказа регистрации start можно повторить.
* Failed unregister сохраняет listener/manager и повторяется на main через 250 мс..30 с; новый start ждёт его ACK.
@@ -49,15 +49,22 @@ class ProximityManager(private val context: Context) {
private var listener: SensorEventListener? = null
private var started = false
private var thresholdCm = 5f
/** Датчик обнаружен при создании объекта; успешная регистрация start этим не гарантируется. */
val available: Boolean
get() = sensor != null
/** Начать наблюдение на main. При отказе сохранить UNKNOWN и разрешить следующую попытку start. */
fun start() {
/** Начать наблюдение на main с порогом в см; смена порога ждёт нового показания. При отказе остаётся UNKNOWN. */
fun start(thresholdCm: Float = 5f) {
checkMain()
if (started) return
if (!thresholdCm.isFinite() || thresholdCm <= 0) {
LogManager.addLog("ERROR", "Proximity", "Invalid start threshold=$thresholdCm cm")
return
}
if (started && this.thresholdCm == thresholdCm) return
if (started) stop()
this.thresholdCm = thresholdCm
cleanup()
if (retired.isNotEmpty()) {
LogManager.addLog("ERROR", "Proximity", "Sensor start blocked by previous unregister; owner retained")
@@ -89,11 +96,11 @@ class ProximityManager(private val context: Context) {
badReading = true; return
}
badReading = false
val nearNow = value < range
val nearNow = value < range && value < thresholdCm
if (_near.value != nearNow) {
_near.value = nearNow
LogManager.addLog("INFO", "Proximity",
"${if (nearNow) "NEAR" else "FAR"} v=${event.values[0]} max=${event.sensor.maximumRange}")
"${if (nearNow) "NEAR" else "FAR"} v=$value max=$range threshold=$thresholdCm")
}
}
@@ -112,7 +119,7 @@ class ProximityManager(private val context: Context) {
LogManager.addLog("WARN", "Proximity", "sensor registration failed; proximity remains unknown")
return
}
LogManager.addLog("INFO", "Proximity", "started (maxRange=${s.maximumRange})")
LogManager.addLog("INFO", "Proximity", "started maxRange=${s.maximumRange} threshold=$thresholdCm cm")
}
/** Сначала запретить события регистрации и сбросить показание, затем снять слушатель; только main. */
@@ -81,7 +81,7 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
) {
if (!connecting) {
QrScannerPreview(
modifier = Modifier.fillMaxWidth().height(180.dp),
modifier = Modifier.fillMaxWidth().aspectRatio(3f / 4f),
onResult = { link = it }
)
}
@@ -20,8 +20,10 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.unit.dp
@@ -78,7 +80,8 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
}
}
Box(modifier, contentAlignment = Alignment.Center) {
// CameraX масштабирует TextureView; ограничиваем видео областью сканера, чтобы оно не заходило под поле ссылки.
Box(modifier.clipToBounds(), contentAlignment = Alignment.Center) {
if (!hasPermission) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(stringResource(R.string.camera_permission_required))
@@ -98,7 +101,10 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
}) { Text(stringResource(if (permanentDenial) R.string.settings else R.string.grant)) }
}
} else {
val previewView = remember { PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } }
val previewView = remember { PreviewView(context).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FIT_CENTER // Вписываем весь кадр с сохранением пропорций.
} }
DisposableEffect(lifecycleOwner, previewView) {
cameraError = null
val session = QrScannerSession(context, lifecycleOwner, previewView, onScan = { values ->
@@ -112,7 +118,10 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
session.start()
onDispose { session.close() }
}
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize())
AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize().onSizeChanged {
LogManager.addLog("DEBUG", "QR", "Preview viewport=${it.width}x${it.height} scale=${previewView.scaleType} " +
"clipped=true mode=${previewView.implementationMode}")
})
Canvas(Modifier.fillMaxSize()) {
val side = size.minDimension * 0.75f
val left = (size.width - side) / 2
@@ -1236,7 +1236,7 @@ private fun RadioSettingsTab() {
/* ── Audio Settings Tab ── */
private data class AudioSettingsSnapshot(val voice: VoiceSettings, val bitrate: CallVideoBitrateRange,
val jitterMs: Int, val sound: Boolean)
val jitterMs: Int, val sound: Boolean, val proximity: Pair<Boolean, Float>)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -1249,7 +1249,8 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
loadFailed = false
try {
snapshot = withContext(Dispatchers.IO) { AudioSettingsSnapshot(provider.getVoiceSettings(), provider.getCallVideoBitrateRange(),
provider.getInt("chat.call_jitter_max_reserve_ms").coerceIn(500, 2000), provider.getSoundEnabled()) }
provider.getInt("chat.call_jitter_max_reserve_ms").coerceIn(500, 2000), provider.getSoundEnabled(),
provider.proximitySettings.first()) }
} catch (e: CancellationException) { throw e }
catch (e: Exception) { loadFailed = true; provider.changes.reportFailure("load audio settings", e) }
}
@@ -1275,6 +1276,10 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
/* Notification sound */
var soundEnabled by remember { mutableStateOf(settingsSnapshot.sound) }
val proximitySettings by remember { provider.changes.observe("proximity settings", provider.proximitySettings) }
.collectAsState(initial = settingsSnapshot.proximity)
var proximityThreshold by remember { mutableFloatStateOf(proximitySettings.second) }
LaunchedEffect(proximitySettings.second) { proximityThreshold = proximitySettings.second }
LaunchedEffect(micTesting) {
try { while (micTesting && vm.audioRecorder.isTesting) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) } }
@@ -1338,6 +1343,31 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.use_proximity_sensor), modifier = Modifier.weight(1f))
Switch(checked = proximitySettings.first, onCheckedChange = { enabled ->
provider.changes.submit("proximity enabled") { provider.setProximityEnabled(enabled) }
})
}
Text(stringResource(R.string.proximity_threshold, proximityThreshold))
Slider(
value = proximityThreshold,
onValueChange = { proximityThreshold = (it * 10).roundToInt() / 10f },
valueRange = 0.1f..10f,
steps = 98,
enabled = proximitySettings.first,
onValueChangeFinished = {
val threshold = proximityThreshold
provider.changes.submit("proximity threshold") {
try { provider.setProximityThreshold(threshold) }
finally { proximityThreshold = proximitySettings.second }
}
}
)
Text(stringResource(R.string.proximity_sensor_hint), style = MaterialTheme.typography.bodySmall)
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Входящие звонки поверх блокировки ── */
Text(stringResource(R.string.incoming_calls), style = MaterialTheme.typography.titleMedium)
val phoneOptions by remember { provider.changes.observe("incoming call preferences", provider.incomingCallOptions) }
@@ -355,6 +355,9 @@
<string name="codec_high">"Высокое (64 кбит/с)"</string>
<string name="notification_sound">"Звук уведомлений"</string>
<string name="incoming_message_sound">"Звук при получении сообщения"</string>
<string name="use_proximity_sensor">Использовать датчик приближения</string>
<string name="proximity_threshold">Порог приближения: %1$.1f см</string>
<string name="proximity_sensor_hint">Блокирует экран звонка у уха. Порог действует только для датчиков, выдающих расстояние; для датчиков «близко/далеко» расстояние срабатывания задаёт устройство.</string>
<string name="show_over_lock_screen">"Показывать поверх экрана блокировки"</string>
<string name="always_allowed">"Всегда разрешено"</string>
<string name="allowed">"Разрешено"</string>
@@ -356,6 +356,9 @@
<string name="codec_high">"High (64 kbps)"</string>
<string name="notification_sound">"Notification sound"</string>
<string name="incoming_message_sound">"Play sound on incoming message"</string>
<string name="use_proximity_sensor">Use proximity sensor</string>
<string name="proximity_threshold">Proximity threshold: %1$.1f cm</string>
<string name="proximity_sensor_hint">Locks the call screen near your ear. The threshold only affects sensors that report distance; for sensors reporting only near/far, the detection distance is set by the device.</string>
<string name="show_over_lock_screen">"Show over lock screen"</string>
<string name="always_allowed">"Always allowed"</string>
<string name="allowed">"Allowed"</string>
@@ -9,6 +9,7 @@ kotlin {
"com/utun/chat/data/RouterHostEnvironment.kt", "com/utun/chat/data/RadioHostEnvironment.kt",
"com/utun/chat/data/CallServiceHost.kt", "com/utun/chat/data/CallService.kt", "com/utun/chat/data/CallController.kt",
"com/utun/chat/data/CallState.kt", "com/utun/chat/data/CallAudioRouter.kt", "com/utun/chat/data/CoreActivity.kt",
"com/utun/chat/data/SettingsOperations.kt",
"com/utun/chat/data/RadioAutoSettings.kt", "com/utun/chat/data/StreamServiceHost.kt",
"com/utun/chat/data/StreamService.kt", "com/utun/chat/data/StreamController.kt", "com/utun/chat/data/StreamTxStats.kt")
}
@@ -88,7 +88,7 @@ object TelecomCallManager {
class ProximityManager(context: Context) {
val near = MutableStateFlow<Boolean?>(null)
fun start() {}
fun start(thresholdCm: Float = 5f) {}
fun stop() { near.value = null }
}
object RadioCallController { fun busy() = false }
@@ -197,6 +197,8 @@ object NativeLib {
/** Управляемая подготовка конфига: тест удерживает реальный worker, считает перекрытия и затем дожидается его завершения. */
class HostConfigProvider {
val changes = SettingsOperations()
val proximitySettings = MutableStateFlow(true to 5f)
val radioCompressorSettings = MutableStateFlow(Any())
var failStreamSave = false
fun saveStreamCaptureSettings(config: Any?, audio: Boolean?): Deferred<Unit> = CompletableDeferred<Unit>().also {
@@ -69,6 +69,49 @@ class ProximityManagerLifecycleTest {
assertNull(proximity.near.value)
}
@Test fun thresholdDeterminesNearBelowSensorMaximum() {
proximity.start(2f)
for ((distance, near) in listOf(1.9f to true, 2f to false, 3f to false, 0f to true, 5f to false)) {
manager.deliver(0, distance)
Looper.getMainLooper().dispatchAll()
assertEquals(near, proximity.near.value)
}
}
@Test fun thresholdChangeWaitsForFreshSampleAndRejectsOldCallback() {
proximity.start(5f)
manager.deliver(0, 3f); Looper.getMainLooper().dispatchAll()
assertEquals(true, proximity.near.value)
proximity.start(2f)
assertNull(proximity.near.value)
assertEquals(2, manager.registerAttempts)
assertEquals(1, manager.active.size)
manager.deliver(0, 0f); Looper.getMainLooper().dispatchAll()
assertNull(proximity.near.value)
manager.deliver(1, 3f); Looper.getMainLooper().dispatchAll()
assertEquals(false, proximity.near.value)
}
@Test fun binarySensorKeepsFarAtMaximumEvenWithLargerThreshold() {
manager.sensor = android.hardware.Sensor(1f)
proximity = ProximityManager(context)
for (threshold in listOf(0.1f, 5f, 10f)) {
proximity.start(threshold)
val index = manager.registrations.lastIndex
manager.deliver(index, 0f); Looper.getMainLooper().dispatchAll()
assertEquals(true, proximity.near.value)
manager.deliver(index, 1f); Looper.getMainLooper().dispatchAll()
assertEquals(false, proximity.near.value)
}
}
@Test fun invalidThresholdIsLoggedWithoutRegistering() {
for (threshold in listOf(0f, -1f, Float.NaN, Float.POSITIVE_INFINITY)) proximity.start(threshold)
assertEquals(0, manager.registerAttempts)
assertNull(proximity.near.value)
assertEquals(4, LogManager.entries.count { it.level == "ERROR" && "Invalid start threshold" in it.message })
}
@Test fun missingSensorServiceRemainsUnknown() {
context.sensorManager = null
proximity = ProximityManager(context)