Add Android boot startup, lazy settings, emoji and DM calls

Add battery optimization controls and configurable boot startup. Keep message sounds in the application and render general settings lazily to avoid ANRs. Add animated emoji controls and live BGP availability for direct-message calls on Android and desktop.
This commit is contained in:
evgeny committed 2026-10-02 17:18:24 +03:00
1 parent 66afcc8625
commit 23368a5bbe
40 files changed
+1165 -180

No files matched your search

@@ -16,6 +16,8 @@ import com.utun.chat.data.ChatEventCallback
import com.utun.chat.data.CallController
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import java.nio.ByteBuffer
import java.nio.ByteOrder
object AppEventHandler : ChatEventCallback {
private val mainHandler = Handler(Looper.getMainLooper())
@@ -23,12 +25,29 @@ object AppEventHandler : ChatEventCallback {
val events: SharedFlow<Pair<Int, ByteArray?>> = _events
override fun onEvent(type: Int, data: ByteArray?) {
if (type == 1 || type == 32) playMessageSound(type, data)
if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) {
mainHandler.post { CallController.onEvent(type, data) }
}
Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}")
_events.tryEmit(type to data)
}
// Звук живёт в приложении: работает без Activity и не повторяется при replay событий в UI.
private fun playMessageSound(type: Int, data: ByteArray?) {
val length = data?.firstOrNull()?.toInt()?.and(0xFF) ?: 0
if (data == null || length == 0 || data.size < 1 + length + 8) {
LogManager.addLog("WARN", "Sound", "Invalid message event: type=$type length=$length size=${data?.size}")
return
}
val author = ByteBuffer.wrap(data, 1 + length, 8).order(ByteOrder.LITTLE_ENDIAN).long
// JNI-callback выполняется в потоке ядра; читаем ID до передачи на main, пока ядро существует.
if (author == NativeLib.getMyNodeId()) return
val id = String(data, 1, length, Charsets.UTF_8)
val soundId = if (type == 32) "dm:$id" else id
LogManager.addLog("DEBUG", "Sound", "Incoming message sound dispatched: type=$type target=$soundId author=$author")
mainHandler.post { NotificationSound.play(soundId) }
}
}
class ChatApplication : Application() {
@@ -12,7 +12,9 @@ import com.utun.chat.data.MicrophonePermission
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.WindowManager
import com.utun.chat.data.CallController
import com.utun.chat.data.CallPhase
@@ -57,6 +59,18 @@ class MainActivity : AppCompatActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, VideoRecord, PhotoRecord, Call, Dm }
private var overLock = false
private var inputScreen = "setup"
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
val started = SystemClock.uptimeMillis()
try {
return super.dispatchTouchEvent(event)
} finally {
val elapsed = SystemClock.uptimeMillis() - started
if (elapsed >= 100) LogManager.addLog("WARN", "UI_INPUT",
"Slow touch: screen=$inputScreen action=${event.actionMasked} x=${event.x} y=${event.y} elapsedMs=$elapsed")
}
}
private var microphoneAction: (() -> Unit)? = null
private var microphoneReason = ""
private val overlayPermissionRequester: () -> Unit = {
@@ -184,6 +198,8 @@ class MainActivity : AppCompatActivity() {
// После смерти процесса ViewModel уже новый; сохранённый экран может потерять свою беседу или звонок.
LaunchedEffect(screen) {
inputScreen = screen.name
LogManager.addLog("INFO", "MAIN", "UI screen=$screen")
val unavailable = when (screen) {
Screen.Chat, Screen.MemberList, Screen.PhotoRecord -> vm.currentChannel.value == null
Screen.Dm -> vm.currentDm.value == null
@@ -0,0 +1,30 @@
package com.utun.chat.data
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.PowerManager
import android.provider.Settings
/* Состоянием исключения владеет Android; отдельную настройку приложения не сохраняем. */
object BatteryOptimizationPermission {
fun granted(context: Context): Boolean =
context.getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(context.packageName)
fun request(context: Context, source: String): Boolean {
val actions = listOf(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
for (action in actions) {
val intent = Intent(action)
if (action == Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) intent.data = Uri.parse("package:${context.packageName}")
try {
context.startActivity(intent)
LogManager.addLog("INFO", "PERMISSION", "Battery optimization request: source=$source action=$action package=${context.packageName}")
return true
} catch (e: Exception) {
LogManager.addLog("WARN", "PERMISSION", "Cannot open battery optimization screen: source=$source action=$action error=$e")
}
}
LogManager.addLog("ERROR", "PERMISSION", "Battery optimization request failed: source=$source package=${context.packageName}")
return false
}
}
@@ -57,7 +57,7 @@ data class Channel(val id: String, val name: String, val lastMsgAt: Long = 0, va
val peersOnline: Int = 0)
data class DmConversation(val convId: String, val peerNodeId: Long = 0, val peerName: String = "",
val lastTs: Long = 0) {
val lastTs: Long = 0, val online: Boolean = false) {
val displayName: String get() = peerName.ifBlank { "0x%016x".format(peerNodeId) }
}
data class Message(val id: Long, val author: String, val text: String, val ts: Long,
@@ -52,6 +52,7 @@ 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 startOnBootKey = booleanPreferencesKey("start_on_boot")
private val soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
@@ -267,6 +268,30 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[serviceEnabledKey] = enabled }
}
val startOnBootFlow: Flow<Boolean> = context.dataStore.data.map { it[startOnBootKey] ?: true }
suspend fun setStartOnBoot(enabled: Boolean) {
context.dataStore.edit { prefs -> prefs[startOnBootKey] = enabled }
LogManager.addLog("INFO", "CONFIG", "Start on boot setting saved: enabled=$enabled")
}
// Одним снимком проверяем все условия автозапуска.
suspend fun shouldStartOnBoot(): Boolean {
val prefs = context.dataStore.data.first()
val setupDone = prefs[firstLaunchDone] ?: false
val startOnBoot = prefs[startOnBootKey] ?: true
val serviceEnabled = prefs[serviceEnabledKey] ?: true
val reason = when {
!setupDone -> "setup_incomplete"
!startOnBoot -> "autostart_disabled"
!serviceEnabled -> "service_disabled"
else -> "start"
}
LogManager.addLog("INFO", "BOOT",
"Boot decision: setupDone=$setupDone startOnBoot=$startOnBoot serviceEnabled=$serviceEnabled reason=$reason")
return setupDone && startOnBoot && serviceEnabled
}
/* ── Notification sound (global toggle + per-group mute) ── */
fun getSoundEnabled(): Boolean = runBlocking {
@@ -185,6 +185,7 @@ object NativeLib {
fun dmStart(nodeId: Long, sourceChId: String, peerName: String) { nativeDmStart(nodeId, sourceChId, peerName) }
fun dmSend(convId: String, text: String) { nativeDmSend(convId, text) }
fun dmRequestConversations() { nativeDmRequestConversations() }
fun dmRequestCallRoute(convId: String) { nativeDmRequestCallRoute(convId) }
fun dmRequestMessages(convId: String) { nativeDmRequestMessages(convId) }
fun dmSendFile(convId: String, path: String, name: String): Boolean = nativeDmSendFile(convId, path, name)
@@ -298,6 +299,7 @@ object NativeLib {
private external fun nativeDmStart(nodeId: Long, sourceChId: String, peerName: String)
private external fun nativeDmSend(convId: String, text: String)
private external fun nativeDmRequestConversations()
private external fun nativeDmRequestCallRoute(convId: String)
private external fun nativeDmRequestMessages(convId: String)
private external fun nativeDmSendFile(convId: String, path: String, name: String): Boolean
}
@@ -0,0 +1,36 @@
package com.utun.chat.headless
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.core.content.ContextCompat
import com.utun.chat.ChatApplication
import com.utun.chat.data.LogManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/** BOOT_COMPLETED приходит после разблокировки, когда настройки и БД уже доступны. */
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != Intent.ACTION_BOOT_COMPLETED) {
LogManager.addLog("WARN", "BOOT", "Unexpected boot receiver action=${intent.action}")
return
}
LogManager.addLog("INFO", "BOOT", "Boot completed broadcast received")
val pending = goAsync()
val appContext = context.applicationContext
CoroutineScope(Dispatchers.IO).launch {
try {
if (!ChatApplication.instance.configProvider.shouldStartOnBoot()) return@launch
val serviceIntent = Intent(appContext, HeadlessService::class.java).setAction(Intent.ACTION_BOOT_COMPLETED)
ContextCompat.startForegroundService(appContext, serviceIntent)
LogManager.addLog("INFO", "BOOT", "Boot service start requested: service=${HeadlessService::class.java.name}")
} catch (e: Exception) {
LogManager.addLog("ERROR", "BOOT", "Boot service start failed: $e")
} finally {
pending.finish()
}
}
}
}
@@ -66,7 +66,11 @@ class HeadlessService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val source = if (intent == null) "sticky_restart" else intent.action ?: "manual"
LogManager.addLog("INFO", "SRV", "Service start command: source=$source startId=$startId flags=$flags ready=${NativeLib.isReady()}")
return START_STICKY
}
private fun notification(): android.app.Notification {
val intent = Intent(this, MainActivity::class.java)
@@ -0,0 +1,130 @@
package com.utun.chat.ui.components
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.icu.text.BreakIterator
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.TextUnit
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import com.airbnb.lottie.compose.LottieAnimation
import com.airbnb.lottie.compose.LottieCompositionSpec
import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.rememberLottieComposition
import com.airbnb.lottie.compose.rememberLottieAnimatable
import com.utun.chat.data.LogManager
import java.util.Locale
import kotlin.math.ceil
private const val EMOJI_PLAYBACK_MIN_MS = 4000f
// Соответствие Unicode и TGS такое же, как в desktop src/emoji.cpp.
private val animatedEmojiAssets = linkedMapOf(
"✨" to "sparkles_emoji.tgs",
"🏳️" to "white_flag_emoji.tgs",
"👋" to "greeting.tgs",
"✋" to "stop.tgs",
"🤖" to "robot.tgs"
)
val animatedEmojiCharacters: List<String> = animatedEmojiAssets.keys.toList()
private val normalizedEmojiAssets = animatedEmojiAssets.mapKeys { it.key.removeSuffix("\uFE0F") }
private fun animationAsset(emoji: String): String? = normalizedEmojiAssets[emoji.removeSuffix("\uFE0F")]
/** Загрузка и кеширование TGS выполняются Lottie вне UI-потока; фон останавливает воспроизведение. */
@Composable
fun AnimatedEmoji(emoji: String, fontSize: TextUnit, modifier: Modifier = Modifier, color: Color = Color.Unspecified,
loop: Boolean = false) {
val asset = animationAsset(emoji)
if (asset == null) {
Text(emoji, fontSize = fontSize, color = color, modifier = modifier)
return
}
val result = rememberLottieComposition(LottieCompositionSpec.Asset(asset))
val composition = result.value
var replay by remember(asset) { mutableIntStateOf(0) }
LaunchedEffect(asset, composition, result.error) {
if (result.error != null) LogManager.addLog("ERROR", "Emoji", "animation load failed: asset=$asset error=${result.error}")
else if (composition != null) {
LogManager.addLog("DEBUG", "Emoji", "animation ready: asset=$asset frames=${composition.durationFrames} fps=${composition.frameRate}")
composition.warnings.forEach { LogManager.addLog("WARN", "Emoji", "animation warning: asset=$asset $it") }
}
}
val lifecycle = LocalLifecycleOwner.current.lifecycle
var playing by remember(lifecycle) { mutableStateOf(lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) }
DisposableEffect(lifecycle, asset) {
val observer = LifecycleEventObserver { _, _ ->
val resumed = lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
if (playing != resumed) LogManager.addLog("DEBUG", "Emoji", "animation visibility: asset=$asset resumed=$resumed")
playing = resumed
}
lifecycle.addObserver(observer)
onDispose { lifecycle.removeObserver(observer) }
}
val replayModifier = if (loop) Modifier else Modifier.clickable(enabled = composition != null, onClickLabel = stringResource(R.string.replay)) {
replay++
LogManager.addLog("DEBUG", "Emoji", "animation replay requested: asset=$asset replay=$replay")
}
Box(modifier.then(replayModifier).semantics { contentDescription = emoji }, contentAlignment = Alignment.Center) {
if (composition == null) Text(emoji, fontSize = fontSize, color = color)
else key(asset, replay, loop) {
val animation = rememberLottieAnimatable()
// Останавливаемся только на границе цикла, после минимум четырёх секунд воспроизведения.
val iterations = if (loop) LottieConstants.IterateForever
else ceil(EMOJI_PLAYBACK_MIN_MS / composition.duration.coerceAtLeast(1f)).toInt().coerceAtLeast(1)
LaunchedEffect(composition, playing) {
if (!playing || (!loop && animation.composition == composition && animation.isAtEnd)) return@LaunchedEffect
LogManager.addLog("DEBUG", "Emoji",
"animation run: asset=$asset replay=$replay cycles=$iterations cycle_ms=${composition.duration} " +
"iteration=${animation.iteration} progress=${animation.progress}")
animation.animate(composition, iterations = iterations, initialProgress = animation.progress,
ignoreSystemAnimationsDisabled = true)
LogManager.addLog("DEBUG", "Emoji",
"animation finished: asset=$asset replay=$replay cycles=${animation.iteration} progress=${animation.progress}")
}
LottieAnimation(composition, progress = { animation.progress }, modifier = Modifier.fillMaxSize())
}
}
}
/** Полные графемы сохраняют модификаторы кожи и ZWJ-последовательности обычными Unicode-смайликами. */
@Composable
fun EmojiText(text: String, fontSize: TextUnit, color: Color, modifier: Modifier = Modifier) {
val annotated = remember(text) {
buildAnnotatedString {
val iterator = BreakIterator.getCharacterInstance(Locale.US)
iterator.setText(text)
var start = iterator.first()
var end = iterator.next()
while (end != BreakIterator.DONE) {
val cluster = text.substring(start, end)
val asset = animationAsset(cluster)
if (asset == null) append(cluster) else appendInlineContent(asset, cluster)
start = end
end = iterator.next()
}
}
}
val inline = remember(fontSize, color) {
animatedEmojiAssets.values.associateWith {
InlineTextContent(Placeholder(fontSize * 1.3f, fontSize * 1.3f, PlaceholderVerticalAlign.Center)) { emoji ->
AnimatedEmoji(emoji, fontSize, Modifier.fillMaxSize(), color)
}
}
}
Text(annotated, inlineContent = inline, fontSize = fontSize, color = color, modifier = modifier)
}
@@ -0,0 +1,68 @@
package com.utun.chat.ui.components
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.LifecycleResumeEffect
import com.utun.chat.data.BatteryOptimizationPermission
import com.utun.chat.data.LogManager
@Composable
fun BatteryOptimizationSetting(source: String, enabled: Boolean = true) {
val context = LocalContext.current
var granted by remember(context) { mutableStateOf(BatteryOptimizationPermission.granted(context)) }
var lastLoggedState by remember(context) { mutableStateOf<Boolean?>(null) }
var requestPending by rememberSaveable { mutableStateOf(false) }
var error by rememberSaveable { mutableStateOf<Int?>(null) }
// Результат системного диалога определяем повторной проверкой, а не resultCode.
LifecycleResumeEffect(context, source) {
granted = BatteryOptimizationPermission.granted(context)
if (lastLoggedState != granted) {
LogManager.addLog("INFO", "PERMISSION", "Battery optimization exemption: source=$source granted=$granted")
lastLoggedState = granted
}
if (requestPending) {
LogManager.addLog(if (granted) "INFO" else "WARN", "PERMISSION", "Battery optimization request completed: source=$source granted=$granted")
requestPending = false
}
if (granted) error = null
onPauseOrDispose { }
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.background_unrestricted), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.battery_optimization_hint),
style = MaterialTheme.typography.bodySmall)
Text(stringResource(if (granted) R.string.allowed else R.string.not_allowed), style = MaterialTheme.typography.bodyMedium,
color = if (granted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant)
if (!granted) {
Button(enabled = enabled && !requestPending, onClick = {
error = null
granted = BatteryOptimizationPermission.granted(context)
if (!granted) {
requestPending = true
if (!BatteryOptimizationPermission.request(context, source)) {
requestPending = false
error = R.string.battery_settings_failed
}
}
}) { Text(stringResource(R.string.grant)) }
}
error?.let { Text(stringResource(it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import com.utun.chat.data.ConversationTarget
import com.utun.chat.data.LogManager
@@ -32,7 +33,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
val context = LocalContext.current
val scope = rememberCoroutineScope()
val activeTarget by rememberUpdatedState(target)
var text by rememberSaveable(target.nativeId) { mutableStateOf("") }
var text by rememberSaveable(target.nativeId, stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) }
var selected by remember(target) { mutableStateOf<SelectedAttachment?>(null) }
var pickerTarget by remember { mutableStateOf<ConversationTarget?>(null) }
var preparing by remember(target) { mutableStateOf(false) }
@@ -78,7 +79,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
if (preparing || preparation?.jobs?.isNotEmpty() == true) Text(stringResource(R.string.media_preparing), modifier = Modifier.padding(8.dp))
else if (preparation?.failed == true) Text(stringResource(R.string.attachment_prepare_failed), color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(8.dp))
InputBar(
key(target) { InputBar(
text = text, onTextChange = { text = it }, enabled = !preparing,
onSend = {
val attachment = selected
@@ -104,7 +105,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
preparing = false
}
}
if (text.isNotBlank()) { onSendText(text); text = "" }
if (text.text.isNotBlank()) { onSendText(text.text); text = TextFieldValue() }
},
isRecording = recording, isRecordingLocked = locked, recordingDurationMs = duration, recordingLevel = level,
onPttPressed = { viewModel.startVoiceRecording(target) },
@@ -115,7 +116,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
onClearAttachment = { selected = null }, isVideoMode = videoMode,
onToggleMode = { viewModel.toggleVideoMode() }, onVideoRecordOpen = onRecordVideo,
modifier = Modifier.navigationBarsPadding()
)
) }
}
if (menu) ModalBottomSheet(onDismissRequest = { menu = false }) {
ListItem(headlineContent = { Text(stringResource(R.string.select_file)) }, modifier = Modifier.clickable {
@@ -0,0 +1,65 @@
package com.utun.chat.ui.components
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.LogManager
private data class EmojiCategory(val nameRes: Int, val emoji: List<String>, val animated: Boolean = false)
private val emojiCategories = listOf(
EmojiCategory(R.string.emoji_faces, ("😀 😃 😄 😁 😆 😅 😂 🤣 😊 🙂 🙃 😉 😍 🥰 😘 😋 😛 😜 🤪 🤗 🤭 🤫 🤔 😎 " +
"🥳 😏 😒 😔 😢 😭 😤 😠 😡 🤯 😱 😨 😰 😴 🤤 🤢 🤮 🤧 🥺 😇 🤡 💩 👻 🤖").split(" ")),
EmojiCategory(R.string.emoji_animations, animatedEmojiCharacters, animated = true),
EmojiCategory(R.string.emoji_gestures, ("👍 👎 👏 🙌 👐 🤲 🙏 🤝 ✌️ 🤞 🤟 🤘 👌 🤌 👈 👉 " +
"👆 👇 ☝️ ✋ 👋 🤚 🖐️ ✊ 👊 🤛 🤜 💪 🫶 👀 🧠 🫂").split(" ")),
EmojiCategory(R.string.emoji_hearts, ("❤️ 🧡 💛 💚 💙 💜 🖤 🤍 🤎 💔 ❤️‍🔥 ❤️‍🩹 💕 💞 💓 💗 " +
"💖 💘 💝 💟 ❣️ 💋 💯 💢 💥 💫 💦 💤 ✨ ⭐ 🌟 🔥").split(" ")),
EmojiCategory(R.string.emoji_nature, ("🐶 🐱 🐭 🐹 🐰 🦊 🐻 🐼 🐨 🐯 🦁 🐮 🐷 🐸 🐵 🐔 🐧 🐦 🦆 " +
"🦉 🦋 🐝 🐢 🐬 🐳 🐟 🌸 🌹 🌻 🌲 🌴 🍀 ☀️ 🌙 🌈 ☁️ 🌧️ ❄️").split(" ")),
EmojiCategory(R.string.emoji_food, ("🍏 🍎 🍐 🍊 🍋 🍌 🍉 🍇 🍓 🫐 🍒 🍑 🍍 🥝 🥑 🍅 🥕 🌽 🥐 🍞 🧀 🥚 " +
"🍳 🥞 🍔 🍟 🍕 🌭 🌮 🍣 🍜 🍿 🍩 🍪 🎂 🍫 🍬 🍦 ☕ 🍵 🥤 🍺 🥂").split(" ")),
EmojiCategory(R.string.emoji_other, ("🎉 🎊 🎁 🎈 🎀 🏆 🥇 ⚽ 🏀 🎮 🎲 🎵 🎶 🎸 🎤 🎧 📷 🎬 💡 📚 ✏️ 💼 " +
"💰 💎 🔑 🔒 🛠️ ⚙️ 📱 💻 🚗 ✈️ 🚀 🏠 🌍 ✅ ❌ ❓ ❗ ⚠️ ♻️ 🏳️ 🏁").split(" "))
)
/** Панель остаётся открытой для вставки нескольких смайликов подряд. */
@Composable
fun EmojiPickerPanel(onSelect: (String) -> Unit) {
var categoryIndex by remember { mutableStateOf(0) }
val gridState = rememberLazyGridState()
LaunchedEffect(categoryIndex) { gridState.scrollToItem(0) }
Column(Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
emojiCategories.forEachIndexed { index, category ->
FilterChip(selected = categoryIndex == index, onClick = {
categoryIndex = index
LogManager.addLog("DEBUG", "Composer", "emoji category selected: ${category.nameRes}")
}, label = { Text(stringResource(category.nameRes)) })
}
}
LazyVerticalGrid(columns = GridCells.Adaptive(48.dp), state = gridState, modifier = Modifier.fillMaxWidth().height(200.dp)) {
items(emojiCategories[categoryIndex].emoji, key = { it }) { emoji ->
val description = stringResource(R.string.insert_emoji, emoji)
TextButton(onClick = { onSelect(emoji) }, contentPadding = PaddingValues(0.dp),
modifier = Modifier.height(48.dp).semantics { contentDescription = description }) {
if (emojiCategories[categoryIndex].animated) AnimatedEmoji(emoji, 26.sp, Modifier.size(32.dp), loop = true)
else Text(emoji, fontSize = 26.sp)
}
}
}
}
}
@@ -9,6 +9,7 @@ import android.icu.text.BreakIterator
import android.media.MediaMetadataRetriever
import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.BackHandler
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
@@ -18,12 +19,18 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.InsertEmoticon
import androidx.compose.material.icons.outlined.Keyboard
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import com.utun.chat.ui.theme.ChatColors
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
@@ -34,11 +41,16 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.data.Message
import com.utun.chat.data.LogManager
import com.utun.chat.data.NativeLib
import com.utun.chat.data.PhotoUtil
import com.utun.chat.viewmodel.ChatViewModel
@@ -107,8 +119,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = alignment
) {
Text(
message.text.trim(),
EmojiText(
text = message.text.trim(),
fontSize = if (emojiCount == 1) 100.sp else 51.sp,
color = textColor
)
@@ -137,7 +149,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
isImage -> ImageBubble(message, textColor, onDownloadFile, onOpenImage)
isMedia -> FileBubble(message, textColor, onDownloadFile, onOpenFile)
else -> {
Text(message.text, fontSize = 17.sp, color = textColor)
EmojiText(message.text, fontSize = 17.sp, color = textColor)
}
}
@@ -487,8 +499,8 @@ private const val HOLD_THRESHOLD_MS = 250L
@Composable
fun InputBar(
text: String,
onTextChange: (String) -> Unit,
text: TextFieldValue,
onTextChange: (TextFieldValue) -> Unit,
onSend: () -> Unit,
enabled: Boolean = true,
isRecording: Boolean = false,
@@ -511,6 +523,22 @@ fun InputBar(
) {
val ctx = LocalContext.current
val density = LocalDensity.current
val focusManager = LocalFocusManager.current
val keyboard = LocalSoftwareKeyboardController.current
val inputFocus = remember { FocusRequester() }
var emojiPanel by remember { mutableStateOf(false) }
fun closeEmojiPanel(reason: String) {
if (!emojiPanel) return
emojiPanel = false
LogManager.addLog("DEBUG", "Composer", "emoji panel closed: $reason")
}
BackHandler(enabled = emojiPanel) { closeEmojiPanel("back") }
LaunchedEffect(isRecording, enabled) {
if (isRecording || !enabled) closeEmojiPanel(if (isRecording) "recording" else "disabled")
}
DisposableEffect(Unit) {
onDispose { if (emojiPanel) LogManager.addLog("DEBUG", "Composer", "emoji panel closed: conversation left") }
}
val lockThresholdPx = with(density) { LOCK_SWIPE_THRESHOLD_DP.dp.toPx() }
var hasMicPerm by remember {
@@ -606,8 +634,30 @@ fun InputBar(
value = text,
onValueChange = onTextChange,
enabled = enabled,
modifier = Modifier.weight(1f),
modifier = Modifier.weight(1f).focusRequester(inputFocus).onFocusChanged {
if (it.isFocused) closeEmojiPanel("text input focused")
},
placeholder = { Text(stringResource(R.string.message_placeholder)) },
leadingIcon = {
IconButton(enabled = enabled, onClick = {
if (emojiPanel) {
closeEmojiPanel("keyboard button")
inputFocus.requestFocus()
keyboard?.show()
} else {
// Compose сворачивает выделение при потере фокуса; сохраняем диапазон для вставки.
focusManager.clearFocus()
onTextChange(text.copy(composition = null))
keyboard?.hide()
emojiPanel = true
LogManager.addLog("DEBUG", "Composer",
"emoji panel opened: selection=${text.selection.start}..${text.selection.end}")
}
}) {
Icon(if (emojiPanel) Icons.Outlined.Keyboard else Icons.Outlined.InsertEmoticon,
contentDescription = stringResource(if (emojiPanel) R.string.open_keyboard else R.string.open_emoji))
}
},
maxLines = 4
)
}
@@ -622,7 +672,7 @@ fun InputBar(
IconButton(onClick = onVoiceSend) {
Text("\u25B6", fontSize = 20.sp, color = Color(0xFF81C784))
}
} else if (!isRecording && (text.isNotBlank() || hasAttachment)) {
} else if (!isRecording && (text.text.isNotBlank() || hasAttachment)) {
/* Send button — round, primary color */
val sendColor = MaterialTheme.colorScheme.primary
Box(
@@ -717,6 +767,13 @@ fun InputBar(
}
}
}
if (emojiPanel && !isRecording && enabled) EmojiPickerPanel(onSelect = { emoji ->
val start = text.selection.min
val end = text.selection.max
val updated = text.text.replaceRange(start, end, emoji)
onTextChange(TextFieldValue(updated, selection = TextRange(start + emoji.length)))
LogManager.addLog("DEBUG", "Composer", "emoji inserted: start=$start replaced=${end - start} length=${emoji.length}")
})
}
}
@@ -0,0 +1,21 @@
package com.utun.chat.ui.components
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
@Composable
fun PeerStatusDot(online: Boolean, modifier: Modifier = Modifier) {
val description = stringResource(if (online) R.string.online else R.string.not_online)
Box(modifier.size(12.dp).background(if (online) Color(0xFF4CAF50) else Color(0xFFE53935), CircleShape)
.semantics { contentDescription = description })
}
@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.*
import androidx.compose.runtime.*
import com.utun.chat.ui.components.PeerStatusDot
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -170,6 +171,7 @@ fun ChannelListScreen(
Row(Modifier.fillMaxWidth().padding(12.dp)) {
Box(Modifier.size(44.dp), contentAlignment = Alignment.Center) {
Text("@", fontSize = 22.sp, color = Color(0xFFD2A8FF))
PeerStatusDot(dm.online, Modifier.align(Alignment.TopStart).offset(x = (-4).dp, y = (-6).dp))
}
Spacer(Modifier.width(10.dp))
Column {
@@ -8,10 +8,13 @@ import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import com.utun.chat.ui.components.PeerStatusDot
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.utun.chat.data.*
@@ -31,6 +34,8 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
onRecordVideo: () -> Unit, onPlayVideo: (Message) -> Unit) {
val target = ConversationTarget.Pm(conv.convId)
val playback by viewModel.playbackState.collectAsState()
val callAvailable by viewModel.dmCallAvailable.collectAsState()
val callState by viewModel.callState.collectAsState()
val state = rememberLazyListState()
val context = LocalContext.current
val scope = rememberCoroutineScope()
@@ -47,7 +52,8 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
} ?: throw IllegalStateException(AppLanguage.text(R.string.file_open_failed))
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = AppLanguage.text(R.string.file_save_failed)
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}")
error = AppLanguage.text(R.string.file_save_failed)
}
}
}
@@ -55,8 +61,18 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
if (messages.lastOrNull()?.isOutgoing == true) state.scrollToItem(0)
}
Column(Modifier.fillMaxSize().imePadding()) {
TopAppBar(title = { Text(conv.displayName) }, navigationIcon = {
TopAppBar(title = {
Row(verticalAlignment = Alignment.CenterVertically) {
PeerStatusDot(callAvailable)
Spacer(Modifier.width(8.dp))
Text(conv.displayName)
}
}, navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
}, actions = {
IconButton(onClick = { viewModel.startDmCall() }, enabled = callAvailable && callState?.ongoing != true) {
Icon(Icons.Filled.Call, stringResource(if (callAvailable) R.string.call else R.string.peer_unavailable))
}
})
ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(),
onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) },
@@ -13,6 +13,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.utun.chat.data.LogManager
import com.utun.chat.ui.components.BatteryOptimizationSetting
import com.utun.chat.ui.components.LanguageSetting
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@@ -51,6 +52,8 @@ fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit)
Text(it, color = MaterialTheme.colorScheme.error)
}
Spacer(Modifier.height(24.dp))
BatteryOptimizationSetting(source = "setup", enabled = !saving)
Spacer(Modifier.height(24.dp))
Button(
enabled = validName && !saving,
modifier = Modifier.fillMaxWidth(),
@@ -4,6 +4,10 @@ import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
@@ -29,6 +33,7 @@ import com.utun.chat.data.LogManager
import com.utun.chat.data.ServerEntry
import com.utun.chat.data.LocalSocketState
import com.utun.chat.headless.HeadlessService
import com.utun.chat.ui.components.BatteryOptimizationSetting
import com.utun.chat.ui.components.LanguageSetting
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
@@ -38,6 +43,7 @@ import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LifecycleResumeEffect
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
@@ -137,6 +143,9 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
val localSocketStates by vm.localSockets.collectAsState()
val serviceEnabled by provider.serviceEnabledFlow.collectAsState(initial = true)
val startOnBoot by provider.startOnBootFlow.collectAsState(initial = true)
var savingStartOnBoot by remember { mutableStateOf(false) }
var startOnBootError by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
nodeName = provider.getString("node.name") ?: provider.getDeviceName()
@@ -158,7 +167,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
val stored = provider.getString("debug.categories") ?: ""
catLevels = if (stored.isNotEmpty()) stored.split(",").associate { it.split("=", limit = 2).let { (k, v) -> k to v } } else emptyMap()
servers = provider.loadServersFromConfig()
availableIfaces = provider.getAvailableInterfaces()
availableIfaces = allIface.drop(1)
connectionMode = provider.getConnectionMode()
autoConnect = provider.getInt("chat.group_autoconnect") != 0
}
@@ -187,73 +196,95 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
if (lvl.isEmpty()) NativeLib.setDebugLevel(cat, "NONE") else NativeLib.setDebugLevel(cat, lvl)
}
Column(
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
var ctrlExpanded by remember { mutableStateOf(false) }
val ctrlIfaces = listOf("lo" to "127.0.0.1") + availableIfaces
val listState = rememberLazyListState()
val openedAt = remember { android.os.SystemClock.uptimeMillis() }
LaunchedEffect(Unit) {
withFrameNanos { }
LogManager.addLog("INFO", "SETTINGS", "General settings displayed: elapsedMs=${android.os.SystemClock.uptimeMillis() - openedAt} " +
"visibleItems=${listState.layoutInfo.visibleItemsInfo.size} totalItems=${listState.layoutInfo.totalItemsCount}")
}
// Создаём и раскладываем только видимые настройки, включая динамические интерфейсы и категории.
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
LanguageSetting()
item(key = "language") { LanguageSetting() }
item(key = "battery") { BatteryOptimizationSetting(source = "settings") }
/* Node */
Text(stringResource(R.string.node), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(nodeName, { v -> onDirty(true); nodeName = v; scope.launch { provider.setValue("node.name", v) } },
label = { Text(stringResource(R.string.node_name)) }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } },
label = { Text(stringResource(R.string.listen_port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
item(key = "node-title") { Text(stringResource(R.string.node), style = MaterialTheme.typography.titleMedium) }
item(key = "node-name") {
OutlinedTextField(nodeName, { v -> onDirty(true); nodeName = v; scope.launch { provider.setValue("node.name", v) } },
label = { Text(stringResource(R.string.node_name)) }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
}
item(key = "listen-port") {
OutlinedTextField(listenPort, { v -> onDirty(true); listenPort = v; scope.launch { provider.setValue("node.listen_port", v) } },
label = { Text(stringResource(R.string.listen_port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
}
/* Control interface */
var ctrlExpanded by remember { mutableStateOf(false) }
val ctrlIfaces = remember { listOf("lo" to "127.0.0.1") + provider.getAvailableInterfaces() }
Row(verticalAlignment = Alignment.CenterVertically) {
ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) {
OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true,
label = { Text(stringResource(R.string.control_bind_ip)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) {
ctrlIfaces.forEach { (n, ip) ->
DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = {
controlBindIp = ip; ctrlBindLabel = "$n ($ip)"; onDirty(true)
scope.launch { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false
})
item(key = "control") {
Row(verticalAlignment = Alignment.CenterVertically) {
ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) {
OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true,
label = { Text(stringResource(R.string.control_bind_ip)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) {
ctrlIfaces.forEach { (n, ip) ->
DropdownMenuItem(text = { Text("$n ($ip)") }, onClick = {
controlBindIp = ip; ctrlBindLabel = "$n ($ip)"; onDirty(true)
scope.launch { provider.setValue("control.bind_ip", ip) }; ctrlExpanded = false
})
}
}
}
Spacer(Modifier.width(8.dp))
OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } },
label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
Spacer(Modifier.width(8.dp))
OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } },
label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
/* Keys */
Text(stringResource(R.string.keys), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(pubKey, {}, label = { Text(stringResource(R.string.public_key)) }, readOnly = true, singleLine = true)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { if (pubKey.length == 64) showOverwriteKeys = true else { onDirty(true); vm.requestRegenerateKeys() } },
enabled = !generatingKeys) { Text(stringResource(if (generatingKeys) R.string.generating else R.string.generate_keys)) }
Button(onClick = { showClearDb = true },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.clear_db))
item(key = "keys-title") { Text(stringResource(R.string.keys), style = MaterialTheme.typography.titleMedium) }
item(key = "public-key") { OutlinedTextField(pubKey, {}, label = { Text(stringResource(R.string.public_key)) }, readOnly = true, singleLine = true) }
item(key = "key-actions") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { if (pubKey.length == 64) showOverwriteKeys = true else { onDirty(true); vm.requestRegenerateKeys() } },
enabled = !generatingKeys) { Text(stringResource(if (generatingKeys) R.string.generating else R.string.generate_keys)) }
Button(onClick = { showClearDb = true },
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Icon(Icons.Default.Delete, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.clear_db))
}
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
item(key = "network-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
/* ── Connection Mode ── */
Text(stringResource(R.string.network_interfaces), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") },
label = { Text(stringResource(R.string.automatic)) })
FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") },
label = { Text(stringResource(R.string.manual)) })
item(key = "network-title") { Text(stringResource(R.string.network_interfaces), style = MaterialTheme.typography.titleMedium) }
item(key = "connection-mode") {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
FilterChip(selected = connectionMode == "auto", onClick = { if (connectionMode != "auto") switchMode("auto") },
label = { Text(stringResource(R.string.automatic)) })
FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") },
label = { Text(stringResource(R.string.manual)) })
}
}
if (connectionMode == "auto") {
/* Auto mode: read-only live socket display */
if (localSocketStates.isEmpty()) {
Text(stringResource(R.string.no_sockets), color = MaterialTheme.colorScheme.onSurfaceVariant)
item(key = "no-sockets") { Text(stringResource(R.string.no_sockets), color = MaterialTheme.colorScheme.onSurfaceVariant) }
} else {
localSocketStates.forEach { s ->
items(localSocketStates) { s ->
val stateColor = when (s.state) { 2 -> Color(0xFF81C784); 1 -> Color(0xFFF1C40F); else -> Color(0xFFFFB4AB) }
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -278,7 +309,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
}
} else {
/* Manual mode: existing editable server config */
servers.forEachIndexed { idx, srv ->
itemsIndexed(servers, key = { _, srv -> "server-${srv.id}" }) { idx, srv ->
Column(modifier = Modifier.fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
var ifExpanded by remember { mutableStateOf(false) }
@@ -316,137 +347,186 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
}
}
Spacer(Modifier.width(4.dp))
var portText by remember(srv.id) { mutableStateOf(srv.port.toString()) }
var portText by rememberSaveable(srv.id) { mutableStateOf(srv.port.toString()) }
OutlinedTextField(portText, { v -> portText = v; val p = v.toIntOrNull(); if (p != null && p in 1..65535)
save(servers.toMutableList().also { it[idx] = srv.copy(port = p) }) },
singleLine = true, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
}
Spacer(Modifier.height(2.dp))
}
Spacer(Modifier.height(2.dp))
}
TextButton(onClick = {
val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1")
val rndPort = (2000..9999).random()
save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort))
}, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.add_interface)) }
item(key = "add-interface") {
TextButton(onClick = {
val iface = availableIfaces.firstOrNull() ?: ("lo" to "127.0.0.1")
val rndPort = (2000..9999).random()
save(servers + ServerEntry(UUID.randomUUID().toString(), iface.first, iface.second, "tcp", rndPort))
}, modifier = Modifier.fillMaxWidth()) { Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(4.dp)); Text(stringResource(R.string.add_interface)) }
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
item(key = "standby-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
/* ── Standby (background duty-cycle) ── */
Text(stringResource(R.string.standby), style = MaterialTheme.typography.titleMedium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(standbyActiveSec,
{ v -> standbyActiveSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..3600) {
scope.launch { provider.setValue("chat.standby_active_sec", v) }; NativeLib.setChatSetting("standby_active_sec", v) } },
label = { Text(stringResource(R.string.standby_active_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
item(key = "standby-title") { Text(stringResource(R.string.standby), style = MaterialTheme.typography.titleMedium) }
item(key = "standby-duty-cycle") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(standbyActiveSec,
{ v -> standbyActiveSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..3600) {
scope.launch { provider.setValue("chat.standby_active_sec", v) }; NativeLib.setChatSetting("standby_active_sec", v) } },
label = { Text(stringResource(R.string.standby_active_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
OutlinedTextField(standbySleepSec,
{ v -> standbySleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
scope.launch { provider.setValue("chat.standby_sleep_sec", v) }; NativeLib.setChatSetting("standby_sleep_sec", v) } },
label = { Text(stringResource(R.string.standby_sleep_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
}
item(key = "standby-min-sleep") {
OutlinedTextField(standbyMinSleepSec,
{ v -> standbyMinSleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
scope.launch { provider.setValue("chat.standby_min_sleep_sec", v) }; NativeLib.setChatSetting("standby_min_sleep_sec", v) } },
label = { Text(stringResource(R.string.standby_min_sleep_seconds)) }, modifier = Modifier.heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
OutlinedTextField(standbySleepSec,
{ v -> standbySleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
scope.launch { provider.setValue("chat.standby_sleep_sec", v) }; NativeLib.setChatSetting("standby_sleep_sec", v) } },
label = { Text(stringResource(R.string.standby_sleep_seconds)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
}
item(key = "standby-keepalive") {
OutlinedTextField(standbyKeepaliveMs,
{ v -> standbyKeepaliveMs = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1000..600000) {
scope.launch { provider.setValue("chat.standby_keepalive_ms", v) }; NativeLib.setChatSetting("standby_keepalive_ms", v) } },
label = { Text(stringResource(R.string.standby_keepalive_ms)) }, modifier = Modifier.heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
OutlinedTextField(standbyMinSleepSec,
{ v -> standbyMinSleepSec = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..86400) {
scope.launch { provider.setValue("chat.standby_min_sleep_sec", v) }; NativeLib.setChatSetting("standby_min_sleep_sec", v) } },
label = { Text(stringResource(R.string.standby_min_sleep_seconds)) }, modifier = Modifier.heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
OutlinedTextField(standbyKeepaliveMs,
{ v -> standbyKeepaliveMs = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1000..600000) {
scope.launch { provider.setValue("chat.standby_keepalive_ms", v) }; NativeLib.setChatSetting("standby_keepalive_ms", v) } },
label = { Text(stringResource(R.string.standby_keepalive_ms)) }, modifier = Modifier.heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
HorizontalDivider(Modifier.padding(vertical = 8.dp))
item(key = "chat-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
/* ── Chat ── */
Text(stringResource(R.string.chat), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.group_autoconnect), modifier = Modifier.weight(1f))
Switch(checked = autoConnect, onCheckedChange = {
autoConnect = it
val s = if (it) "1" else "0"
scope.launch { provider.setValue("chat.group_autoconnect", s) }
NativeLib.setChatSetting("group_autoconnect", s)
})
item(key = "chat-title") { Text(stringResource(R.string.chat), style = MaterialTheme.typography.titleMedium) }
item(key = "autoconnect") {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.group_autoconnect), modifier = Modifier.weight(1f))
Switch(checked = autoConnect, onCheckedChange = {
autoConnect = it
val s = if (it) "1" else "0"
scope.launch { provider.setValue("chat.group_autoconnect", s) }
NativeLib.setChatSetting("group_autoconnect", s)
})
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
item(key = "debug-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
/* Debug */
Text(stringResource(R.string.debug), style = MaterialTheme.typography.titleMedium)
DebugLevelDropdown(label = stringResource(R.string.global_level), current = debugLevel.uppercase(), onSelect = setDebug)
OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } },
label = { Text(stringResource(R.string.log_file)) }, singleLine = true)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(logUdpIp, { v ->
logUdpIp = v; scope.launch { provider.setValue("log_udp.ip", v) }
val p = logUdpPort.toIntOrNull() ?: 0
if (v.isNotEmpty() && p > 0) NativeLib.setUdpLogTarget(v, p) else NativeLib.setUdpLogTarget("", 0)
}, label = { Text(stringResource(R.string.udp_log_ip)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true)
OutlinedTextField(logUdpPort, { v ->
logUdpPort = v; val p = v.toIntOrNull() ?: 0; scope.launch { provider.setValue("log_udp.port", v) }
if (logUdpIp.isNotEmpty() && p > 0 && p <= 65535) NativeLib.setUdpLogTarget(logUdpIp, p) else NativeLib.setUdpLogTarget("", 0)
}, label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
item(key = "debug-title") { Text(stringResource(R.string.debug), style = MaterialTheme.typography.titleMedium) }
item(key = "debug-level") { DebugLevelDropdown(label = stringResource(R.string.global_level), current = debugLevel.uppercase(), onSelect = setDebug) }
item(key = "log-file") {
OutlinedTextField(logFile, { v -> logFile = v; scope.launch { provider.setValue("debug.log_file", v) } },
label = { Text(stringResource(R.string.log_file)) }, singleLine = true)
}
Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories },
horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.categories), style = MaterialTheme.typography.titleMedium)
Icon(Icons.Default.ArrowDropDown, stringResource(if (showDebugCategories) R.string.collapse else R.string.expand), modifier = Modifier.padding(end = 4.dp))
item(key = "log-udp") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(logUdpIp, { v ->
logUdpIp = v; scope.launch { provider.setValue("log_udp.ip", v) }
val p = logUdpPort.toIntOrNull() ?: 0
if (v.isNotEmpty() && p > 0) NativeLib.setUdpLogTarget(v, p) else NativeLib.setUdpLogTarget("", 0)
}, label = { Text(stringResource(R.string.udp_log_ip)) }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true)
OutlinedTextField(logUdpPort, { v ->
logUdpPort = v; val p = v.toIntOrNull() ?: 0; scope.launch { provider.setValue("log_udp.port", v) }
if (logUdpIp.isNotEmpty() && p > 0 && p <= 65535) NativeLib.setUdpLogTarget(logUdpIp, p) else NativeLib.setUdpLogTarget("", 0)
}, label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
}
item(key = "debug-categories-toggle") {
Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories },
horizontalArrangement = Arrangement.SpaceBetween) {
Text(stringResource(R.string.categories), style = MaterialTheme.typography.titleMedium)
Icon(Icons.Default.ArrowDropDown, stringResource(if (showDebugCategories) R.string.collapse else R.string.expand), modifier = Modifier.padding(end = 4.dp))
}
}
if (showDebugCategories) {
CATEGORIES.forEach { cat ->
items(CATEGORIES, key = { "debug-$it" }) { cat ->
val current = catLevels[cat] ?: ""
DebugLevelDropdown(label = cat, current = current.ifEmpty { stringResource(R.string.global_level_hint) },
onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true)
}
}
Spacer(Modifier.height(16.dp))
item(key = "debug-spacer") { Spacer(Modifier.height(16.dp)) }
/* ── Background service ── */
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text(stringResource(R.string.service), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(if (serviceEnabled) R.string.service_running else R.string.service_stopped), modifier = Modifier.weight(1f),
color = if (serviceEnabled) Color(0xFF81C784) else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
item(key = "service-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
item(key = "service-title") { Text(stringResource(R.string.service), style = MaterialTheme.typography.titleMedium) }
item(key = "start-on-boot") {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.start_on_boot))
Text(stringResource(R.string.start_on_boot_hint), style = MaterialTheme.typography.bodySmall)
}
Switch(checked = startOnBoot, enabled = !savingStartOnBoot, onCheckedChange = { enabled ->
savingStartOnBoot = true
startOnBootError = false
scope.launch {
try {
provider.setStartOnBoot(enabled)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
LogManager.addLog("ERROR", "CONFIG", "Start on boot setting save failed: enabled=$enabled error=$e")
startOnBootError = true
} finally {
savingStartOnBoot = false
}
}
})
}
}
if (startOnBootError) {
item(key = "boot-error") { Text(stringResource(R.string.start_on_boot_save_failed), color = MaterialTheme.colorScheme.error) }
}
item(key = "service-actions") {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(if (serviceEnabled) R.string.service_running else R.string.service_stopped), modifier = Modifier.weight(1f),
color = if (serviceEnabled) Color(0xFF81C784) else MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium)
Button(onClick = {
runBlocking { provider.setServiceEnabled(true) }
ContextCompat.startForegroundService(ChatApplication.instance,
Intent(ChatApplication.instance, HeadlessService::class.java))
}, enabled = !serviceEnabled) { Text(stringResource(R.string.service_start)) }
Button(onClick = {
runBlocking { provider.setServiceEnabled(false) }
ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java))
}, enabled = serviceEnabled,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.service_stop)) }
}
}
item(key = "restart-divider") { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
item(key = "restart") {
Button(onClick = {
runBlocking { provider.setServiceEnabled(true) }
ContextCompat.startForegroundService(ChatApplication.instance,
Intent(ChatApplication.instance, HeadlessService::class.java))
}, enabled = !serviceEnabled) { Text(stringResource(R.string.service_start)) }
scope.launch(Dispatchers.IO) {
if (NativeLib.isReady()) {
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port")
NativeLib.restart(path, port, provider.buildConfigText())
}
}
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.instance_restart))
}
}
item(key = "exit-spacer") { Spacer(Modifier.height(8.dp)) }
item(key = "exit") {
Button(onClick = {
runBlocking { provider.setServiceEnabled(false) }
ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java))
}, enabled = serviceEnabled,
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.service_stop)) }
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Button(onClick = {
scope.launch(Dispatchers.IO) {
if (NativeLib.isReady()) {
val path = provider.getString("db.path") ?: ""; val port = provider.getInt("control.port")
NativeLib.restart(path, port, provider.buildConfigText())
}
onExit()
}, modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.stop_all_exit))
}
}, modifier = Modifier.fillMaxWidth(), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.secondary)) {
Icon(Icons.Default.Refresh, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.instance_restart))
}
Spacer(Modifier.height(8.dp))
Button(onClick = {
runBlocking { provider.setServiceEnabled(false) }
ChatApplication.instance.stopService(Intent(ChatApplication.instance, HeadlessService::class.java))
onExit()
}, modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) {
Icon(Icons.Default.PowerSettingsNew, null, modifier = Modifier.size(18.dp)); Spacer(Modifier.width(8.dp)); Text(stringResource(R.string.stop_all_exit))
}
Spacer(Modifier.height(16.dp))
item(key = "bottom-spacer") { Spacer(Modifier.height(16.dp)) }
}
/* Dialogs */
@@ -154,6 +154,9 @@ class ChatViewModel : ViewModel() {
private val _currentDm = MutableStateFlow<DmConversation?>(null)
val currentDm: StateFlow<DmConversation?> = _currentDm
private var dmPresenceRefreshJob: kotlinx.coroutines.Job? = null
private val _dmCallAvailable = MutableStateFlow(false)
val dmCallAvailable: StateFlow<Boolean> = _dmCallAvailable
/* Одноразовый сигнал навигации: MainActivity открывает экран DM и сбрасывает. */
private val _dmOpen = MutableStateFlow<String?>(null)
@@ -258,6 +261,11 @@ class ChatViewModel : ViewModel() {
}
private fun handleEvent(type: Int, data: ByteArray?) {
if (type == 13) {
dmPresenceRefreshJob?.cancel()
_dmCallAvailable.value = false
_dmConversations.value = _dmConversations.value.map { it.copy(online = false) }
} else if (type in listOf(4, 5, 12, 16, 17, 24, 25, 31, 33)) refreshDmCallRoute()
when (type) {
9 -> { /* DB_READY */
if (dbReady) return
@@ -278,11 +286,6 @@ 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()
@@ -323,6 +326,30 @@ class ChatViewModel : ViewModel() {
4 -> { if (repo != null) { LogManager.addLog("INFO", "VM", "CHANNEL_UPDATED — refreshChannels"); refreshChannels() } else { LogManager.addLog("WARN", "VM", "CHANNEL_UPDATED skipped — repo is NULL") } }
12 -> LogManager.addLog("INFO", "VM", "service started")
13 -> { _attachmentPreparations.value = emptyMap(); LogManager.addLog("INFO", "VM", "service stopped") }
47 -> {
if (data == null || data.size < 17 || data.size != 17 + (data[0].toInt() and 0xff)) {
LogManager.addLog("WARN", "Call", "invalid PM route snapshot size=${data?.size}"); return
}
val len = data[0].toInt() and 0xff
val conv = String(data, 1, len, Charsets.UTF_8)
val buf = java.nio.ByteBuffer.wrap(data, 1 + len, 16).order(java.nio.ByteOrder.LITTLE_ENDIAN)
val peer = buf.long
val group = buf.long
val available = peer != 0L && group != 0L
_dmConversations.value = _dmConversations.value.map {
if (it.convId != conv) it else {
val online = available && it.peerNodeId == peer
if (it.online != online) LogManager.addLog("INFO", "Call", "PM presence conv=$conv peer=$peer online=$online")
it.copy(online = online)
}
}
val dm = _currentDm.value ?: return
if (conv != dm.convId) return
val canCall = available && peer == dm.peerNodeId
if (_dmCallAvailable.value != canCall)
LogManager.addLog("INFO", "Call", "PM button conv=$conv peer=$peer group=$group enabled=$canCall")
_dmCallAvailable.value = canCall
}
16 -> { /* NODEINFO_UPDATED: [node_id:8][presence:1][up:1][best_rtt:2][bgp_rtt:2][bgp_nodes:2] */
if (data == null || data.size < 12) return
val ns = parseNodeStatus(data) ?: return
@@ -507,6 +534,8 @@ class ChatViewModel : ViewModel() {
if (pend != null) {
pendingStartPeer = null
_currentDm.value = DmConversation(convId = convId, peerNodeId = pend.first, peerName = pend.second)
_dmCallAvailable.value = false
refreshDmCallRoute()
refreshDmMessages()
_dmOpen.value = convId
LogManager.addLog("INFO", "VM", "DM auto-open conv=$convId")
@@ -520,7 +549,6 @@ class ChatViewModel : ViewModel() {
val author = java.nio.ByteBuffer.wrap(data, 1 + cl, 8)
.order(java.nio.ByteOrder.LITTLE_ENDIAN).long
LogManager.addLog("INFO", "VM", "DM_MSG_RECEIVED conv=$convId author=0x%016x".format(author))
if (author != NativeLib.getMyNodeId()) NotificationSound.play("dm:$convId")
val cur = _currentDm.value
if (cur != null && cur.convId == convId) refreshDmMessages()
refreshDmConversations()
@@ -537,7 +565,11 @@ class ChatViewModel : ViewModel() {
}
45 -> {
if (data == null) return
_dmConversations.value = repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList()
val previous = _dmConversations.value.associateBy { it.convId }
_dmConversations.value = (repo?.parseDmConversations(String(data, Charsets.UTF_8)) ?: emptyList()).map {
it.copy(online = previous[it.convId]?.online == true)
}
refreshDmCallRoute()
}
46 -> {
if (data == null || data.size < 18) return
@@ -807,6 +839,24 @@ class ChatViewModel : ViewModel() {
/* ── DM (личные сообщения) ── */
private fun refreshDmCallRoute() {
if (dmPresenceRefreshJob?.isActive == true) return
dmPresenceRefreshJob = viewModelScope.launch {
kotlinx.coroutines.delay(50)
if (!NativeLib.isReady()) { _dmCallAvailable.value = false; return@launch }
val conversations = _dmConversations.value + listOfNotNull(_currentDm.value)
conversations.distinctBy { it.convId }.forEach { NativeLib.dmRequestCallRoute(it.convId) }
}
}
fun startDmCall() {
val dm = _currentDm.value ?: return
if (!_dmCallAvailable.value) {
LogManager.addLog("WARN", "Call", "PM call unavailable conv=${dm.convId}"); return
}
startCall("dm:${dm.convId}", dm.peerNodeId, dm.displayName)
}
fun refreshDmConversations() {
if (repo != null) NativeLib.dmRequestConversations()
}
@@ -830,12 +880,15 @@ class ChatViewModel : ViewModel() {
if (repo == null) return
val dm = DmConversation(convId = convId, peerNodeId = peerNodeId, peerName = peerName)
_currentDm.value = dm
_dmCallAvailable.value = false
refreshDmCallRoute()
_dmMessages.value = emptyList()
refreshDmMessages()
}
fun closeDm() {
_currentDm.value = null
_dmCallAvailable.value = false
_dmMessages.value = emptyList()
}