Add extensible Russian and English interface languages on Android

This commit is contained in:
evgeny committed 2026-10-02 16:05:33 +03:00
1 parent df346fc097
commit 66afcc8625
40 files changed
+1354 -412

No files matched your search

@@ -1,6 +1,8 @@
package com.utun.chat
import android.app.Application
import android.content.res.Configuration
import com.utun.chat.data.AppLanguage
import android.util.Log
import android.os.Handler
import android.os.Looper
@@ -59,4 +61,9 @@ class ChatApplication : Application() {
companion object {
lateinit var instance: ChatApplication
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
AppLanguage.applied()
}
}
@@ -1,5 +1,7 @@
package com.utun.chat
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import android.Manifest
import android.content.Intent
import android.net.Uri
@@ -14,7 +16,7 @@ import android.view.KeyEvent
import android.view.WindowManager
import com.utun.chat.data.CallController
import com.utun.chat.data.CallPhase
import androidx.activity.ComponentActivity
import androidx.appcompat.app.AppCompatActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
@@ -29,6 +31,7 @@ import androidx.compose.foundation.layout.statusBars
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.core.content.ContextCompat
@@ -50,7 +53,7 @@ import com.utun.chat.ui.screens.*
import com.utun.chat.viewmodel.ChatViewModel
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
class MainActivity : AppCompatActivity() {
enum class Screen { ChannelList, Chat, MemberList, Settings, Logs, VideoRecord, PhotoRecord, Call, Dm }
private var overLock = false
@@ -74,7 +77,7 @@ class MainActivity : ComponentActivity() {
LogManager.addLog(if (granted) "INFO" else "WARN", "PERMISSION", "Microphone result=$granted reason=$microphoneReason")
if (granted) action?.invoke()
else {
Toast.makeText(this, "Для звонков и PTT разрешите доступ к микрофону", Toast.LENGTH_LONG).show()
Toast.makeText(this, AppLanguage.text(R.string.microphone_permission_calls), Toast.LENGTH_LONG).show()
if (!shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) showMicrophoneSettings()
}
}
@@ -93,26 +96,31 @@ class MainActivity : ComponentActivity() {
} catch (e: Exception) {
microphoneAction = null
LogManager.addLog("ERROR", "PERMISSION", "Cannot request microphone permission: $e")
Toast.makeText(this, "Не удалось запросить доступ к микрофону", Toast.LENGTH_LONG).show()
Toast.makeText(this, AppLanguage.text(R.string.microphone_request_failed), Toast.LENGTH_LONG).show()
}
}
private fun showMicrophoneSettings() {
android.app.AlertDialog.Builder(this)
.setMessage("Доступ к микрофону запрещён. Разрешите его в настройках приложения, затем повторите звонок или нажатие PTT.")
.setPositiveButton("Настройки") { _, _ ->
.setMessage(AppLanguage.text(R.string.microphone_permission_settings))
.setPositiveButton(AppLanguage.text(R.string.settings)) { _, _ ->
try {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "PERMISSION", "Cannot open app settings: $e")
}
}
.setNegativeButton("Отмена", null).show()
.setNegativeButton(AppLanguage.text(R.string.cancel), null).show()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
LogManager.addLog("INFO", "LANGUAGE",
"UI language applied: selected=${AppLanguage.selectedTag().ifEmpty { "system" }} " +
"effective=${resources.configuration.locales.toLanguageTags()}")
AppLanguage.applied()
requestNotificationPermission()
/* Входящий звонок: showWhenLocked/turnScreenOn ДО setContent, иначе полноэкранный
@@ -158,19 +166,37 @@ class MainActivity : ComponentActivity() {
onStopOrDispose { vm.setForeground(false) }
}
var screen by remember { mutableStateOf(Screen.ChannelList) }
var showJoinDialog by remember { mutableStateOf(false) }
var inviteChannelId by remember { mutableStateOf<String?>(null) }
var screen by rememberSaveable { mutableStateOf(Screen.ChannelList) }
var showJoinDialog by rememberSaveable { mutableStateOf(false) }
var inviteChannelId by rememberSaveable { mutableStateOf<String?>(null) }
var videoMessage by remember { mutableStateOf<Message?>(null) }
var videoTarget by remember { mutableStateOf<ConversationTarget?>(null) }
var videoTargetId by rememberSaveable { mutableStateOf<String?>(null) }
val videoTarget = videoTargetId?.let {
if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:")) else ConversationTarget.Channel(it)
}
var photoMessage by remember { mutableStateOf<Message?>(null) }
var hadCall by remember { mutableStateOf(false) }
var screenBeforeCall by remember { mutableStateOf<Screen?>(null) }
var hadCall by rememberSaveable { mutableStateOf(false) }
var screenBeforeCall by rememberSaveable { mutableStateOf<Screen?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val callState by vm.callState.collectAsState()
// После смерти процесса ViewModel уже новый; сохранённый экран может потерять свою беседу или звонок.
LaunchedEffect(screen) {
val unavailable = when (screen) {
Screen.Chat, Screen.MemberList, Screen.PhotoRecord -> vm.currentChannel.value == null
Screen.Dm -> vm.currentDm.value == null
Screen.VideoRecord -> videoTarget == null
Screen.Call -> callState == null
else -> false
}
if (unavailable) {
LogManager.addLog("WARN", "MAIN", "Restored screen=$screen has no conversation/call; returning to channel list")
screen = Screen.ChannelList
}
}
/* После звонка снимаем showWhenLocked входящего. Экран может гаснуть:
аудио и CPU удерживает CallService независимо от Activity. */
LaunchedEffect(callState) {
@@ -187,8 +213,12 @@ class MainActivity : ComponentActivity() {
/* Звонок управляет навигацией: OUTGOING/RINGING/ACTIVE → экран Call (запоминаем
откуда пришли, чтобы свернуться обратно). ENDED не навигирует — CallScreen сам
показывает «Завершён», если пользователь уже на нём. INCOMING → диалог поверх. */
var lastCallPhase by rememberSaveable { mutableStateOf<CallPhase?>(null) }
LaunchedEffect(callState?.phase) {
when (callState?.phase) {
val phase = callState?.phase
if (phase == lastCallPhase) return@LaunchedEffect
lastCallPhase = phase
when (phase) {
CallPhase.OUTGOING, CallPhase.RINGING,
CallPhase.ACTIVE -> {
if (screen != Screen.Call) screenBeforeCall = screen
@@ -212,7 +242,7 @@ class MainActivity : ComponentActivity() {
val showInvite: (Channel) -> Unit = { ch ->
if (ch.id.toULongOrNull() == null) {
LogManager.addLog("ERROR", "INVITE", "channel id is not numeric: ${ch.id}")
scope.launch { snackbarHostState.showSnackbar("Invalid channel id") }
scope.launch { snackbarHostState.showSnackbar(AppLanguage.text(R.string.invalid_channel_id)) }
} else {
inviteChannelId = ch.id
vm.requestInvite(ch.id)
@@ -300,7 +330,7 @@ class MainActivity : ComponentActivity() {
onBack = { screen = Screen.ChannelList },
onPlayVideo = { msg -> videoMessage = msg },
onViewPhoto = { msg -> photoMessage = msg },
onRecordVideo = { videoTarget = ConversationTarget.Channel(channel.id); screen = Screen.VideoRecord },
onRecordVideo = { videoTargetId = ConversationTarget.Channel(channel.id).nativeId; screen = Screen.VideoRecord },
onCapturePhoto = { screen = Screen.PhotoRecord }
)
}
@@ -328,7 +358,7 @@ class MainActivity : ComponentActivity() {
messages = dmMessages,
viewModel = vm,
onSendMessage = { vm.sendDmMessage(it) },
onRecordVideo = { videoTarget = ConversationTarget.Pm(dm.convId); screen = Screen.VideoRecord },
onRecordVideo = { videoTargetId = ConversationTarget.Pm(dm.convId).nativeId; screen = Screen.VideoRecord },
onPlayVideo = { videoMessage = it },
onBack = { vm.closeDm(); screen = Screen.ChannelList }
)
@@ -421,7 +451,7 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
requestMicrophone("PTT") {
RadioOverlayService.sync(this)
Toast.makeText(this, "Микрофон разрешён. Нажмите PTT снова", Toast.LENGTH_LONG).show()
Toast.makeText(this, AppLanguage.text(R.string.microphone_ptt_retry), Toast.LENGTH_LONG).show()
}
}
CallController.setActivityVisible(true)
@@ -444,7 +474,7 @@ class MainActivity : ComponentActivity() {
intent.removeExtra(MicrophonePermission.EXTRA_REQUEST)
requestMicrophone("PTT") {
RadioOverlayService.sync(this)
Toast.makeText(this, "Микрофон разрешён. Нажмите PTT снова", Toast.LENGTH_LONG).show()
Toast.makeText(this, AppLanguage.text(R.string.microphone_ptt_retry), Toast.LENGTH_LONG).show()
}
}
if (incomingCallId(intent) != 0L) {
@@ -0,0 +1,69 @@
package com.utun.chat.data
import android.content.Context
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.ContextCompat
import androidx.core.os.LocaleListCompat
import com.utun.chat.ChatApplication
import com.utun.chat.R
import com.utun.chat.headless.HeadlessService
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
/** Android/AppCompat хранит выбор; XML задаёт общий список для приложения и системных настроек. */
object AppLanguage {
private var appliedLocales: String? = null
val supportedTags: List<String> by lazy {
ChatApplication.instance.resources.getXml(R.xml.locales_config).use { parser ->
buildList {
while (parser.next() != XmlPullParser.END_DOCUMENT) {
if (parser.eventType == XmlPullParser.START_TAG && parser.name == "locale") {
add(parser.getAttributeValue("http://schemas.android.com/apk/res/android", "name"))
}
}
}
}
}
fun selectedTag(): String = AppCompatDelegate.getApplicationLocales().toLanguageTags()
fun select(tag: String) {
if (tag.isNotEmpty() && tag !in supportedTags) {
LogManager.addLog("ERROR", "LANGUAGE", "Unsupported language tag=$tag supported=$supportedTags")
return
}
val previous = selectedTag()
if (previous == tag) return
LogManager.addLog("INFO", "LANGUAGE", "Language selected: ${previous.ifEmpty { "system" }} -> ${tag.ifEmpty { "system" }}")
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag))
}
fun nativeName(tag: String): String {
val locale = Locale.forLanguageTag(tag)
return locale.getDisplayName(locale).replaceFirstChar { it.titlecase(locale) }
}
// Application/Service также получают сохранённый язык, включая холодный запуск без Activity.
fun context(): Context = ContextCompat.getContextForLanguage(ChatApplication.instance)
fun text(@StringRes id: Int, vararg args: Any): String = context().getString(id, *args)
/** Вызывается после применения конфигурации; обновляет только текст действующих уведомлений. */
fun applied() {
val locales = context().resources.configuration.locales.toLanguageTags()
if (locales == appliedLocales) return
appliedLocales = locales
LogManager.addLog("INFO", "LANGUAGE", "Application language applied: locales=$locales")
val updates = listOf(
"network" to { HeadlessService.refreshLanguage() },
"incoming call" to { IncomingCallManager.refreshLanguage() },
"ongoing call" to { CallController.refreshLanguage() },
"radio" to { RadioOverlayService.refreshLanguage() }
)
updates.forEach { (name, update) ->
try { update() }
catch (e: Exception) { LogManager.addLog("ERROR", "LANGUAGE", "Notification language refresh failed: $name: $e") }
}
}
}
@@ -36,6 +36,8 @@ object CallController : TelecomCallManager.Listener {
val proximityNear: StateFlow<Boolean> = proximity.near
private var service: CallService? = null
fun refreshLanguage() { service?.refreshLanguage() }
private var serviceRequested = false
private var accepting = false
private var acceptSent = false
@@ -1,5 +1,6 @@
package com.utun.chat.data
import com.utun.chat.R
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -56,10 +57,16 @@ class CallService : Service() {
override fun onCreate() {
super.onCreate()
createChannel()
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) }
LogManager.addLog("INFO", "CallService", "created")
}
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL, "Текущий звонок", NotificationManager.IMPORTANCE_DEFAULT).apply {
description = "Собеседник, длительность и завершение звонка на экране блокировки"
NotificationChannel(CHANNEL, AppLanguage.text(R.string.ongoing_call), NotificationManager.IMPORTANCE_DEFAULT).apply {
description = AppLanguage.text(R.string.ongoing_call_description)
setSound(null, null)
enableVibration(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
@@ -68,10 +75,10 @@ class CallService : Service() {
LogManager.addLog("INFO", "CallService",
"notification channel=$CHANNEL enabled=${manager.areNotificationsEnabled()} " +
"importance=${channel.importance} lockscreenVisibility=${channel.lockscreenVisibility}")
audio.onFailure = { id, detail -> CallController.audioFailed(id, detail) }
LogManager.addLog("INFO", "CallService", "created")
}
fun refreshLanguage() { createChannel(); updateNotification() }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val id = intent?.getLongExtra(EXTRA_ID, 0L) ?: 0L
if (intent?.action == ACTION_END) {
@@ -161,13 +168,13 @@ class CallService : Service() {
Intent(this, CallService::class.java).setAction(ACTION_END)
.setData(Uri.parse("utun-call:$callId")).putExtra(EXTRA_ID, callId),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)
val builder = NotificationCompat.Builder(this, CHANNEL)
val builder = NotificationCompat.Builder(AppLanguage.context(), CHANNEL)
.setSmallIcon(android.R.drawable.ic_menu_call)
.setContentTitle(person.name)
.setContentText(when (st?.phase) {
CallPhase.ACTIVE -> "Разговор"
CallPhase.RINGING -> "Звоним…"
else -> "Установка звонка"
CallPhase.ACTIVE -> AppLanguage.text(R.string.call_talking)
CallPhase.RINGING -> AppLanguage.text(R.string.call_ringing)
else -> AppLanguage.text(R.string.call_setup)
})
.setCategory(NotificationCompat.CATEGORY_CALL)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@@ -1,11 +1,16 @@
package com.utun.chat.data
import com.utun.chat.R
/** Состояние единственной звонковой сессии; не зависит от Activity или ViewModel. */
enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED }
/** Значения CONN_TYPE_* общего ядра. */
enum class CallConnectionType(val label: String) {
NONE("нет подключения"), DIRECT("прямое"), REVERSE("reverse"), INDIRECT("indirect")
enum class CallConnectionType(val labelRes: Int) {
NONE(R.string.connection_none), DIRECT(R.string.connection_direct),
REVERSE(R.string.connection_reverse), INDIRECT(R.string.connection_indirect);
val label: String get() = AppLanguage.text(labelRes)
}
data class CallStats(
@@ -1,5 +1,6 @@
package com.utun.chat.data
import com.utun.chat.R
import android.graphics.ImageFormat
import android.hardware.camera2.CameraCharacteristics
import android.hardware.camera2.CameraMetadata
@@ -20,11 +21,18 @@ enum class LensType { SELFIE, MAIN, ULTRA_WIDE, MACRO, TELE }
data class LensOption(
val type: LensType,
val label: String,
val mm: String,
val cameraId: String,
val eqFocal: Float
) {
val label: String get() = AppLanguage.text(when (type) {
LensType.SELFIE -> R.string.lens_selfie
LensType.MAIN -> R.string.lens_main
LensType.ULTRA_WIDE -> R.string.lens_wide
LensType.MACRO -> R.string.lens_macro
LensType.TELE -> R.string.lens_tele
})
val mm: String get() = if (eqFocal > 0f) AppLanguage.text(R.string.focal_mm, round(eqFocal).toInt()) else ""
fun selector(): CameraSelector = CameraSelector.Builder()
.addCameraFilter { infos -> infos.filter { Camera2CameraInfo.from(it).cameraId == cameraId } }
.build()
@@ -52,7 +60,7 @@ object CameraLensManager {
if (facing == CameraMetadata.LENS_FACING_FRONT) {
val eq = eqFocal35(c2)
result.add(LensOption(LensType.SELFIE, "Селфи", mmLabel(eq), cameraId, eq))
result.add(LensOption(LensType.SELFIE, cameraId, eq))
continue
}
if (facing != CameraMetadata.LENS_FACING_BACK) continue
@@ -83,17 +91,9 @@ object CameraLensManager {
else -> LensType.MAIN
}
fun typeLabel(type: LensType): String = when (type) {
LensType.SELFIE -> "Селфи"
LensType.MAIN -> "Обычная"
LensType.ULTRA_WIDE -> "Широкая"
LensType.MACRO -> "Макро"
LensType.TELE -> "Теле"
}
val backOptions = backs.sortedBy { it.eqFocal }.map { l ->
val type = typeOf(l)
LensOption(type, typeLabel(type), mmLabel(l.eqFocal), l.cameraId, l.eqFocal)
LensOption(type, l.cameraId, l.eqFocal)
}
result.addAll(backOptions)
@@ -101,9 +101,6 @@ object CameraLensManager {
return result
}
private fun mmLabel(eqFocal: Float): String =
if (eqFocal > 0f) "${round(eqFocal).toInt()}мм" else ""
private fun eqFocal35(c2: Camera2CameraInfo): Float {
val focal = (c2.getCameraCharacteristic(CameraCharacteristics.LENS_INFO_AVAILABLE_FOCAL_LENGTHS)
?: floatArrayOf()).firstOrNull() ?: 0f
@@ -1,5 +1,6 @@
package com.utun.chat.data
import com.utun.chat.R
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
@@ -66,7 +67,7 @@ data class Message(val id: Long, val author: String, val text: String, val ts: L
val imageWidth: Int = 0, val imageHeight: Int = 0,
val videoWidth: Int = 0, val videoHeight: Int = 0, val videoDurationMs: Int = 0,
val downloadState: String = "", /* "", "downloading", "downloaded", "error" */
val deliveryStatus: String = "")
val deliveryStatusRes: Int = 0)
data class ChatMember(val nodeId: Long, val name: String, val online: Boolean = false,
val isAdmin: Boolean = false, val isSuper: Boolean = false,
@@ -95,7 +96,8 @@ data class MemberDetail(val nodeId: Long, val name: String = "",
data class MemberAddr(val family: Int = 0, val protocol: String = "", val address: String = "",
val port: Int = 0, val socketId: Int = 0, val addrType: Int = 0, val rtt: Int = 0) {
val roleLabel: String get() = when (addrType) {
0 -> "local"; 1 -> "public"; 2 -> "nat_eim"; 3 -> "nat_strict"; 4 -> "nat"; else -> "?$addrType"
0 -> AppLanguage.text(R.string.address_local); 1 -> AppLanguage.text(R.string.address_public)
2 -> "nat_eim"; 3 -> "nat_strict"; 4 -> "nat"; else -> "?$addrType"
}
}
@@ -103,7 +105,9 @@ data class LinkStatus(val linkId: Int = 0, val localFamily: Int = 0, val localAd
val remoteFamily: Int = 0, val remoteAddr: String = "", val remotePort: Int = 0,
val linkState: Int = 0, val linkStatus: Int = 0,
val isServer: Boolean = false, val socketName: String = "", val protocol: String = "") {
val stateLabel: String get() = when (linkState) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
val stateLabel: String get() = AppLanguage.text(when (linkState) {
2 -> R.string.connection_up; 1 -> R.string.connection_connecting; else -> R.string.connection_down
})
}
data class MemberLinks(val connected: Boolean = false, val initialized: Boolean = false,
@@ -114,7 +118,9 @@ data class LocalSocketState(
val port: Int = 0, val protocol: String = "", val state: Int = 0,
val linksUp: Int = 0, val linksDown: Int = 0
) {
val stateLabel: String get() = when (state) { 2 -> "UP"; 1 -> "CONNECTING"; else -> "DOWN" }
val stateLabel: String get() = AppLanguage.text(when (state) {
2 -> R.string.connection_up; 1 -> R.string.connection_connecting; else -> R.string.connection_down
})
}
data class ConnInfo(
@@ -567,15 +573,15 @@ class ChatRepository {
val status = obj.optString("media_status")
val path = obj.optString("media_path")
val delivery = if (file) when {
status == "delivered" -> "Доставлено"
status == "expired" -> "Срок хранения истёк"
outgoing -> "Ожидает доставки"
path.isNotEmpty() -> "Получено"
else -> "Загрузка…"
status == "delivered" -> R.string.delivery_delivered
status == "expired" -> R.string.delivery_expired
outgoing -> R.string.delivery_pending
path.isNotEmpty() -> R.string.delivery_received
else -> R.string.delivery_loading
} else if (outgoing) {
if (obj.optBoolean("pending")) "Ожидает доставки" else "Доставлено"
} else ""
val author = if (outgoing) "Вы" else peerName.ifBlank { "0x%016x".format(obj.optLong("author", 0)) }
if (obj.optBoolean("pending")) R.string.delivery_pending else R.string.delivery_delivered
} else 0
val author = peerName.ifBlank { "0x%016x".format(obj.optLong("author", 0)) }
list.add(Message(
id = obj.optLong("seq", 0),
author = author,
@@ -591,7 +597,7 @@ class ChatRepository {
videoWidth = obj.optInt("width"),
videoHeight = obj.optInt("height"),
downloadState = if (!file) "" else if (path.isNotEmpty()) "downloaded" else if (status == "expired") "error" else "downloading",
deliveryStatus = delivery
deliveryStatusRes = delivery
))
}
} catch (e: Exception) { Log.w("utun-gui", "getDmMessages error", e) }
@@ -1,5 +1,6 @@
package com.utun.chat.data
import com.utun.chat.R
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
@@ -62,6 +63,14 @@ object IncomingCallManager {
showNotification(callId, channelId, peerNodeId)
}
fun refreshLanguage() {
createChannel(AppLanguage.context())
val st = CallController.state.value ?: return
if (activeCallId != 0L && st.callId == activeCallId) {
showNotification(activeCallId, st.channelId, st.peerNodeId, alert = false)
}
}
/* ── пробуждение устройства ── */
private fun acquireWakeLock() {
@@ -91,8 +100,8 @@ object IncomingCallManager {
private fun createChannel(ctx: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_CALL, "Входящие звонки", NotificationManager.IMPORTANCE_HIGH).apply {
description = "Уведомление о входящем звонке"
val ch = NotificationChannel(CHANNEL_CALL, AppLanguage.text(R.string.incoming_calls), NotificationManager.IMPORTANCE_HIGH).apply {
description = AppLanguage.text(R.string.incoming_call_notification)
enableVibration(false)
setSound(null, null)
}
@@ -100,8 +109,8 @@ object IncomingCallManager {
}
}
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long) {
val ctx = ChatApplication.instance
private fun showNotification(callId: Long, channelId: String, peerNodeId: Long, alert: Boolean = true) {
val ctx = AppLanguage.context()
if (!NotificationManagerCompat.from(ctx).areNotificationsEnabled()) {
LogManager.addLog("WARN", "InCall", "notifications disabled — skip (still ringing)")
return
@@ -125,19 +134,20 @@ object IncomingCallManager {
val builder = NotificationCompat.Builder(ctx, CHANNEL_CALL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Входящий звонок")
.setContentTitle(AppLanguage.text(R.string.incoming_call))
.setContentText("0x%016x".format(peerNodeId))
.setCategory(NotificationCompat.CATEGORY_CALL)
.setPriority(NotificationCompat.PRIORITY_MAX)
.setOngoing(true)
.setAutoCancel(false)
.setOnlyAlertOnce(true)
.setContentIntent(contentPi)
.addAction(0, "Завершить", declinePi)
.addAction(0, AppLanguage.text(R.string.hang_up), declinePi)
val nm = ctx.getSystemService(NotificationManager::class.java)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent()) {
if (alert && (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE || nm.canUseFullScreenIntent())) {
builder.setFullScreenIntent(contentPi, true)
} else {
} else if (alert) {
LogManager.addLog("WARN", "InCall", "full-screen intent not allowed — heads-up only")
}
@@ -1,5 +1,6 @@
package com.utun.chat.data
import com.utun.chat.R
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
@@ -517,7 +518,7 @@ class RadioAudioEngine {
if (!MicrophonePermission.granted()) {
MicrophonePermission.request("PTT") {
RadioOverlayService.sync(ChatApplication.instance)
android.widget.Toast.makeText(ChatApplication.instance, "Микрофон разрешён. Нажмите PTT снова",
android.widget.Toast.makeText(ChatApplication.instance, AppLanguage.text(R.string.microphone_ptt_retry),
android.widget.Toast.LENGTH_LONG).show()
}
return false
@@ -1,5 +1,6 @@
package com.utun.chat.data
import com.utun.chat.R
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
@@ -55,6 +56,13 @@ class RadioOverlayService : Service() {
private var defaultCenter: Point? = null
private var currentService: RadioOverlayService? = null
fun refreshLanguage() {
currentService?.let { service ->
service.createChannel()
service.getSystemService(NotificationManager::class.java).notify(NOTIF_ID, service.buildNotification())
}
}
// Координаты центра обычной PTT в пикселях экрана; обновляются из layout чата.
fun setDefaultCenter(x: Int, y: Int) {
if (defaultCenter?.x == x && defaultCenter?.y == y) return
@@ -79,13 +87,13 @@ class RadioOverlayService : Service() {
private var activityVisible = false
private var waitingOverlayPermission = false
var overlayPermissionRequester: (() -> Unit)? = null
private val _status = MutableStateFlow<String?>(null)
val status: StateFlow<String?> = _status
private val _status = MutableStateFlow<Int?>(null)
val status: StateFlow<Int?> = _status
private fun setStatus(message: String?) {
private fun setStatus(message: Int?) {
if (_status.value == message) return
_status.value = message
LogManager.addLog("INFO", "RadioOverlay", "state=${message ?: "disabled"} shown=${_isShown.value}")
LogManager.addLog("INFO", "RadioOverlay", "state=${message?.let { ChatApplication.instance.resources.getResourceEntryName(it) } ?: "disabled"} shown=${_isShown.value}")
}
suspend fun setEnabled(enabled: Boolean) {
@@ -107,7 +115,7 @@ class RadioOverlayService : Service() {
val ctx = ChatApplication.instance
val provider = ctx.configProvider
if (provider.getRadioOverlay() && !canDrawOverlays(ctx)) {
setStatus("Нужно разрешение поверх приложений")
setStatus(R.string.overlay_permission_required)
if (waitingOverlayPermission) return
val request = overlayPermissionRequester
if (request == null) {
@@ -120,7 +128,7 @@ class RadioOverlayService : Service() {
return
}
if ((provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0) && !MicrophonePermission.granted()) {
setStatus("Нужен доступ к микрофону")
setStatus(R.string.microphone_access_required)
MicrophonePermission.request("Floating PTT / VAD") { sync(ctx) }
return
}
@@ -147,9 +155,9 @@ class RadioOverlayService : Service() {
val micAllowed = MicrophonePermission.granted()
when {
!enabled -> setStatus(null)
!overlayAllowed -> setStatus("Нужно разрешение поверх приложений")
!micAllowed -> setStatus("Нужен доступ к микрофону")
!radio.isActive() -> setStatus("Кнопка появится при включении рации")
!overlayAllowed -> setStatus(R.string.overlay_permission_required)
!micAllowed -> setStatus(R.string.microphone_access_required)
!radio.isActive() -> setStatus(R.string.overlay_when_radio_on)
}
if (!radio.isActive() || (!vad && (!enabled || !overlayAllowed)) || !micAllowed) {
if (!micAllowed && running) {
@@ -170,11 +178,11 @@ class RadioOverlayService : Service() {
}
if (starting) { pendingStop = false; return }
if (!activityVisible) {
if (enabled && overlayAllowed) setStatus("Для запуска кнопки откройте приложение")
if (enabled && overlayAllowed) setStatus(R.string.overlay_open_app)
LogManager.addLog("DEBUG", "RadioOverlay", "Service start deferred until Activity resume")
return
}
if (enabled && overlayAllowed) setStatus("Запуск плавающей кнопки…")
if (enabled && overlayAllowed) setStatus(R.string.overlay_starting)
pendingStop = false
starting = true
try {
@@ -182,7 +190,7 @@ class RadioOverlayService : Service() {
LogManager.addLog("INFO", "RadioOverlay", "Microphone service requested from visible Activity overlay=$enabled vad=$vad")
} catch (e: Exception) {
starting = false
if (enabled) setStatus("Не удалось запустить плавающую кнопку")
if (enabled) setStatus(R.string.overlay_start_failed)
LogManager.addLog("ERROR", "RadioOverlay", "Service start failed: $e")
}
}
@@ -225,7 +233,7 @@ class RadioOverlayService : Service() {
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioOverlay", "Foreground microphone start failed: $e")
starting = false
setStatus("Не удалось запустить сервис микрофона")
setStatus(R.string.microphone_service_failed)
stopSelf()
return
}
@@ -266,12 +274,12 @@ class RadioOverlayService : Service() {
val wantOverlay = provider.getRadioOverlay() && canDrawOverlays(this)
if (wantOverlay && overlayView == null) {
try { addOverlay() } catch (e: Exception) {
setStatus("Не удалось показать плавающую кнопку")
setStatus(R.string.overlay_show_failed)
LogManager.addLog("ERROR", "RadioOverlay", "Overlay creation failed: $e")
}
}
if (!wantOverlay) removeOverlay()
if (overlayView != null) setStatus("Плавающая кнопка активна")
if (overlayView != null) setStatus(R.string.overlay_active)
if (!wantOverlay && provider.getInt("radio.vad_enabled") == 0) stopSelf()
}
@@ -290,18 +298,19 @@ class RadioOverlayService : Service() {
private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply {
description = "Оверлей PTT рации"
val ch = NotificationChannel(CHANNEL_ID, AppLanguage.text(R.string.radio_ptt), NotificationManager.IMPORTANCE_LOW).apply {
description = AppLanguage.text(R.string.radio_overlay_description)
}
getSystemService(NotificationManager::class.java).createNotificationChannel(ch)
}
}
private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
private fun buildNotification() = NotificationCompat.Builder(AppLanguage.context(), CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_btn_speak_now)
.setContentTitle("Рация активна")
.setContentText("Рация: PTT / автоматическая передача по голосу")
.setContentTitle(AppLanguage.text(R.string.radio_active))
.setContentText(AppLanguage.text(R.string.radio_notification))
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
private fun addOverlay() {
@@ -472,8 +481,8 @@ class RadioOverlayService : Service() {
pendingStop = false
if (currentService === this) currentService = null
_isShown.value = false
if (_status.value == "Плавающая кнопка активна" || _status.value == "Запуск плавающей кнопки…") {
setStatus(if (ChatApplication.instance.configProvider.getRadioOverlay()) "Плавающая кнопка остановлена" else null)
if (_status.value == R.string.overlay_active || _status.value == R.string.overlay_starting) {
setStatus(if (ChatApplication.instance.configProvider.getRadioOverlay()) R.string.overlay_stopped else null)
}
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy()
@@ -9,6 +9,8 @@ import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.utun.chat.ChatApplication
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import com.utun.chat.MainActivity
import com.utun.chat.data.LogManager
import com.utun.chat.data.NativeLib
@@ -16,23 +18,22 @@ import com.utun.chat.data.NetworkActivityMonitor
import com.utun.chat.data.NetworkChangeMonitor
class HeadlessService : Service() {
companion object {
private var currentService: HeadlessService? = null
fun refreshLanguage() {
currentService?.let { service ->
service.createNotificationChannel()
service.getSystemService(NotificationManager::class.java).notify(1, service.notification())
}
}
}
override fun onCreate() {
super.onCreate()
currentService = this
createNotificationChannel()
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
val notification = NotificationCompat.Builder(this, "utun_channel")
.setContentTitle("vibechat")
.setContentText("Service running...")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
startForeground(1, notification)
startForeground(1, notification())
val provider = ChatApplication.instance.configProvider
val dbDir = provider.getString("db.path") ?: filesDir.absolutePath
@@ -55,6 +56,7 @@ class HeadlessService : Service() {
}
override fun onDestroy() {
if (currentService === this) currentService = null
LogManager.addLog("INFO", "SRV", "uTun service stopping")
NetworkChangeMonitor.stop()
NetworkActivityMonitor.stop()
@@ -66,12 +68,26 @@ class HeadlessService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
private fun notification(): android.app.Notification {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(this, 0, intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
return NotificationCompat.Builder(AppLanguage.context(), "utun_channel")
.setContentTitle("vibechat")
.setContentText(AppLanguage.text(R.string.service_notification))
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"utun_channel", "vibechat",
NotificationManager.IMPORTANCE_LOW
).apply { description = "vibechat background service" }
).apply { description = AppLanguage.text(R.string.service_description) }
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
@@ -1,5 +1,7 @@
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.clickable
import androidx.compose.foundation.layout.*
@@ -43,9 +45,9 @@ fun ActiveCallBanner(
val name = st.peerName.ifEmpty { "0x%016x".format(st.peerNodeId) }
val status = when (phase) {
CallPhase.ACTIVE -> "Разговор " + fmtDuration(st.durationSeconds)
CallPhase.RINGING -> "Звоним…"
else -> "Пытаемся связаться…"
CallPhase.ACTIVE -> stringResource(R.string.call_talking_prefix) + fmtDuration(st.durationSeconds)
CallPhase.RINGING -> stringResource(R.string.call_ringing)
else -> stringResource(R.string.call_connecting)
}
Row(
@@ -59,7 +61,7 @@ fun ActiveCallBanner(
) {
Icon(
Icons.Filled.PhoneInTalk,
contentDescription = "Звонок",
contentDescription = stringResource(R.string.call_noun),
tint = Color.White,
modifier = Modifier.size(22.dp)
)
@@ -87,7 +89,7 @@ fun ActiveCallBanner(
contentAlignment = Alignment.Center
) {
IconButton(onClick = onHangup) {
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(22.dp))
Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(22.dp))
}
}
}
@@ -1,5 +1,8 @@
package com.utun.chat.ui.components
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import androidx.compose.ui.res.stringResource
import android.net.Uri
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -8,6 +11,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
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
@@ -28,7 +32,7 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
val context = LocalContext.current
val scope = rememberCoroutineScope()
val activeTarget by rememberUpdatedState(target)
var text by remember(target) { mutableStateOf("") }
var text by rememberSaveable(target.nativeId) { mutableStateOf("") }
var selected by remember(target) { mutableStateOf<SelectedAttachment?>(null) }
var pickerTarget by remember { mutableStateOf<ConversationTarget?>(null) }
var preparing by remember(target) { mutableStateOf(false) }
@@ -63,13 +67,16 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
if (pinned == activeTarget) { selected = attachment; sendAsVideo = false; chooseVideo = attachment.video }
else LogManager.addLog("INFO", "Attachment", "picker discarded after target change")
} catch (e: CancellationException) { throw e }
catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "picker failed: ${e.message}"); error = "Не удалось открыть вложение" }
catch (e: Exception) {
LogManager.addLog("ERROR", "Attachment", "picker failed: ${e.message}")
error = AppLanguage.text(R.string.attachment_open_failed)
}
}
}
Column {
if (error.isNotEmpty()) Text(error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(8.dp))
if (preparing || preparation?.jobs?.isNotEmpty() == true) Text("Подготовка медиа…", modifier = Modifier.padding(8.dp))
else if (preparation?.failed == true) Text("Не удалось подготовить вложение", color = MaterialTheme.colorScheme.error,
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(
text = text, onTextChange = { text = it }, enabled = !preparing,
@@ -82,12 +89,15 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
var cache: String? = null
try {
withContext(Dispatchers.IO) { cache = viewModel.copyUriToCache(context, attachment.uri, attachment.name) }
val path = cache ?: throw IllegalStateException("Не удалось скопировать вложение")
val path = cache ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_copy_failed))
if (video) viewModel.sendVideoMessage(path, attachment.target)
else viewModel.sendAttachment(path, attachment.target, attachment.name)
cache = null // Native/backend принял собственный cache-файл.
} catch (e: CancellationException) { throw e }
catch (e: Exception) { LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}"); error = e.message ?: "Ошибка вложения" }
catch (e: Exception) {
LogManager.addLog("ERROR", "Attachment", "send failed: ${e.message}")
error = e.message ?: AppLanguage.text(R.string.attachment_send_failed)
}
finally {
val abandoned = cache
if (abandoned != null) withContext(NonCancellable + Dispatchers.IO) { File(abandoned).delete() }
@@ -108,18 +118,18 @@ fun ConversationComposer(target: ConversationTarget, viewModel: ChatViewModel, o
)
}
if (menu) ModalBottomSheet(onDismissRequest = { menu = false }) {
ListItem(headlineContent = { Text("Выбрать файл") }, modifier = Modifier.clickable {
ListItem(headlineContent = { Text(stringResource(R.string.select_file)) }, modifier = Modifier.clickable {
menu = false; pickerTarget = target; picker.launch(arrayOf("*/*"))
})
ListItem(headlineContent = { Text("Записать видео") }, modifier = Modifier.clickable { menu = false; onRecordVideo() })
if (onCapturePhoto != null) ListItem(headlineContent = { Text("Сфотографировать") },
ListItem(headlineContent = { Text(stringResource(R.string.record_video)) }, modifier = Modifier.clickable { menu = false; onRecordVideo() })
if (onCapturePhoto != null) ListItem(headlineContent = { Text(stringResource(R.string.take_photo)) },
modifier = Modifier.clickable { menu = false; onCapturePhoto() })
Spacer(Modifier.height(16.dp))
}
if (chooseVideo) AlertDialog(
onDismissRequest = { chooseVideo = false }, title = { Text("Отправить видео") },
text = { Text("Выбери способ отображения вложения") },
confirmButton = { TextButton(onClick = { sendAsVideo = true; chooseVideo = false }) { Text("Как видео") } },
dismissButton = { TextButton(onClick = { sendAsVideo = false; chooseVideo = false }) { Text("Как файл") } }
onDismissRequest = { chooseVideo = false }, title = { Text(stringResource(R.string.send_video)) },
text = { Text(stringResource(R.string.attachment_display_choice)) },
confirmButton = { TextButton(onClick = { sendAsVideo = true; chooseVideo = false }) { Text(stringResource(R.string.as_video)) } },
dismissButton = { TextButton(onClick = { sendAsVideo = false; chooseVideo = false }) { Text(stringResource(R.string.as_file)) } }
)
}
@@ -0,0 +1,41 @@
package com.utun.chat.ui.components
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import com.utun.chat.data.LogManager
@Composable
fun LanguageSetting() {
var expanded by remember { mutableStateOf(false) }
val selected = AppLanguage.selectedTag()
val systemLabel = stringResource(R.string.language_system)
val options = listOf("" to systemLabel) + AppLanguage.supportedTags.map { it to AppLanguage.nativeName(it) }
val selectedName = options.firstOrNull { it.first == selected }?.second ?: AppLanguage.nativeName(selected)
Box(Modifier.fillMaxWidth()) {
OutlinedButton(onClick = {
expanded = true
LogManager.addLog("DEBUG", "LANGUAGE", "Language menu opened: selected=$selected options=${options.map { it.first }}")
}, modifier = Modifier.fillMaxWidth()) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.interface_language), style = MaterialTheme.typography.labelMedium)
Text(selectedName, style = MaterialTheme.typography.bodyLarge)
}
Icon(Icons.Default.ArrowDropDown, contentDescription = null)
}
}
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
options.forEach { (tag, name) ->
DropdownMenuItem(text = { Text(name) }, onClick = { expanded = false; AppLanguage.select(tag) })
}
}
}
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.components
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap
@@ -75,6 +77,10 @@ private fun detectEmojiOnly(text: String): Int {
return if (count in 1..3) count else 0
}
@Composable
private fun deliveryLabel(message: Message): String =
if (message.deliveryStatusRes == 0) "" else stringResource(message.deliveryStatusRes)
@Composable
fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState = ChatViewModel.PlaybackState(),
onPlayVoice: (String, Int) -> Unit = { _, _ -> },
@@ -121,7 +127,8 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
.background(bgColor)
.padding(12.dp)
) {
if (!isVoice) Text(message.author, fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
if (!isVoice) Text(if (message.isOutgoing) stringResource(R.string.you) else message.author,
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = ChatColors.Secondary)
Spacer(Modifier.height(2.dp))
when {
@@ -137,7 +144,7 @@ fun MessageBubble(message: Message, playbackState: ChatViewModel.PlaybackState =
if (!isVoice) {
Spacer(Modifier.height(2.dp))
Text(
listOf(formatTime(message.ts), message.deliveryStatus).filter { it.isNotEmpty() }.joinToString(" · "),
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp,
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.End)
@@ -236,8 +243,8 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
) {
Text(
when {
downloading -> "Downloading..."
notDownloaded -> "Tap to download"
downloading -> stringResource(R.string.downloading)
notDownloaded -> stringResource(R.string.tap_download)
playing || paused -> "${formatDurationMs(elapsedMs)} / ${formatDurationMs(durMs)}"
else -> formatDurationMs(durMs)
},
@@ -245,7 +252,7 @@ private fun VoiceBubble(message: Message, textColor: Color, isActive: Boolean,
color = ChatColors.Secondary
)
Text(
listOf(formatTime(message.ts), message.deliveryStatus).filter { it.isNotEmpty() }.joinToString(" · "),
listOf(formatTime(message.ts), deliveryLabel(message)).filter { it.isNotEmpty() }.joinToString(" · "),
fontSize = 11.sp,
color = ChatColors.Secondary,
modifier = Modifier.align(Alignment.CenterVertically).padding(start = 8.dp)
@@ -265,10 +272,10 @@ private fun FileBubble(message: Message, textColor: Color, onDownload: (Message)
else -> "\u2B07\uFE0F"
}
val stateText = when {
state == "downloaded" -> "Tap to save"
state == "downloading" -> "Downloading..."
state == "error" -> "Download failed, tap to retry"
else -> "Tap to download"
state == "downloaded" -> stringResource(R.string.tap_save)
state == "downloading" -> stringResource(R.string.downloading)
state == "error" -> stringResource(R.string.download_failed_retry)
else -> stringResource(R.string.tap_download)
}
Column(
@@ -381,10 +388,10 @@ private fun VideoBubble(message: Message, textColor: Color,
if (message.fileSize > 0 && message.videoDurationMs > 0)
append("${formatBitrate(message.fileSize, message.videoDurationMs)} · ")
append(when {
downloading -> "Downloading..."
isError -> "Failed, tap to retry"
!downloaded -> "Tap to download"
else -> "Tap to play"
downloading -> stringResource(R.string.downloading)
isError -> stringResource(R.string.failed_retry)
!downloaded -> stringResource(R.string.tap_download)
else -> stringResource(R.string.tap_play)
})
}.trimEnd(),
fontSize = 12.sp, color = ChatColors.Secondary
@@ -443,10 +450,10 @@ private fun ImageBubble(message: Message, textColor: Color,
Spacer(Modifier.height(2.dp))
Text(
resText + formatFileSize(message.fileSize) + " · " + when {
downloading -> "Downloading..."
isError -> "Failed, tap to retry"
!downloaded -> "Tap to download"
else -> "Tap to open"
downloading -> stringResource(R.string.downloading)
isError -> stringResource(R.string.failed_retry)
!downloaded -> stringResource(R.string.tap_download)
else -> stringResource(R.string.tap_open)
},
fontSize = 12.sp, color = ChatColors.Secondary
)
@@ -600,7 +607,7 @@ fun InputBar(
onValueChange = onTextChange,
enabled = enabled,
modifier = Modifier.weight(1f),
placeholder = { Text("Message...") },
placeholder = { Text(stringResource(R.string.message_placeholder)) },
maxLines = 4
)
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.Manifest
import android.app.Activity
import android.content.Context
@@ -59,11 +61,12 @@ private fun fmtDuration(sec: Int): String {
return "%02d:%02d".format(m, s)
}
@Composable
private fun callStatusText(phase: CallPhase): String = when (phase) {
CallPhase.OUTGOING -> "Пытаемся связаться…"
CallPhase.RINGING -> "Звоним…"
CallPhase.ACTIVE -> "Разговор"
CallPhase.ENDED -> "Завершён"
CallPhase.OUTGOING -> stringResource(R.string.call_connecting)
CallPhase.RINGING -> stringResource(R.string.call_ringing)
CallPhase.ACTIVE -> stringResource(R.string.call_talking)
CallPhase.ENDED -> stringResource(R.string.call_ended)
else -> ""
}
@@ -170,7 +173,7 @@ fun CallScreen(
fontSize = 16.sp
)
Spacer(Modifier.height(8.dp))
Text("Подключение: ${st?.connectionType?.label ?: "нет подключения"}",
Text(stringResource(R.string.connection_label, st?.connectionType?.label ?: stringResource(R.string.connection_none)),
color = Color(0xFFAEBCC8), fontSize = 13.sp)
val bgpPath = st?.bgpPath ?: ""
if (bgpPath.isNotEmpty()) {
@@ -185,7 +188,7 @@ fun CallScreen(
if (phase == CallPhase.ENDED) {
Button(onClick = { viewModel.dismissCall(); onClose() }) {
Text("Закрыть")
Text(stringResource(R.string.close))
}
} else {
if (phase == CallPhase.ACTIVE) {
@@ -193,24 +196,24 @@ fun CallScreen(
if (earpieceAvailable) {
CallControlButton(
icon = Icons.Filled.PhoneInTalk,
contentDescription = "Динамик",
label = "Динамик",
contentDescription = stringResource(R.string.earpiece),
label = stringResource(R.string.earpiece),
active = route == AudioRoute.EARPIECE,
onClick = { viewModel.setAudioRoute(AudioRoute.EARPIECE) }
)
}
CallControlButton(
icon = Icons.AutoMirrored.Filled.VolumeUp,
contentDescription = "Громкая связь",
label = "Громкая",
contentDescription = stringResource(R.string.speakerphone),
label = stringResource(R.string.speaker_short),
active = route == AudioRoute.SPEAKER,
onClick = { viewModel.setAudioRoute(AudioRoute.SPEAKER) }
)
if (headsetAvailable) {
CallControlButton(
icon = Icons.Filled.Headset,
contentDescription = "Гарнитура",
label = "Гарнитура",
contentDescription = stringResource(R.string.headset),
label = stringResource(R.string.headset),
active = route == AudioRoute.HEADSET,
onClick = {
if (hasBtPermission) {
@@ -229,8 +232,8 @@ fun CallScreen(
if (phase == CallPhase.ACTIVE) {
CallControlButton(
icon = if (muted) Icons.Filled.MicOff else Icons.Filled.Mic,
contentDescription = if (muted) "Включить микрофон" else "Выключить микрофон",
label = if (muted) "Вкл. микрофон" else "Mute",
contentDescription = stringResource(if (muted) R.string.microphone_enable else R.string.microphone_disable),
label = stringResource(if (muted) R.string.microphone_on_short else R.string.microphone_mute_short),
active = muted,
activeColor = Color(0xFFB32632),
onClick = { viewModel.toggleMute() }
@@ -242,11 +245,11 @@ fun CallScreen(
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.hangupCall() }) {
Icon(Icons.Filled.CallEnd, "Завершить", tint = Color.White, modifier = Modifier.size(36.dp))
Icon(Icons.Filled.CallEnd, stringResource(R.string.hang_up), tint = Color.White, modifier = Modifier.size(36.dp))
}
}
Spacer(Modifier.height(6.dp))
Text("Завершить", color = Color(0xFFAEBCC8), fontSize = 12.sp)
Text(stringResource(R.string.hang_up), color = Color(0xFFAEBCC8), fontSize = 12.sp)
}
}
}
@@ -255,10 +258,8 @@ fun CallScreen(
st?.stats?.let { stats ->
Spacer(Modifier.height(20.dp))
Text(
"Буфер: ${stats.bufferMs} мс · Запас: ${stats.reserveMs} мс\n" +
"За 10 с: минимум ${stats.minMs} · максимум ${stats.maxMs} мс\n" +
"Скорость: ${"%.2f".format(stats.tempoX100 / 100.0)}× · RTT: ${stats.rttMs} мс\n" +
"Потери буфера: ${stats.dropped} · Недостачи: ${stats.underruns}",
stringResource(R.string.call_stats, stats.bufferMs, stats.reserveMs, stats.minMs, stats.maxMs,
"%.2f".format(stats.tempoX100 / 100.0), stats.rttMs, stats.dropped, stats.underruns),
modifier = Modifier.padding(horizontal = 16.dp),
color = Color(0xFFAEBCC8),
fontSize = 12.sp,
@@ -275,7 +276,7 @@ fun CallScreen(
.statusBarsPadding()
.padding(8.dp)
) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Свернуть звонок", tint = Color.White, modifier = Modifier.size(24.dp))
Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.minimize_call), tint = Color.White, modifier = Modifier.size(24.dp))
}
if (locked) {
@@ -291,11 +292,11 @@ fun CallScreen(
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Filled.Lock, "Экран заблокирован", tint = Color.White, modifier = Modifier.size(48.dp))
Icon(Icons.Filled.Lock, stringResource(R.string.screen_locked), tint = Color.White, modifier = Modifier.size(48.dp))
Spacer(Modifier.height(16.dp))
Text("Экран заблокирован", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.screen_locked), color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(8.dp))
Text("Отведите телефон от уха, чтобы разблокировать", color = Color(0xFFAEBCC8), fontSize = 14.sp)
Text(stringResource(R.string.move_phone_away), color = Color(0xFFAEBCC8), fontSize = 14.sp)
}
}
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -62,15 +64,15 @@ fun ChannelListScreen(
.background(if (isStuck) Color(0xFFFFB4AB) else Color(0xFF81C784), CircleShape)
)
Spacer(Modifier.width(4.dp))
IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, "Join") }
IconButton(onClick = onLogs) { Icon(Icons.Default.Build, "Logs") }
IconButton(onClick = onSettings) { Icon(Icons.Default.Settings, "Settings") }
IconButton(onClick = onJoinChannel) { Icon(Icons.Default.QrCode, stringResource(R.string.join)) }
IconButton(onClick = onLogs) { Icon(Icons.Default.Build, stringResource(R.string.logs)) }
IconButton(onClick = onSettings) { Icon(Icons.Default.Settings, stringResource(R.string.settings)) }
}
)
},
floatingActionButton = {
FloatingActionButton(onClick = { showCreateDialog = true }) {
Icon(Icons.Default.Add, "New channel")
Icon(Icons.Default.Add, stringResource(R.string.new_channel))
}
}
) { padding ->
@@ -78,7 +80,7 @@ fun ChannelListScreen(
if (channels.isEmpty() && dmConversations.isEmpty()) {
Box(Modifier.fillMaxSize().weight(1f)) {
Text(
"No channels yet.\nTap + to create one.",
stringResource(R.string.no_channels),
modifier = Modifier.padding(32.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -106,15 +108,15 @@ fun ChannelListScreen(
onDismissRequest = { menuChannelId = null }
) {
DropdownMenuItem(
text = { Text("Invite link") },
text = { Text(stringResource(R.string.invite_link)) },
onClick = { menuChannelId = null; onInviteClick(ch) }
)
DropdownMenuItem(
text = { Text("Options") },
text = { Text(stringResource(R.string.options)) },
onClick = { menuChannelId = null; optionsChannel = ch }
)
DropdownMenuItem(
text = { Text("Delete channel", color = MaterialTheme.colorScheme.error) },
text = { Text(stringResource(R.string.delete_channel), color = MaterialTheme.colorScheme.error) },
onClick = { menuChannelId = null; deleteConfirmChannel = ch }
)
}
@@ -154,7 +156,7 @@ fun ChannelListScreen(
if (dmConversations.isNotEmpty()) {
item {
Text(
"Личные",
stringResource(R.string.private_chats),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
@@ -204,16 +206,16 @@ fun ChannelListScreen(
deleteConfirmChannel?.let { ch ->
AlertDialog(
onDismissRequest = { deleteConfirmChannel = null },
title = { Text("Delete channel") },
text = { Text("Delete \"${ch.name}\" and all its local messages and files? Other members keep the channel.") },
title = { Text(stringResource(R.string.delete_channel)) },
text = { Text(stringResource(R.string.delete_channel_confirm, ch.name)) },
confirmButton = {
TextButton(onClick = { deleteConfirmChannel = null; onDeleteChannel(ch) },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)) {
Text("Delete")
Text(stringResource(R.string.delete))
}
},
dismissButton = {
TextButton(onClick = { deleteConfirmChannel = null }) { Text("Cancel") }
TextButton(onClick = { deleteConfirmChannel = null }) { Text(stringResource(R.string.cancel)) }
}
)
}
@@ -230,7 +232,7 @@ fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) {
text = {
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Sound", modifier = Modifier.weight(1f))
Text(stringResource(R.string.sound), modifier = Modifier.weight(1f))
Switch(
checked = soundOn,
onCheckedChange = {
@@ -245,7 +247,7 @@ fun ChannelOptionsDialog(channel: Channel, onDismiss: () -> Unit) {
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
}
)
}
@@ -255,22 +257,22 @@ fun CreateChannelDialog(onDismiss: () -> Unit, onCreate: (String) -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Channel") },
title = { Text(stringResource(R.string.new_channel)) },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Channel name") },
label = { Text(stringResource(R.string.channel_name)) },
singleLine = true
)
},
confirmButton = {
Button(onClick = { if (name.isNotBlank()) onCreate(name.trim()) }, enabled = name.isNotBlank()) {
Text("Create")
Text(stringResource(R.string.create))
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
}
)
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.os.SystemClock
import android.view.ViewConfiguration
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -115,7 +117,7 @@ fun ChatScreen(
TopAppBar(
title = { Text(channel.name) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
},
actions = {
IconButton(onClick = { viewModel.toggleSound() }) {
@@ -126,10 +128,11 @@ fun ChatScreen(
modifier = Modifier.background(
if (radioActive) Color(0xFF27AE60) else Color.Transparent, CircleShape)
) {
PortableRadioIcon("Рация: ${if (radioActive) "выключить" else "включить"}")
val action = stringResource(if (radioActive) R.string.radio_turn_off else R.string.radio_turn_on)
PortableRadioIcon(stringResource(R.string.radio_toggle, action))
}
IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, "Invite") }
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") }
IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, stringResource(R.string.invite)) }
IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, stringResource(R.string.members)) }
}
)
if (radioActive) {
@@ -138,7 +141,7 @@ fun ChatScreen(
if (radioTalking != null) {
Surface(color = MaterialTheme.colorScheme.primaryContainer) {
Text(
text = "\uD83C\uDF99 ${viewModel.radioName(radioTalking!!)} говорит",
text = stringResource(R.string.radio_speaking, viewModel.radioName(radioTalking!!)),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp),
style = MaterialTheme.typography.bodyMedium
)
@@ -273,29 +276,30 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
verticalAlignment = Alignment.CenterVertically
) {
val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
val routeName = stringResource(if (radioRoute == AudioRoute.HEADSET) R.string.radio_headset else R.string.radio_speaker)
RadioControlButton(Icons.Default.AutoMode, stringResource(R.string.radio_auto_route, routeName),
radioAuto, centerIcon = currentDeviceIcon) {
viewModel.setRadioAuto()
}
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона",
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, stringResource(R.string.radio_phone_audio),
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN)
}
if (wiredInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
RadioControlButton(Icons.Default.HeadsetMic, stringResource(R.string.radio_wired_audio),
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
}
}
if (btInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
RadioControlButton(Icons.Default.HeadsetMic, stringResource(R.string.radio_bluetooth_audio),
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
}
}
}
VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550))
RadioControlButton(null, "Плавающая PTT-кнопка", overlayShown, floating = true) {
RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) {
scope.launch {
if (overlayEnabled && !overlayShown) RadioOverlayService.requestPermissions()
else RadioOverlayService.setEnabled(!overlayEnabled)
@@ -303,7 +307,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
}
}
if (overlayEnabled && !overlayShown) overlayStatus?.let { message ->
Text(message, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
Text(stringResource(message), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp).clickable { RadioOverlayService.requestPermissions() })
}
}
@@ -1,5 +1,8 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import androidx.compose.ui.res.stringResource
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
@@ -41,10 +44,10 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
withContext(Dispatchers.IO) {
context.contentResolver.openOutputStream(uri)?.use { output ->
File(path).inputStream().use { it.copyTo(output) }
} ?: throw IllegalStateException("Не удалось открыть файл")
} ?: throw IllegalStateException(AppLanguage.text(R.string.file_open_failed))
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = "Не удалось сохранить файл"
LogManager.addLog("ERROR", "PM", "save failed: ${e.message}"); error = AppLanguage.text(R.string.file_save_failed)
}
}
}
@@ -53,7 +56,7 @@ fun DmChatScreen(conv: DmConversation, messages: List<Message>, viewModel: ChatV
}
Column(Modifier.fillMaxSize().imePadding()) {
TopAppBar(title = { Text(conv.displayName) }, navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Назад") }
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
})
ConversationMessages(target, messages, state, playback, Modifier.weight(1f).fillMaxWidth(),
onPlayVoice = { path, duration -> viewModel.playVoiceMessage(path, duration) },
@@ -1,5 +1,8 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -10,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.LanguageSetting
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch
@@ -28,14 +32,16 @@ fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit)
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("Добро пожаловать", style = MaterialTheme.typography.headlineMedium)
Text(stringResource(R.string.welcome), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
Text("Задайте имя устройства (ник), которое увидят другие участники.")
Text(stringResource(R.string.device_name_hint))
Spacer(Modifier.height(24.dp))
LanguageSetting()
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it; error = null },
label = { Text("Имя устройства (ник)") },
label = { Text(stringResource(R.string.device_nickname)) },
singleLine = true,
enabled = !saving,
modifier = Modifier.fillMaxWidth()
@@ -57,14 +63,14 @@ fun FirstLaunchScreen(initialName: String, onComplete: suspend (String) -> Unit)
throw e
} catch (e: Exception) {
LogManager.addLog("ERROR", "SETUP", "Failed to save device name: $e")
error = "Не удалось сохранить имя. Попробуйте ещё раз."
error = AppLanguage.text(R.string.device_name_save_failed)
} finally {
saving = false
}
}
}
) {
Text(if (saving) "Сохранение…" else "Продолжить")
Text(stringResource(if (saving) R.string.saving else R.string.continue_action))
}
}
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
@@ -49,9 +51,9 @@ fun IncomingCallDialog(
Spacer(Modifier.height(20.dp))
Text(name, color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Medium)
Spacer(Modifier.height(6.dp))
Text("Входящий звонок…", color = Color(0xFFAEBCC8), fontSize = 14.sp)
Text(stringResource(R.string.incoming_call_waiting), color = Color(0xFFAEBCC8), fontSize = 14.sp)
Spacer(Modifier.height(6.dp))
Text("Подключение: ${st.connectionType.label}", color = Color(0xFFAEBCC8), fontSize = 13.sp)
Text(stringResource(R.string.connection_label, st.connectionType.label), color = Color(0xFFAEBCC8), fontSize = 13.sp)
Spacer(Modifier.height(28.dp))
Row(horizontalArrangement = Arrangement.spacedBy(48.dp)) {
@@ -61,10 +63,10 @@ fun IncomingCallDialog(
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.declineCall() }) {
Icon(Icons.Filled.CallEnd, "Отклонить", tint = Color.White, modifier = Modifier.size(30.dp))
Icon(Icons.Filled.CallEnd, stringResource(R.string.decline), tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Отклонить", color = Color(0xFFAEBCC8), fontSize = 12.sp)
Text(stringResource(R.string.decline), color = Color(0xFFAEBCC8), fontSize = 12.sp)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
@@ -72,10 +74,10 @@ fun IncomingCallDialog(
contentAlignment = Alignment.Center
) {
IconButton(onClick = { viewModel.acceptCall() }) {
Icon(Icons.Filled.Call, "Принять", tint = Color.White, modifier = Modifier.size(30.dp))
Icon(Icons.Filled.Call, stringResource(R.string.accept), tint = Color.White, modifier = Modifier.size(30.dp))
}
}
Text("Принять", color = Color(0xFFAEBCC8), fontSize = 12.sp)
Text(stringResource(R.string.accept), color = Color(0xFFAEBCC8), fontSize = 12.sp)
}
}
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -23,10 +25,11 @@ import com.google.zxing.common.BitMatrix
import com.utun.chat.data.InviteCandidate
import com.utun.chat.data.LogManager
@Composable
private fun candidateLabel(c: InviteCandidate): String =
c.name + when {
c.isAuto -> " (auto)"
c.isSelf -> " (self)"
c.isAuto -> stringResource(R.string.candidate_auto)
c.isSelf -> stringResource(R.string.candidate_self)
else -> ""
}
@@ -50,13 +53,13 @@ fun InviteLinkDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Invite Link", fontWeight = FontWeight.Bold) },
title = { Text(stringResource(R.string.invite_link), fontWeight = FontWeight.Bold) },
text = {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box {
OutlinedButton(onClick = { menuOpen = true }) {
Text(
effectiveSelected?.let { candidateLabel(it) } ?: "Выбор узла...",
effectiveSelected?.let { candidateLabel(it) } ?: stringResource(R.string.select_node),
maxLines = 1
)
}
@@ -79,17 +82,17 @@ fun InviteLinkDialog(
Spacer(modifier = Modifier.height(8.dp))
when {
pending -> Text("Генерация ссылки...", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
pending -> Text(stringResource(R.string.invite_generating), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
link != null -> {
qrBitmap?.let {
Image(bitmap = it.asImageBitmap(), contentDescription = "QR",
Image(bitmap = it.asImageBitmap(), contentDescription = stringResource(R.string.qr_code),
modifier = Modifier.size(200.dp))
Spacer(modifier = Modifier.height(8.dp))
}
Text(link, fontFamily = FontFamily.Monospace, fontSize = 11.sp,
modifier = Modifier.padding(8.dp))
}
else -> Text("Не удалось собрать ссылку", fontFamily = FontFamily.Monospace, fontSize = 11.sp)
else -> Text(stringResource(R.string.invite_build_failed), fontFamily = FontFamily.Monospace, fontSize = 11.sp)
}
}
},
@@ -104,10 +107,10 @@ fun InviteLinkDialog(
LogManager.addLog("INFO", "INVITE", "Link copied to clipboard")
}
}
) { Text(if (copied) "Copied!" else "Copy Link") }
) { Text(stringResource(if (copied) R.string.copied else R.string.copy_link)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Close") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.close)) }
}
)
}
@@ -1,5 +1,8 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
@@ -37,7 +40,7 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
val invite = InviteLink.decode(text)
if (invite == null) {
LogManager.addLog("WARN", "INVITE", "Invalid invite link in join dialog")
error = "Invalid invite link"
error = AppLanguage.text(R.string.invalid_invite_link)
} else {
connecting = true
LogManager.addLog("INFO", "INVITE", "connect: ch=0x%016x node=0x%016x addrs=%d"
@@ -48,7 +51,7 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Join Channel") },
title = { Text(stringResource(R.string.join_channel)) },
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
@@ -63,11 +66,11 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
OutlinedTextField(
value = link,
onValueChange = { link = it },
label = { Text("Invite link") },
label = { Text(stringResource(R.string.invite_link)) },
placeholder = { Text("utun://...") },
trailingIcon = {
IconButton(onClick = { link = clipboard.getText()?.text.orEmpty() }, enabled = !connecting) {
Icon(Icons.Filled.ContentPaste, "Paste invite link")
Icon(Icons.Filled.ContentPaste, stringResource(R.string.paste_invite_link))
}
},
singleLine = true,
@@ -76,10 +79,10 @@ fun JoinChannelDialog(onConnect: (InviteData) -> Unit, onDismiss: () -> Unit) {
modifier = Modifier.fillMaxWidth()
)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
if (connecting) Text("Connecting to peer...", color = MaterialTheme.colorScheme.primary)
if (connecting) Text(stringResource(R.string.connecting_peer), color = MaterialTheme.colorScheme.primary)
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }
)
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -45,8 +47,8 @@ fun LogScreen(onBack: () -> Unit) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Logs") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, "Back") } },
title = { Text(stringResource(R.string.logs)) },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.Default.ArrowBack, stringResource(R.string.back)) } },
actions = {
for ((lvl, label) in arrayOf("" to "ALL", "ERROR" to "ERR", "WARN" to "WRN", "INFO" to "INF", "DEBUG" to "DBG", "TRACE" to "TRC")) {
val selected = filterLevel == lvl
@@ -1,5 +1,8 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
@@ -71,9 +74,9 @@ fun MemberListScreen(
Column(modifier = Modifier.fillMaxSize().navigationBarsPadding()) {
TopAppBar(
title = { Text(if (channelOwner) "Members 🔑 (${members.size})" else "Members (${members.size})") },
title = { Text(stringResource(if (channelOwner) R.string.members_owner_count else R.string.members_count, members.size)) },
navigationIcon = {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") }
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) }
}
)
@@ -184,23 +187,23 @@ private fun MemberListItem(
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (member.isSuper) {
Icon(Icons.Filled.Bolt, "Supernode", tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
Icon(Icons.Filled.Bolt, stringResource(R.string.supernode), tint = Color(0xFFFFC107), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (member.isAdmin) {
Icon(Icons.Filled.Star, "Admin", tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
Icon(Icons.Filled.Star, stringResource(R.string.admin), tint = Color(0xFFFFD700), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (member.isModer) {
Icon(Icons.Filled.Shield, "Moderator", tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Icon(Icons.Filled.Shield, stringResource(R.string.moderator), tint = Color(0xFF79B8FF), modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (member.isStorage) {
Icon(Icons.Filled.Storage, "Storage", tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Icon(Icons.Filled.Storage, stringResource(R.string.storage), tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
if (!member.isVerified) {
Icon(Icons.Filled.Close, "Not verified", tint = Color.Red, modifier = Modifier.size(16.dp))
Icon(Icons.Filled.Close, stringResource(R.string.not_verified), tint = Color.Red, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
}
Text(
@@ -215,12 +218,12 @@ private fun MemberListItem(
)
}
val connectionLabel = when {
member.isSelf -> "self"
member.directUp -> "D" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
member.bgpPresence -> "B" + if (member.bgpRtt > 0) ":${member.bgpRtt / 10}ms" else ""
member.indirectUp -> "indirect↑" + if (member.rtt > 0) ":${member.rtt / 10}ms" else ""
member.connecting -> "connecting…"
else -> "offline"
member.isSelf -> stringResource(R.string.self)
member.directUp -> "D" + if (member.rtt > 0) stringResource(R.string.rtt_suffix, member.rtt / 10) else ""
member.bgpPresence -> "B" + if (member.bgpRtt > 0) stringResource(R.string.rtt_suffix, member.bgpRtt / 10) else ""
member.indirectUp -> stringResource(R.string.indirect_up) + if (member.rtt > 0) stringResource(R.string.rtt_suffix, member.rtt / 10) else ""
member.connecting -> stringResource(R.string.connecting)
else -> stringResource(R.string.offline)
}
Text(
text = connectionLabel, fontSize = 11.sp,
@@ -229,8 +232,8 @@ private fun MemberListItem(
}
if (!member.isSelf) {
IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, "Call") }
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, "Message") }
IconButton(onClick = onCall, enabled = member.online) { Icon(Icons.Filled.Call, stringResource(R.string.call)) }
IconButton(onClick = onMessage) { Icon(Icons.AutoMirrored.Filled.Message, stringResource(R.string.message)) }
}
}
}
@@ -291,26 +294,26 @@ private fun MemberDetailCard(
if (!isSelf) {
Spacer(Modifier.height(2.dp))
if (detail.directPresence) {
Text("Direct: ${if (detail.directUp) "UP" else "DOWN"}" +
(if (detail.directUp && detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
Text(stringResource(R.string.direct_state, if (detail.directUp) "UP" else "DOWN") +
(if (detail.directUp && detail.bestRtt > 0) stringResource(R.string.rtt_spaced, detail.bestRtt / 10) else ""),
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
}
if (detail.indirectPresence) {
Text("Indirect: ${if (detail.indirectUp) "UP" else "DOWN"}",
Text(stringResource(R.string.indirect_state, if (detail.indirectUp) "UP" else "DOWN"),
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
}
if (detail.bgpPresence && detail.bgpNodes > 0) {
Text("BGP: ${detail.bgpNodes} nodes" +
(if (detail.bestRtt > 0) " ${detail.bestRtt / 10}ms" else ""),
Text(pluralStringResource(R.plurals.bgp_nodes, detail.bgpNodes, detail.bgpNodes) +
(if (detail.bestRtt > 0) stringResource(R.string.rtt_spaced, detail.bestRtt / 10) else ""),
fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurface)
}
if (!detail.directPresence && !detail.indirectPresence && !detail.bgpPresence) {
Text("offline", fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.offline), fontSize = 13.sp, lineHeight = 16.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text("Connect") }
TextButton(onClick = onEdit, enabled = canEdit) { Text("Edit") }
if (!isSelf) TextButton(onClick = onConnect, enabled = !detail.directUp) { Text(stringResource(R.string.connect)) }
TextButton(onClick = onEdit, enabled = canEdit) { Text(stringResource(R.string.edit)) }
}
Spacer(Modifier.height(4.dp))
@@ -318,20 +321,20 @@ private fun MemberDetailCard(
if (detail.lastSeen > 0) {
val sec = System.currentTimeMillis() / 1000 - detail.lastSeen
val ago = when {
sec < 60 -> "${sec}s"
sec < 3600 -> "${sec / 60}m"
sec < 86400 -> "${sec / 3600}h"
else -> "${sec / 86400}d"
sec < 60 -> stringResource(R.string.seconds_short, sec)
sec < 3600 -> stringResource(R.string.minutes_short, sec / 60)
sec < 86400 -> stringResource(R.string.hours_short, sec / 3600)
else -> stringResource(R.string.days_short, sec / 86400)
}
headerParts.add("seen $ago")
headerParts.add(stringResource(R.string.last_seen_ago, ago))
}
if (detail.created > 0) {
val sec = System.currentTimeMillis() / 1000 - detail.created
val ago = when {
sec < 604800 -> "${sec / 86400}d"
else -> "${sec / 2592000}mo"
sec < 604800 -> stringResource(R.string.days_short, sec / 86400)
else -> stringResource(R.string.months_short, sec / 2592000)
}
headerParts.add("created $ago")
headerParts.add(stringResource(R.string.created_ago, ago))
}
Text(
text = headerParts.joinToString(" · "),
@@ -343,7 +346,7 @@ private fun MemberDetailCard(
if (isSelf) {
if (localSocketStates.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text("Listen sockets:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.listen_sockets), fontSize = 13.sp, fontWeight = FontWeight.Medium)
localSocketStates.forEach { sk ->
val dot = if (sk.state == 2) Color(0xFF81C784) else Color(0xFFAEBCC8)
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -361,7 +364,7 @@ private fun MemberDetailCard(
} else {
if (detail.addresses.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text("Addresses:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.addresses), fontSize = 13.sp, fontWeight = FontWeight.Medium)
detail.addresses.forEachIndexed { idx, a ->
Text(
text = "[sock${a.socketId}] ${a.protocol} ${a.address}:${a.port} ${a.roleLabel}",
@@ -376,7 +379,7 @@ private fun MemberDetailCard(
if (memberLinks.links.isNotEmpty()) {
Spacer(Modifier.height(6.dp))
Text("Connections:", fontSize = 13.sp, fontWeight = FontWeight.Medium)
Text(stringResource(R.string.connections), fontSize = 13.sp, fontWeight = FontWeight.Medium)
memberLinks.links.forEachIndexed { idx, lk ->
val dcol = if (lk.linkState == 3 && lk.linkStatus == 1) Color(0xFF81C784) else Color(0xFFFFB4AB)
val arrow = if (lk.isServer) "◀" else "▶"
@@ -440,13 +443,13 @@ private fun MemberEditDialog(
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Edit ${member.name}") },
title = { Text(stringResource(R.string.edit_member, member.name)) },
text = {
Column {
OutlinedTextField(
value = nick,
onValueChange = { nick = it },
label = { Text("Nickname") },
label = { Text(stringResource(R.string.nickname)) },
enabled = !member.isSelf,
modifier = Modifier.fillMaxWidth(),
singleLine = true
@@ -454,27 +457,27 @@ private fun MemberEditDialog(
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = admin, onCheckedChange = { admin = it }, enabled = isOwner)
Text("Admin", modifier = Modifier.clickable(enabled = isOwner) { admin = !admin })
Text(stringResource(R.string.admin), modifier = Modifier.clickable(enabled = isOwner) { admin = !admin })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = supernode, onCheckedChange = { supernode = it }, enabled = isOwner)
Text("Supernode", modifier = Modifier.clickable(enabled = isOwner) { supernode = !supernode })
Text(stringResource(R.string.supernode), modifier = Modifier.clickable(enabled = isOwner) { supernode = !supernode })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = moder, onCheckedChange = { moder = it }, enabled = isOwner)
Text("Moderator", modifier = Modifier.clickable(enabled = isOwner) { moder = !moder })
Text(stringResource(R.string.moderator), modifier = Modifier.clickable(enabled = isOwner) { moder = !moder })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = storage, onCheckedChange = { storage = it }, enabled = isOwner)
Text("Storage", modifier = Modifier.clickable(enabled = isOwner) { storage = !storage })
Text(stringResource(R.string.storage), modifier = Modifier.clickable(enabled = isOwner) { storage = !storage })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = verified, onCheckedChange = { verified = it }, enabled = isOwner)
Text("Verified", modifier = Modifier.clickable(enabled = isOwner) { verified = !verified })
Text(stringResource(R.string.verified), modifier = Modifier.clickable(enabled = isOwner) { verified = !verified })
}
Row(verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = deleted, onCheckedChange = { deleted = it }, enabled = isOwner)
Text("Deleted", modifier = Modifier.clickable(enabled = isOwner) { deleted = !deleted })
Text(stringResource(R.string.deleted), modifier = Modifier.clickable(enabled = isOwner) { deleted = !deleted })
}
if (isOwner && !member.isSelf) {
Spacer(Modifier.height(12.dp))
@@ -482,12 +485,12 @@ private fun MemberEditDialog(
onClick = { showTransferConfirm = true },
modifier = Modifier.fillMaxWidth()
) {
Text("Transfer admin rights")
Text(stringResource(R.string.transfer_admin_rights))
}
} else if (!isOwner) {
Spacer(Modifier.height(4.dp))
Text(
"Admin/Super require channel private key",
stringResource(R.string.admin_requires_key),
fontSize = 11.sp,
color = Color(0xFFAEBCC8)
)
@@ -495,29 +498,27 @@ private fun MemberEditDialog(
}
},
confirmButton = {
TextButton(onClick = { onSave(nick, admin, supernode, moder, storage, verified, deleted) }) { Text("Save") }
TextButton(onClick = { onSave(nick, admin, supernode, moder, storage, verified, deleted) }) { Text(stringResource(R.string.save)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
}
)
if (showTransferConfirm) {
AlertDialog(
onDismissRequest = { showTransferConfirm = false },
title = { Text("Transfer admin rights") },
title = { Text(stringResource(R.string.transfer_admin_rights)) },
text = {
Text(
"Send the channel private key to \"${member.name}\"?\n\n" +
"The key is transferred encrypted and signed. The recipient will become a co-owner " +
"and will be able to manage members."
stringResource(R.string.transfer_key_confirm, member.name)
)
},
confirmButton = {
TextButton(onClick = { showTransferConfirm = false; onTransferAdmin() }) { Text("Send") }
TextButton(onClick = { showTransferConfirm = false; onTransferAdmin() }) { Text(stringResource(R.string.send)) }
},
dismissButton = {
TextButton(onClick = { showTransferConfirm = false }) { Text("Cancel") }
TextButton(onClick = { showTransferConfirm = false }) { Text(stringResource(R.string.cancel)) }
}
)
}
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.Manifest
import android.content.pm.PackageManager
import android.graphics.Bitmap
@@ -38,6 +40,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.utun.chat.data.CameraLensManager
import com.utun.chat.data.LensType
import com.utun.chat.data.LensOption
import com.utun.chat.data.LogManager
import com.utun.chat.data.PhotoResolution
@@ -187,7 +190,7 @@ fun PhotoRecordScreen(
providerRef.value = provider
val list = CameraLensManager.enumerate(provider)
lenses = list
val default = list.firstOrNull { it.type.name == "SELFIE" } ?: list.firstOrNull()
val default = list.firstOrNull { it.type == LensType.SELFIE } ?: list.firstOrNull()
if (default != null) {
selectLens(default)
}
@@ -206,9 +209,9 @@ fun PhotoRecordScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Фото") },
title = { Text(stringResource(R.string.photo)) },
navigationIcon = {
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, "Закрыть") }
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, stringResource(R.string.close)) }
},
actions = {
if (boundCamera?.cameraInfo?.hasFlashUnit() == true) {
@@ -217,7 +220,7 @@ fun PhotoRecordScreen(
try { boundCamera?.cameraControl?.enableTorch(next) } catch (_: Exception) {}
torchOn = next
}) {
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, "Подсветка", tint = if (torchOn) Color(0xFFFFD600) else Color.White)
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, stringResource(R.string.camera_light), tint = if (torchOn) Color(0xFFFFD600) else Color.White)
}
}
}
@@ -227,9 +230,9 @@ fun PhotoRecordScreen(
Box(Modifier.fillMaxSize().background(Color.Black).padding(padding)) {
if (!hasCamera) {
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Нужно разрешение камеры", color = Color.White, fontSize = 16.sp)
Text(stringResource(R.string.camera_permission_required), color = Color.White, fontSize = 16.sp)
Spacer(Modifier.height(12.dp))
Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text("Камера") }
Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.camera)) }
}
} else if (phase == PhotoPhase.CAPTURED) {
Column(Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
@@ -312,7 +315,7 @@ fun PhotoRecordScreen(
.clickable { discardAndReset() },
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.Delete, "Переснять", tint = Color.White, modifier = Modifier.size(26.dp))
Icon(Icons.Filled.Delete, stringResource(R.string.retake), tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(28.dp))
Box(
@@ -323,7 +326,7 @@ fun PhotoRecordScreen(
},
contentAlignment = Alignment.Center
) {
Icon(Icons.AutoMirrored.Filled.Send, "Отправить", tint = Color.White, modifier = Modifier.size(26.dp))
Icon(Icons.AutoMirrored.Filled.Send, stringResource(R.string.send), tint = Color.White, modifier = Modifier.size(26.dp))
}
} else {
/* shutter */
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
@@ -76,7 +78,7 @@ fun PhotoViewerScreen(filePath: String, onClose: () -> Unit) {
horizontalArrangement = Arrangement.End
) {
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, "Закрыть", tint = Color.White)
Icon(Icons.Filled.Close, stringResource(R.string.close), tint = Color.White)
}
}
}
@@ -1,5 +1,8 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import androidx.compose.ui.res.stringResource
import android.Manifest
import android.app.Activity
import android.content.ContextWrapper
@@ -83,7 +86,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
Box(modifier, contentAlignment = Alignment.Center) {
if (!hasPermission) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Camera permission required")
Text(stringResource(R.string.camera_permission_required))
TextButton(onClick = {
if (permanentDenial) {
try {
@@ -93,7 +96,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
LogManager.addLog("ERROR", "QR", "Cannot open camera permission settings: $e")
}
} else launcher.launch(Manifest.permission.CAMERA)
}) { Text(if (permanentDenial) "Settings" else "Grant") }
}) { Text(stringResource(if (permanentDenial) R.string.settings else R.string.grant)) }
}
} else {
val previewView = remember { PreviewView(context).apply { implementationMode = PreviewView.ImplementationMode.COMPATIBLE } }
@@ -127,7 +130,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
.addOnFailureListener(mainExecutor) { e ->
if (!disposed.get()) {
LogManager.addLog("ERROR", "QR", "QR scan failed: $e")
cameraError = "QR scanning failed. Paste an invite link."
cameraError = AppLanguage.text(R.string.qr_scan_failed)
analysis.clearAnalyzer()
}
}
@@ -137,7 +140,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
imageProxy.close()
mainExecutor.execute {
if (!disposed.get()) {
cameraError = "QR scanning failed. Paste an invite link."
cameraError = AppLanguage.text(R.string.qr_scan_failed)
analysis.clearAnalyzer()
}
}
@@ -153,7 +156,7 @@ fun QrScannerPreview(modifier: Modifier = Modifier, onResult: (String) -> Unit)
LogManager.addLog("INFO", "QR", "Join dialog camera started")
} catch (e: Exception) {
LogManager.addLog("ERROR", "QR", "Camera initialization failed: $e")
cameraError = "Camera unavailable. Paste an invite link."
cameraError = AppLanguage.text(R.string.camera_unavailable)
}
}
}, mainExecutor)
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
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.rememberScrollState
@@ -10,6 +12,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -26,6 +29,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.LanguageSetting
import com.utun.chat.viewmodel.ChatViewModel
import android.content.Intent
import android.net.Uri
@@ -55,10 +59,10 @@ private val PROTOCOLS = listOf("tcp", "udp")
fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {}) {
val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope()
var dirty by remember { mutableStateOf(false) }
var dirty by rememberSaveable { mutableStateOf(false) }
var tabIndex by remember { mutableStateOf(0) }
val tabs = listOf("Settings", "Audio", "Storage", "Radio")
var tabIndex by rememberSaveable { mutableStateOf(0) }
val tabs = listOf(R.string.settings, R.string.audio, R.string.storage, R.string.radio)
val handleBack = {
if (dirty && NativeLib.isReady()) {
@@ -75,15 +79,15 @@ fun SettingsScreen(vm: ChatViewModel, onBack: () -> Unit, onExit: () -> Unit = {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Settings") },
navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back") } }
title = { Text(stringResource(R.string.settings)) },
navigationIcon = { IconButton(onClick = handleBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.back)) } }
)
}
) { padding ->
Column(modifier = Modifier.padding(padding)) {
TabRow(selectedTabIndex = tabIndex) {
ScrollableTabRow(selectedTabIndex = tabIndex, edgePadding = 0.dp) {
tabs.forEachIndexed { idx, name ->
Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(name) })
Tab(selected = tabIndex == idx, onClick = { tabIndex = idx }, text = { Text(stringResource(name), maxLines = 1) })
}
}
when (tabIndex) {
@@ -187,12 +191,14 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
LanguageSetting()
/* Node */
Text("Node", style = MaterialTheme.typography.titleMedium)
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("Node Name") }, singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
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("Listen Port") }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
label = { Text(stringResource(R.string.listen_port)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true, modifier = Modifier.heightIn(min = 40.dp))
/* Control interface */
@@ -201,7 +207,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
Row(verticalAlignment = Alignment.CenterVertically) {
ExposedDropdownMenuBox(expanded = ctrlExpanded, onExpandedChange = { ctrlExpanded = it }, modifier = Modifier.weight(1f)) {
OutlinedTextField(ctrlBindLabel, {}, readOnly = true, singleLine = true,
label = { Text("Control Bind IP") },
label = { Text(stringResource(R.string.control_bind_ip)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(ctrlExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(ctrlExpanded, onDismissRequest = { ctrlExpanded = false }) {
@@ -215,37 +221,37 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
}
Spacer(Modifier.width(8.dp))
OutlinedTextField(controlPort, { v -> onDirty(true); controlPort = v; scope.launch { provider.setValue("control.port", v) } },
label = { Text("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
/* Keys */
Text("Keys", style = MaterialTheme.typography.titleMedium)
OutlinedTextField(pubKey, {}, label = { Text("Public Key") }, readOnly = true, singleLine = true)
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(if (generatingKeys) "Generating..." else "Generate Keys") }
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("Clear DB")
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))
/* ── Connection Mode ── */
Text("Network Interfaces", style = MaterialTheme.typography.titleMedium)
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("Auto") })
label = { Text(stringResource(R.string.automatic)) })
FilterChip(selected = connectionMode == "manual", onClick = { if (connectionMode != "manual") switchMode("manual") },
label = { Text("Manual") })
label = { Text(stringResource(R.string.manual)) })
}
if (connectionMode == "auto") {
/* Auto mode: read-only live socket display */
if (localSocketStates.isEmpty()) {
Text("No sockets detected", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(stringResource(R.string.no_sockets), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
localSocketStates.forEach { s ->
val stateColor = when (s.state) { 2 -> Color(0xFF81C784); 1 -> Color(0xFFF1C40F); else -> Color(0xFFFFB4AB) }
@@ -292,7 +298,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
Text(srv.bindIp, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodySmall,
maxLines = 1, modifier = Modifier.width(105.dp))
IconButton(onClick = { save(servers.toMutableList().also { it.removeAt(idx) }) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Default.Close, "Remove", modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
Icon(Icons.Default.Close, stringResource(R.string.remove), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.error)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -323,42 +329,42 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
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("Add interface") }
}, 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))
/* ── Standby (background duty-cycle) ── */
Text("Standby", style = MaterialTheme.typography.titleMedium)
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("Active (sec)") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
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("Sleep (sec)") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp),
label = { Text(stringResource(R.string.standby_sleep_seconds)) }, modifier = Modifier.weight(1f).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("Min sleep (sec)") }, modifier = Modifier.heightIn(min = 40.dp),
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("Keepalive in sleep (ms)") }, modifier = Modifier.heightIn(min = 40.dp),
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))
/* ── Chat ── */
Text("Chat", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.chat), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Auto-connect to group nodes", modifier = Modifier.weight(1f))
Text(stringResource(R.string.group_autoconnect), modifier = Modifier.weight(1f))
Switch(checked = autoConnect, onCheckedChange = {
autoConnect = it
val s = if (it) "1" else "0"
@@ -370,31 +376,31 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
HorizontalDivider(Modifier.padding(vertical = 8.dp))
/* Debug */
Text("Debug", style = MaterialTheme.typography.titleMedium)
DebugLevelDropdown(label = "Global Level", current = debugLevel.uppercase(), onSelect = setDebug)
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("Log File") }, singleLine = true)
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("UDP Log IP") }, modifier = Modifier.weight(1f).heightIn(min = 40.dp), singleLine = true)
}, 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("Port") }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
}, label = { Text(stringResource(R.string.port)) }, modifier = Modifier.width(80.dp).heightIn(min = 40.dp),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true)
}
Row(modifier = Modifier.fillMaxWidth().clickable { showDebugCategories = !showDebugCategories },
horizontalArrangement = Arrangement.SpaceBetween) {
Text("Categories", style = MaterialTheme.typography.titleMedium)
Icon(Icons.Default.ArrowDropDown, if (showDebugCategories) "Collapse" else "Expand", modifier = Modifier.padding(end = 4.dp))
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 ->
val current = catLevels[cat] ?: ""
DebugLevelDropdown(label = cat, current = current.ifEmpty { "(global)" },
DebugLevelDropdown(label = cat, current = current.ifEmpty { stringResource(R.string.global_level_hint) },
onSelect = { setCatLevel(cat, if (it == "NONE") "" else it) }, compact = true)
}
}
@@ -402,21 +408,21 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
/* ── Background service ── */
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Text("Service", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.service), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text(if (serviceEnabled) "Running" else "Stopped", modifier = Modifier.weight(1f),
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("Start service") }
}, 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("Stop service") }
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.service_stop)) }
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -429,7 +435,7 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
}
}
}, 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("Restart instance")
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 = {
@@ -438,21 +444,21 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
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("Stop All & Exit")
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))
}
/* Dialogs */
if (showOverwriteKeys) AlertDialog(
onDismissRequest = { showOverwriteKeys = false }, title = { Text("Overwrite Keys?") },
text = { Text("Keys already exist. Generating new keys will invalidate all existing connections and channels.") },
confirmButton = { Button(onClick = { showOverwriteKeys = false; onDirty(true); vm.requestRegenerateKeys() }) { Text("Overwrite") } },
dismissButton = { TextButton(onClick = { showOverwriteKeys = false }) { Text("Cancel") } })
onDismissRequest = { showOverwriteKeys = false }, title = { Text(stringResource(R.string.overwrite_keys_question)) },
text = { Text(stringResource(R.string.overwrite_keys_warning)) },
confirmButton = { Button(onClick = { showOverwriteKeys = false; onDirty(true); vm.requestRegenerateKeys() }) { Text(stringResource(R.string.overwrite)) } },
dismissButton = { TextButton(onClick = { showOverwriteKeys = false }) { Text(stringResource(R.string.cancel)) } })
if (showClearDb) AlertDialog(
onDismissRequest = { showClearDb = false }, title = { Text("Clear Database?") },
text = { Text("This will delete all channels, messages, and peers.\nThis cannot be undone.") },
onDismissRequest = { showClearDb = false }, title = { Text(stringResource(R.string.clear_db_question)) },
text = { Text(stringResource(R.string.clear_db_warning)) },
confirmButton = { Button(onClick = {
showClearDb = false; clearingDb = true
scope.launch(Dispatchers.IO) {
@@ -465,8 +471,8 @@ private fun GeneralSettingsTab(vm: ChatViewModel, dirty: Boolean, onDirty: (Bool
}
withContext(Dispatchers.Main) { clearingDb = false }
}
}, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text("Delete All") } },
dismissButton = { TextButton(onClick = { showClearDb = false }) { Text("Cancel") } })
}, enabled = !clearingDb, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error)) { Text(stringResource(R.string.delete_all)) } },
dismissButton = { TextButton(onClick = { showClearDb = false }) { Text(stringResource(R.string.cancel)) } })
}
/* ── Radio Settings Tab ── */
@@ -485,9 +491,9 @@ private fun RadioSettingsTab() {
var vadThreshold by remember { mutableStateOf(provider.getInt("radio.vad_threshold")) }
var vadHangover by remember { mutableStateOf(provider.getInt("radio.vad_hangover_ms")) }
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
Text("Автоматическая передача по голосу (VAD)", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.radio_vad), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Включить VAD", modifier = Modifier.weight(1f))
Text(stringResource(R.string.enable_vad), modifier = Modifier.weight(1f))
Switch(checked = vadEnabled, onCheckedChange = { enabled ->
vadEnabled = enabled
scope.launch {
@@ -498,11 +504,11 @@ private fun RadioSettingsTab() {
}
})
}
Text(if (vadActive) "VAD активен: микрофон включён" else "VAD неактивен",
Text(stringResource(if (vadActive) R.string.vad_active else R.string.vad_inactive),
style = MaterialTheme.typography.bodySmall)
Text("Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически.",
Text(stringResource(R.string.vad_screen_hint),
style = MaterialTheme.typography.bodySmall)
LabeledSlider("Порог речи:", vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%",
LabeledSlider(stringResource(R.string.speech_threshold), vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%",
{ vadThreshold = it.toInt() }, onValueChangeFinished = {
scope.launch {
provider.setValue("radio.vad_threshold", vadThreshold.toString())
@@ -510,7 +516,7 @@ private fun RadioSettingsTab() {
RadioAudioEngine.instance().reconfigure()
}
})
LabeledSlider("Задержка после тишины:", vadHangover.toFloat(), 50f..2000f, 38, "$vadHangover мс",
LabeledSlider(stringResource(R.string.silence_delay), vadHangover.toFloat(), 50f..2000f, 38, stringResource(R.string.milliseconds, vadHangover),
{ vadHangover = it.toInt() }, onValueChangeFinished = {
scope.launch {
provider.setValue("radio.vad_hangover_ms", vadHangover.toString())
@@ -523,7 +529,7 @@ private fun RadioSettingsTab() {
/* ── Volume-кнопки PTT ── */
var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Кнопки громкости (удержание) — передача", modifier = Modifier.weight(1f))
Text(stringResource(R.string.volume_ptt), modifier = Modifier.weight(1f))
Switch(checked = volumePtt, onCheckedChange = {
volumePtt = it
scope.launch { provider.setRadioVolumePtt(it) }
@@ -538,9 +544,9 @@ private fun RadioSettingsTab() {
val overlayStatus by RadioOverlayService.status.collectAsState()
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("PTT-кнопка поверх приложений")
Text(stringResource(R.string.ptt_overlay))
if (overlayEnabled) Text(
overlayStatus ?: if (overlayShown) "Плавающая кнопка активна" else "Ожидание включения",
overlayStatus?.let { stringResource(it) } ?: stringResource(if (overlayShown) R.string.overlay_active else R.string.waiting_enable),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.clickable { RadioOverlayService.requestPermissions() }
@@ -555,11 +561,11 @@ private fun RadioSettingsTab() {
/* ── Источник микрофона ── */
val micSources = listOf(
"Голос (VOICE_COMMUNICATION)", "Микрофон (MIC)",
"Без обработки (UNPROCESSED)", "Камера/видео (CAMCORDER)")
stringResource(R.string.mic_source_voice), stringResource(R.string.mic_source_mic),
stringResource(R.string.mic_source_raw), stringResource(R.string.mic_source_camera))
var micSource by remember { mutableStateOf(provider.getRadioMicSource().coerceIn(0, micSources.size - 1)) }
var micExpanded by remember { mutableStateOf(false) }
Text("Источник микрофона", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.microphone_source), style = MaterialTheme.typography.titleMedium)
ExposedDropdownMenuBox(expanded = micExpanded, onExpandedChange = { micExpanded = it }) {
OutlinedTextField(micSources[micSource], {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(micExpanded) },
@@ -584,8 +590,8 @@ private fun RadioSettingsTab() {
var captureStereo by remember { mutableStateOf(provider.getRadioCaptureStereo()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Захват стерео")
Text("Воспроизведение всегда стерео; моно-пиры апмиксуются",
Text(stringResource(R.string.stereo_capture))
Text(stringResource(R.string.stereo_playback_hint),
style = MaterialTheme.typography.bodySmall)
}
Switch(checked = captureStereo, onCheckedChange = {
@@ -603,9 +609,9 @@ private fun RadioSettingsTab() {
/* ── Компрессор (AGC) рации ── */
var radioCompOn by remember { mutableStateOf(provider.getInt("radio.compressor_enabled") != 0) }
var radioCompMaxGain by remember { mutableStateOf(provider.getInt("radio.compressor_max_gain_db")) }
Text("Компрессор (AGC) рации", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.radio_compressor), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Enable compressor", modifier = Modifier.weight(1f))
Text(stringResource(R.string.enable_compressor), modifier = Modifier.weight(1f))
Switch(checked = radioCompOn, onCheckedChange = {
radioCompOn = it
scope.launch { provider.setValue("radio.compressor_enabled", if (it) "1" else "0") }
@@ -613,7 +619,7 @@ private fun RadioSettingsTab() {
RadioAudioEngine.instance().reconfigure()
})
}
LabeledSlider("Max gain:", radioCompMaxGain.toFloat(), 1f..45f, 44, "${radioCompMaxGain} dB",
LabeledSlider(stringResource(R.string.max_gain), radioCompMaxGain.toFloat(), 1f..45f, 44, "${radioCompMaxGain} dB",
{ v ->
radioCompMaxGain = v.toInt()
NativeLib.setChatSetting("radio_compressor_max_gain_db", radioCompMaxGain.toString())
@@ -626,13 +632,13 @@ private fun RadioSettingsTab() {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Opus для рации: режим + битрейт ── */
val opusModes = listOf("Голос (VOIP)", "Музыка (AUDIO)")
val opusModes = listOf(stringResource(R.string.opus_voice), stringResource(R.string.opus_music))
val opusBitrates = listOf(16, 32, 64, 96, 128)
var opusMode by remember { mutableStateOf(provider.getInt("radio.opus_mode").coerceIn(0, 1)) }
var opusModeExpanded by remember { mutableStateOf(false) }
var opusBitrate by remember { mutableStateOf(provider.getInt("radio.opus_bitrate")) }
var opusBitrateExpanded by remember { mutableStateOf(false) }
Text("Opus (рация)", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.radio_opus), style = MaterialTheme.typography.titleMedium)
ExposedDropdownMenuBox(expanded = opusModeExpanded, onExpandedChange = { opusModeExpanded = it }) {
OutlinedTextField(opusModes[opusMode], {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusModeExpanded) },
@@ -650,12 +656,12 @@ private fun RadioSettingsTab() {
}
}
ExposedDropdownMenuBox(expanded = opusBitrateExpanded, onExpandedChange = { opusBitrateExpanded = it }) {
OutlinedTextField("${opusBitrate} kbps", {}, readOnly = true, singleLine = true,
OutlinedTextField(stringResource(R.string.bitrate_kbps, opusBitrate), {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(opusBitrateExpanded) },
modifier = Modifier.menuAnchor().heightIn(min = 40.dp))
ExposedDropdownMenu(opusBitrateExpanded, onDismissRequest = { opusBitrateExpanded = false }) {
opusBitrates.forEach { b ->
DropdownMenuItem(text = { Text("$b kbps") }, onClick = {
DropdownMenuItem(text = { Text(stringResource(R.string.bitrate_kbps, b)) }, onClick = {
opusBitrate = b
scope.launch { provider.setValue("radio.opus_bitrate", b.toString()) }
NativeLib.setChatSetting("radio_opus_bitrate", b.toString())
@@ -690,7 +696,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
}
/* Codec preset */
val presets = listOf("Low (16 kbps)", "Standard (32 kbps)", "High (64 kbps)")
val presets = listOf(stringResource(R.string.codec_low), stringResource(R.string.codec_standard), stringResource(R.string.codec_high))
var preset by remember { mutableStateOf(vm.getVoicePreset()) }
var presetExpanded by remember { mutableStateOf(false) }
@@ -725,9 +731,9 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
/* ── Notification sound ── */
Text("Notification sound", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.notification_sound), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Play sound on incoming message", modifier = Modifier.weight(1f))
Text(stringResource(R.string.incoming_message_sound), modifier = Modifier.weight(1f))
Switch(checked = soundEnabled, onCheckedChange = {
soundEnabled = it
NotificationSound.enabled = it
@@ -738,7 +744,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Входящие звонки поверх блокировки ── */
Text("Incoming calls", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.incoming_calls), style = MaterialTheme.typography.titleMedium)
var fsiEnabled by remember { mutableStateOf(IncomingCallManager.canFullScreenIntent()) }
LifecycleResumeEffect(Unit) {
fsiEnabled = IncomingCallManager.canFullScreenIntent()
@@ -761,12 +767,12 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
verticalAlignment = Alignment.CenterVertically
) {
Column(Modifier.weight(1f)) {
Text("Show over lock screen")
Text(stringResource(R.string.show_over_lock_screen))
Text(
when {
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> "Always allowed"
fsiEnabled -> "Allowed"
else -> "Disabled — tap to enable"
Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> stringResource(R.string.always_allowed)
fsiEnabled -> stringResource(R.string.allowed)
else -> stringResource(R.string.disabled_tap_enable)
},
style = MaterialTheme.typography.bodySmall,
color = if (fsiEnabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error
@@ -777,7 +783,7 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Microphone test ── */
Text("Microphone test", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.microphone_test), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val lvlPct = micLevel.coerceIn(0f, 1f)
LinearProgressIndicator(
@@ -792,13 +798,13 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
if (vm.audioRecorder.startMicTest()) { micTesting = true; micLevel = 0f }
}
apply()
}) { Text(if (micTesting) "Stop" else "Test") }
}) { Text(stringResource(if (micTesting) R.string.stop else R.string.test)) }
}
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Voice codec quality ── */
Text("Voice codec quality", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.voice_codec_quality), style = MaterialTheme.typography.titleMedium)
ExposedDropdownMenuBox(expanded = presetExpanded, onExpandedChange = { presetExpanded = it }) {
OutlinedTextField(presets[preset], {}, readOnly = true, singleLine = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(presetExpanded) },
@@ -812,8 +818,8 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
HorizontalDivider(Modifier.padding(vertical = 4.dp))
Text("Звонки: максимальный запас джиттер-буфера", style = MaterialTheme.typography.titleMedium)
Text("${"%.1f".format(jitterReserveMs / 1000.0)} с · применяется к следующему звонку")
Text(stringResource(R.string.call_max_jitter_reserve), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.call_jitter_hint, "%.1f".format(jitterReserveMs / 1000.0)))
Slider(
value = jitterReserveMs.toFloat(),
onValueChange = { jitterReserveMs = (it / 100).toInt() * 100 },
@@ -826,21 +832,21 @@ private fun AudioSettingsTab(vm: ChatViewModel) {
)
/* ── Voice Compressor ── */
Text("Voice Compressor", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.voice_compressor), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Enable compressor", modifier = Modifier.weight(1f))
Text(stringResource(R.string.enable_compressor), modifier = Modifier.weight(1f))
Switch(checked = compressorOn, onCheckedChange = { compressorOn = it; apply() })
}
LabeledSlider("Max gain:", maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB",
LabeledSlider(stringResource(R.string.max_gain), maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB",
{ maxGainDb = it.toInt(); apply() })
LabeledSlider("Lookback time:", lookbackMs.toFloat(), 50f..500f, 45, "${lookbackMs} ms",
LabeledSlider(stringResource(R.string.lookback_time), lookbackMs.toFloat(), 50f..500f, 45, stringResource(R.string.milliseconds, lookbackMs),
{ lookbackMs = it.toInt(); apply() })
LabeledSlider("Lookahead time:", lookaheadMs.toFloat(), 20f..500f, 48, "${lookaheadMs} ms",
LabeledSlider(stringResource(R.string.lookahead_time), lookaheadMs.toFloat(), 20f..500f, 48, stringResource(R.string.milliseconds, lookaheadMs),
{ lookaheadMs = it.toInt(); apply() })
LabeledSlider("Rise rate (x/sec):", riseRate.toFloat(), 2f..10f, 8, "${riseRate}x",
LabeledSlider(stringResource(R.string.rise_rate), riseRate.toFloat(), 2f..10f, 8, "${riseRate}x",
{ riseRate = it.toInt(); apply() })
LabeledSlider("Target level:", targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS",
LabeledSlider(stringResource(R.string.target_level), targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS",
{ targetLevelDb = it.toInt(); apply() })
}
}
@@ -866,9 +872,9 @@ private fun StorageSettingsTab() {
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text("Media Storage", style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.media_storage), style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Autoload media", modifier = Modifier.weight(1f))
Text(stringResource(R.string.autoload_media), modifier = Modifier.weight(1f))
Switch(checked = autoload, onCheckedChange = {
autoload = it
val s = if (it) "1" else "0"
@@ -881,13 +887,13 @@ private fun StorageSettingsTab() {
OutlinedTextField(autoloadMaxSizeMb,
{ v -> autoloadMaxSizeMb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..500) { scope.launch { provider.setValue("chat.storage_autoload_maxsize_mb", v) }; NativeLib.setChatSetting("storage_autoload_maxsize_mb", v) } },
label = { Text("Autoload max size (MB)") },
label = { Text(stringResource(R.string.autoload_max_size)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true, modifier = Modifier.padding(top = 4.dp))
OutlinedTextField(maxStorageGb,
{ v -> maxStorageGb = v; val nb = v.toIntOrNull(); if (nb != null && nb in 1..100) { scope.launch { provider.setValue("chat.storage_maxsize_gb", v) }; NativeLib.setChatSetting("storage_maxsize_gb", v) } },
label = { Text("Max storage size (GB)") },
label = { Text(stringResource(R.string.max_storage_size)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true)
}
@@ -937,7 +943,7 @@ private fun DebugLevelDropdown(label: String, current: String, onSelect: (String
}
} else {
ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
OutlinedTextField(current, {}, readOnly = true, label = { Text("$label level") },
OutlinedTextField(current, {}, readOnly = true, label = { Text(stringResource(R.string.debug_level_label, label)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) },
modifier = Modifier.menuAnchor(), singleLine = true)
ExposedDropdownMenu(expanded, onDismissRequest = { expanded = false }) {
@@ -1,5 +1,8 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import androidx.compose.ui.res.stringResource
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
@@ -183,7 +186,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
}
player.setOnErrorListener { _, what, extra ->
LogManager.addLog("ERROR", "VideoPlayer", "MediaPlayer error what=$what extra=$extra file=$filePath")
error = "Ошибка воспроизведения видео"
error = AppLanguage.text(R.string.video_playback_failed)
true
}
try {
@@ -191,7 +194,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
player.prepareAsync()
} catch (e: Exception) {
LogManager.addLog("ERROR", "VideoPlayer", "setDataSource/prepare failed: ${e.message}")
error = "Не удалось открыть видео"
error = AppLanguage.text(R.string.video_open_failed)
}
onDispose {
@@ -375,7 +378,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
horizontalArrangement = Arrangement.End
) {
IconButton(onClick = onClose) {
Icon(Icons.Filled.Close, "Закрыть", tint = Color.White)
Icon(Icons.Filled.Close, stringResource(R.string.close), tint = Color.White)
}
}
@@ -391,7 +394,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
) {
Icon(
if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
contentDescription = if (isPlaying) "Пауза" else "Плей",
contentDescription = stringResource(if (isPlaying) R.string.pause else R.string.play),
tint = Color.White,
modifier = Modifier.size(48.dp)
)
@@ -413,7 +416,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
) {
Icon(
Icons.Filled.ScreenRotation,
contentDescription = "Перевернуть",
contentDescription = stringResource(R.string.flip),
tint = Color.White,
modifier = Modifier.size(32.dp)
)
@@ -431,7 +434,7 @@ fun VideoPlayerScreen(filePath: String, onClose: () -> Unit) {
) {
Icon(
Icons.Filled.ScreenRotation,
contentDescription = "Перевернуть",
contentDescription = stringResource(R.string.flip),
tint = Color.White,
modifier = Modifier.size(32.dp)
)
@@ -1,5 +1,7 @@
package com.utun.chat.ui.screens
import com.utun.chat.R
import androidx.compose.ui.res.stringResource
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
@@ -41,6 +43,7 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import com.utun.chat.data.CameraLensManager
import com.utun.chat.data.LensType
import com.utun.chat.data.LensOption
import com.utun.chat.data.LogManager
import kotlinx.coroutines.delay
@@ -209,7 +212,7 @@ fun VideoRecordScreen(
providerRef.value = provider
val list = CameraLensManager.enumerate(provider)
lenses = list
val default = list.firstOrNull { it.type.name == "SELFIE" } ?: list.firstOrNull()
val default = list.firstOrNull { it.type == LensType.SELFIE } ?: list.firstOrNull()
if (default != null) {
selectedLens = default
bindCamera(default.selector())
@@ -229,9 +232,9 @@ fun VideoRecordScreen(
Scaffold(
topBar = {
TopAppBar(
title = { Text("Видеосообщение") },
title = { Text(stringResource(R.string.video_message)) },
navigationIcon = {
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, "Закрыть") }
IconButton(onClick = { discardAndReset(); onClose() }) { Icon(Icons.Filled.Close, stringResource(R.string.close)) }
},
actions = {
if (boundCamera?.cameraInfo?.hasFlashUnit() == true) {
@@ -240,7 +243,7 @@ fun VideoRecordScreen(
try { boundCamera?.cameraControl?.enableTorch(next) } catch (_: Exception) {}
torchOn = next
}) {
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, "Подсветка", tint = if (torchOn) Color(0xFFFFD600) else Color.White)
Icon(if (torchOn) Icons.Filled.FlashOn else Icons.Filled.FlashOff, stringResource(R.string.camera_light), tint = if (torchOn) Color(0xFFFFD600) else Color.White)
}
}
}
@@ -250,11 +253,11 @@ fun VideoRecordScreen(
Box(Modifier.fillMaxSize().background(Color.Black).padding(padding)) {
if (!hasCamera || !hasAudio) {
Column(Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Нужны разрешения камеры и микрофона", color = Color.White, fontSize = 16.sp)
Text(stringResource(R.string.camera_microphone_permission), color = Color.White, fontSize = 16.sp)
Spacer(Modifier.height(12.dp))
Row {
if (!hasCamera) Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text("Камера") }
if (hasCamera && !hasAudio) Button(onClick = { audLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { Text("Микрофон") }
if (!hasCamera) Button(onClick = { camLauncher.launch(Manifest.permission.CAMERA) }) { Text(stringResource(R.string.camera)) }
if (hasCamera && !hasAudio) Button(onClick = { audLauncher.launch(Manifest.permission.RECORD_AUDIO) }) { Text(stringResource(R.string.microphone)) }
}
}
} else {
@@ -350,7 +353,7 @@ fun VideoRecordScreen(
.clickable { if (phase == Phase.IDLE) onClose() else discardAndReset() },
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.Delete, if (phase == Phase.IDLE) "Закрыть" else "Удалить", tint = Color.White, modifier = Modifier.size(26.dp))
Icon(Icons.Filled.Delete, if (phase == Phase.IDLE) stringResource(R.string.close) else stringResource(R.string.delete), tint = Color.White, modifier = Modifier.size(26.dp))
}
Spacer(Modifier.width(28.dp))
@@ -400,7 +403,7 @@ fun VideoRecordScreen(
},
contentAlignment = Alignment.Center
) {
Icon(Icons.AutoMirrored.Filled.Send, "Отправить", tint = Color.White, modifier = Modifier.size(26.dp))
Icon(Icons.AutoMirrored.Filled.Send, stringResource(R.string.send), tint = Color.White, modifier = Modifier.size(26.dp))
}
}
}
@@ -1,5 +1,7 @@
package com.utun.chat.viewmodel
import com.utun.chat.R
import com.utun.chat.data.AppLanguage
import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
@@ -1024,9 +1026,9 @@ class ChatViewModel : ViewModel() {
}
fun sendAttachment(filePath: String, target: ConversationTarget, name: String = File(filePath).name) {
val r = repo ?: throw IllegalStateException("Сервис чата остановлен")
val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped))
if (target is ConversationTarget.Pm) {
if (!NativeLib.dmSendFile(target.id, filePath, name)) throw IllegalStateException("Не удалось отправить вложение")
if (!NativeLib.dmSendFile(target.id, filePath, name)) throw IllegalStateException(AppLanguage.text(R.string.attachment_send_failed))
return
}
val chId = target.id
@@ -1067,13 +1069,13 @@ class ChatViewModel : ViewModel() {
viewModelScope.launch {
var accepted = false
try {
val r = repo ?: throw IllegalStateException("Сервис чата остановлен")
val r = repo ?: throw IllegalStateException(AppLanguage.text(R.string.chat_service_stopped))
val meta = withContext(Dispatchers.IO) {
val mmr = MediaMetadataRetriever()
try {
mmr.setDataSource(filePath)
if (mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE) != "video/mp4")
throw IllegalStateException("На Android отправка как видео поддерживает MP4; другой формат отправьте как файл")
throw IllegalStateException(AppLanguage.text(R.string.video_mp4_only))
Triple(
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toIntOrNull() ?: 0,
mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toIntOrNull() ?: 0,
@@ -1082,7 +1084,7 @@ class ChatViewModel : ViewModel() {
} finally { mmr.release() }
}
accepted = r.sendVideo(target.nativeId, filePath, meta.first, meta.second, meta.third)
if (!accepted) throw IllegalStateException("Некорректное видео или сервис остановлен")
if (!accepted) throw IllegalStateException(AppLanguage.text(R.string.video_invalid_or_stopped))
LogManager.addLog("INFO", "VM", "video queued target=${target.nativeId} dur=${meta.first} ${meta.second}x${meta.third}")
} catch (e: kotlinx.coroutines.CancellationException) { throw e }
catch (e: Exception) { LogManager.addLog("ERROR", "VM", "video failed target=${target.nativeId}: ${e.message}") }
@@ -1170,9 +1172,9 @@ class ChatViewModel : ViewModel() {
var cacheFile: File? = null
try {
val name = fileName.substringAfterLast('/').substringAfterLast('\\')
require(name.isNotBlank() && name != "." && name != "..") { "Неверное имя файла" }
require(name.isNotBlank() && name != "." && name != "..") { AppLanguage.text(R.string.invalid_file_name) }
val cacheDir = File(context.cacheDir, "attachments/${java.util.UUID.randomUUID()}")
check(cacheDir.mkdirs()) { "Не удалось создать каталог вложения" }
check(cacheDir.mkdirs()) { AppLanguage.text(R.string.attachment_directory_failed) }
val outputFile = File(cacheDir, name)
cacheFile = outputFile
context.contentResolver.openInputStream(uri)?.use { input ->
@@ -1181,11 +1183,11 @@ class ChatViewModel : ViewModel() {
while (true) {
val n = input.read(buffer)
if (n < 0) break
size += n; check(size <= 1024L * 1024 * 1024) { "Файл больше 1 ГиБ" }
size += n; check(size <= 1024L * 1024 * 1024) { AppLanguage.text(R.string.file_too_large) }
output.write(buffer, 0, n)
}
}
} ?: throw IllegalStateException("Не удалось открыть вложение")
} ?: throw IllegalStateException(AppLanguage.text(R.string.attachment_open_failed))
LogManager.addLog("INFO", "VM", "copied attachment to cache: ${outputFile.absolutePath}")
return outputFile.absolutePath
} catch (e: Exception) {