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:
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
|
||||
|
||||
+30
@@ -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)
|
||||
}
|
||||
+68
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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 {
|
||||
|
||||
+65
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+64
-7
@@ -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(),
|
||||
|
||||
+220
-140
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user