chatgui/chatgui-android: уведомления со звуком, флаги и прочие улучшения UI

This commit is contained in:
evgeny committed 2026-09-10 00:23:14 +03:00
1 parent 6c857ceab8
commit 0f308fdbd7
20 files changed
+292 -32

No files matched your search

@@ -5,6 +5,7 @@ import android.util.Log
import com.utun.chat.data.LogManager
import com.utun.chat.data.ConfigProvider
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.ChatEventCallback
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -29,6 +30,10 @@ class ChatApplication : Application() {
logManager = LogManager
configProvider = ConfigProvider(this)
/* Notification sound state: global toggle + per-group mute */
NotificationSound.enabled = configProvider.getSoundEnabled()
NotificationSound.mutedChannels = configProvider.getMutedChannels()
/* One-time event callback registration — survives ViewModel recreation */
NativeLib.setEventCallback(AppEventHandler)
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -48,6 +49,8 @@ class ConfigProvider(private val context: Context) {
private val firstLaunchDone = booleanPreferencesKey("first_launch_done")
private val connectionModeKey = stringPreferencesKey("connection_mode")
private val serviceEnabledKey = booleanPreferencesKey("service_enabled")
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private fun keysFilePath(): String {
val dir = runBlocking { context.dataStore.data.first()[dbPath] } ?: context.filesDir.absolutePath
@@ -220,6 +223,27 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[serviceEnabledKey] = enabled }
}
/* ── Notification sound (global toggle + per-group mute) ── */
fun getSoundEnabled(): Boolean = runBlocking {
context.dataStore.data.first()[soundEnabledKey] ?: true
}
suspend fun setSoundEnabled(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[soundEnabledKey] = enabled }
}
fun getMutedChannels(): Set<String> = runBlocking {
context.dataStore.data.first()[mutedChannelsKey] ?: emptySet()
}
suspend fun setChannelMuted(channelId: String, muted: Boolean) {
context.dataStore.edit { prefs ->
val cur = prefs[mutedChannelsKey] ?: emptySet()
prefs[mutedChannelsKey] = if (muted) cur + channelId else cur - channelId
}
}
/* ── Server entries ── */
suspend fun detectInterfaces(saved: List<ServerEntry> = emptyList()): List<ServerEntry> {
@@ -0,0 +1,38 @@
package com.utun.chat.data
import android.media.AudioAttributes
import android.media.MediaPlayer
import com.utun.chat.ChatApplication
import com.utun.chat.R
/* Уведомительный звук входящих сообщений (msg_incoming.mp3).
Глобальный тумблер (enabled) + per-group mute (mutedChannels). */
object NotificationSound {
@Volatile var enabled: Boolean = true
@Volatile var mutedChannels: Set<String> = emptySet()
fun isMuted(channelId: String): Boolean = channelId in mutedChannels
fun setMuted(channelId: String, muted: Boolean) {
mutedChannels = if (muted) mutedChannels + channelId else mutedChannels - channelId
}
fun play(channelId: String) {
if (!enabled || isMuted(channelId)) return
try {
val mp = MediaPlayer.create(ChatApplication.instance, R.raw.msg_incoming)
?: return
mp.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
)
mp.setOnCompletionListener { it.release() }
mp.start()
LogManager.addLog("DEBUG", "Sound", "notification played ch=$channelId")
} catch (e: Exception) {
LogManager.addLog("WARN", "Sound", "play failed: ${e.message}")
}
}
}
@@ -23,6 +23,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Channel
import com.utun.chat.data.NotificationSound
import com.utun.chat.ChatApplication
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -39,6 +42,8 @@ fun ChannelListScreen(
onRefresh: () -> Unit = {}
) {
var showCreateDialog by remember { mutableStateOf(false) }
var menuChannelId by remember { mutableStateOf<String?>(null) }
var optionsChannel by remember { mutableStateOf<Channel?>(null) }
LaunchedEffect(Unit) { onRefresh() }
@@ -88,9 +93,22 @@ fun ChannelListScreen(
Box(Modifier.width(4.dp).fillMaxHeight().background(barColor))
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
IconButton(onClick = { onInviteClick(ch) }, modifier = Modifier.size(44.dp)) {
IconButton(onClick = { menuChannelId = ch.id }, modifier = Modifier.size(44.dp)) {
Text("#", fontSize = 22.sp)
}
DropdownMenu(
expanded = menuChannelId == ch.id,
onDismissRequest = { menuChannelId = null }
) {
DropdownMenuItem(
text = { Text("Invite link") },
onClick = { menuChannelId = null; onInviteClick(ch) }
)
DropdownMenuItem(
text = { Text("Options") },
onClick = { menuChannelId = null; optionsChannel = ch }
)
}
if (badgeText.isNotEmpty()) {
val badgeSize = 18.dp
Box(
@@ -137,6 +155,44 @@ fun ChannelListScreen(
}
)
}
optionsChannel?.let { ch ->
ChannelOptionsDialog(
channel = ch,
onDismiss = { optionsChannel = null }
)
}
}
@Composable
fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
var soundOn by remember(channel.id) { mutableStateOf(!NotificationSound.isMuted(channel.id)) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(channel.name) },
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Sound", modifier = Modifier.weight(1f))
Switch(
checked = soundOn,
onCheckedChange = {
soundOn = it
NotificationSound.setMuted(channel.id, !it)
scope.launch {
ChatApplication.instance.configProvider.setChannelMuted(channel.id, !it)
}
}
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
}
)
}
@Composable
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.utun.chat.ChatApplication
import com.utun.chat.data.NativeLib
import com.utun.chat.data.NotificationSound
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
@@ -447,6 +448,9 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
var micTesting by remember { mutableStateOf(false) }
var micLevel by remember { mutableStateOf(0f) }
/* Notification sound */
var soundEnabled by remember { mutableStateOf(provider.getSoundEnabled()) }
LaunchedEffect(micTesting) {
if (micTesting) {
while (true) { micLevel = NativeLib.voiceGetPeakLevel(); delay(25) }
@@ -486,6 +490,19 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
/* ── Notification sound ── */
Text("Notification sound", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Play sound on incoming message", modifier = Modifier.weight(1f))
Switch(checked = soundEnabled, onCheckedChange = {
soundEnabled = it
NotificationSound.enabled = it
scope.launch { provider.setSoundEnabled(it) }
})
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Microphone test ── */
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@@ -203,6 +203,11 @@ class ChatViewModel : ViewModel() {
val chLen = data[0].toInt() and 0xFF
if (data.size >= 1 + chLen) {
val chId = String(data, 1, chLen)
if (data.size >= 1 + chLen + 8) {
val author = java.nio.ByteBuffer.wrap(data, 1 + chLen, 8)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
if (author != NativeLib.getMyNodeId()) NotificationSound.play(chId)
}
val cur = _currentChannel.value
if (cur != null && cur.id == chId) refreshMessages(chId)
refreshChannels()