Improve Android proximity settings and channel scanner layout
This commit is contained in:
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. */
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
+12
-3
@@ -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")
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 }
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+43
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user