diff --git a/tools/chatgui-android/AGENTS.md b/tools/chatgui-android/AGENTS.md
index 83d51447..3fc0575a 100644
--- a/tools/chatgui-android/AGENTS.md
+++ b/tools/chatgui-android/AGENTS.md
@@ -308,3 +308,14 @@ JOIN_READY протокола добавления и READY групповой
- Логи в C — через `debug_set_log_hook` → bridge → Kotlin `LogManager`
- Основной запуск: Kotlin передаёт INI-текст через `nativeStart(configText)`; парсинг выполняет общий `config_parser`
- Перед коммитом: головная C-сборка + `./gradlew assembleDebug`
+
+
+## Языки интерфейса
+
+- Английский — полный набор `app/src/main/res/values/strings.xml`; русский — `values-ru/strings.xml`.
+- Список языков в `res/xml/locales_config.xml` общий для `LanguageSetting` и системных настроек Android 13+.
+- Новый язык: добавить `values-<код>/strings.xml` и `` в `locales_config.xml`.
+- Compose получает текст через `stringResource` / `pluralStringResource`; callbacks и сервисы — через `AppLanguage.text`.
+- Выбор хранит Android/AppCompat (`autoStoreLocales`), пустой tag означает язык системы. В DataStore язык не дублировать.
+- В моделях и статусах хранить значения или ID ресурсов, а не переведённые строки. Протокольные поля и логи не переводить.
+- Переключение пересоздаёт Activity; важное UI-состояние хранить в ViewModel или `rememberSaveable`. Сервисы не перезапускать.
diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts
index 64d55fcf..014a11dd 100644
--- a/tools/chatgui-android/app/build.gradle.kts
+++ b/tools/chatgui-android/app/build.gradle.kts
@@ -63,6 +63,8 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.activity:activity-compose:1.8.2")
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.7.0")
implementation("androidx.datastore:datastore-preferences:1.0.0")
diff --git a/tools/chatgui-android/app/src/main/AndroidManifest.xml b/tools/chatgui-android/app/src/main/AndroidManifest.xml
index a37e0eb7..6dd5c66e 100644
--- a/tools/chatgui-android/app/src/main/AndroidManifest.xml
+++ b/tools/chatgui-android/app/src/main/AndroidManifest.xml
@@ -30,8 +30,14 @@
android:roundIcon="@mipmap/ic_launcher"
android:label="vibechat"
android:supportsRtl="true"
+ android:localeConfig="@xml/locales_config"
android:theme="@style/Theme.UTun">
+
+
+
+
+ .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(null) }
+ var screen by rememberSaveable { mutableStateOf(Screen.ChannelList) }
+ var showJoinDialog by rememberSaveable { mutableStateOf(false) }
+ var inviteChannelId by rememberSaveable { mutableStateOf(null) }
var videoMessage by remember { mutableStateOf(null) }
- var videoTarget by remember { mutableStateOf(null) }
+ var videoTargetId by rememberSaveable { mutableStateOf(null) }
+ val videoTarget = videoTargetId?.let {
+ if (it.startsWith("dm:")) ConversationTarget.Pm(it.removePrefix("dm:")) else ConversationTarget.Channel(it)
+ }
var photoMessage by remember { mutableStateOf(null) }
- var hadCall by remember { mutableStateOf(false) }
- var screenBeforeCall by remember { mutableStateOf(null) }
+ var hadCall by rememberSaveable { mutableStateOf(false) }
+ var screenBeforeCall by rememberSaveable { mutableStateOf(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(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) {
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt
new file mode 100644
index 00000000..b2852269
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt
@@ -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 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") }
+ }
+ }
+}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
index 4cf86be6..24398ef5 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallController.kt
@@ -36,6 +36,8 @@ object CallController : TelecomCallManager.Listener {
val proximityNear: StateFlow = proximity.near
private var service: CallService? = null
+
+ fun refreshLanguage() { service?.refreshLanguage() }
private var serviceRequested = false
private var accepting = false
private var acceptSent = false
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt
index 94948969..fb31adee 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallService.kt
@@ -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)
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
index e06992e4..fb3b5969 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallState.kt
@@ -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(
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt
index 9ef82fcd..4adf44a4 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CameraLensManager.kt
@@ -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
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
index d1602ff9..725113f5 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ChatRepository.kt
@@ -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) }
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt
index 440107b1..108e3fdb 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt
@@ -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")
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
index c7adf47b..faf65173 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
@@ -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
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
index 528a5310..a80e0143 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
@@ -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(null)
- val status: StateFlow = _status
+ private val _status = MutableStateFlow(null)
+ val status: StateFlow = _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()
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt
index 9eb67768..34d0cd87 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/headless/HeadlessService.kt
@@ -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)
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt
index b793e92b..e761acc3 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ActiveCallBanner.kt
@@ -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))
}
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt
index 38fe921b..c7b72542 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/ConversationComposer.kt
@@ -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(null) }
var pickerTarget by remember { mutableStateOf(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)) } }
)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/LanguageSetting.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/LanguageSetting.kt
new file mode 100644
index 00000000..d1245e45
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/LanguageSetting.kt
@@ -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) })
+ }
+ }
+ }
+}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
index 3de3f55e..3600cb69 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/MessageBubble.kt
@@ -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
)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
index f1bb907c..8279b3fb 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt
@@ -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)
}
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
index b97ae41e..e4cd5c37 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
@@ -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)) }
}
)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
index 9c9cab42..47bd810e 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
@@ -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() })
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt
index 5674bbf1..fed543ec 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/DmChatScreen.kt
@@ -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, 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, 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) },
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt
index 6775d8ff..6691972d 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/FirstLaunchScreen.kt
@@ -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))
}
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt
index 51b7e934..b8179852 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/IncomingCallDialog.kt
@@ -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)
}
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt
index 57094318..10afeb21 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/InviteLinkDialog.kt
@@ -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)) }
}
)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt
index acad2014..00e20cab 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/JoinChannelDialog.kt
@@ -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)) } }
)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt
index 73943f11..492ce1b9 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/LogScreen.kt
@@ -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
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
index 3dbd68ff..0d284bc6 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/MemberListScreen.kt
@@ -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)) }
}
)
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt
index ded36e21..8532817e 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoRecordScreen.kt
@@ -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 */
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt
index 075f5f8c..644d04a5 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/PhotoViewerScreen.kt
@@ -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)
}
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScannerPreview.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScannerPreview.kt
index 7cc12e50..06a12224 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScannerPreview.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/QrScannerPreview.kt
@@ -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)
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
index 2d2f66b2..6502e7a9 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
@@ -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 }) {
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt
index efa14c9e..6d16f539 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoPlayerScreen.kt
@@ -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)
)
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt
index ebb65769..29d7b3c9 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/VideoRecordScreen.kt
@@ -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))
}
}
}
diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
index 925aea3e..fd9ce384 100644
--- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
+++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
@@ -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) {
diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
new file mode 100644
index 00000000..53eca907
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml
@@ -0,0 +1,326 @@
+
+
+ "Для звонков и PTT разрешите доступ к микрофону"
+ "Не удалось запросить доступ к микрофону"
+ "Доступ к микрофону запрещён. Разрешите его в настройках приложения, затем повторите звонок или нажатие PTT."
+ "Настройки"
+ "Отмена"
+ "Некорректный ID канала"
+ "Микрофон разрешён. Нажмите PTT снова"
+ "Сервис чата остановлен"
+ "Не удалось отправить вложение"
+ "На Android отправка как видео поддерживает MP4; другой формат отправьте как файл"
+ "Некорректное видео или сервис остановлен"
+ "Неверное имя файла"
+ "Не удалось создать каталог вложения"
+ "Файл больше 1 ГиБ"
+ "Не удалось открыть вложение"
+ "нет подключения"
+ "прямое"
+ "Доставлено"
+ "Срок хранения истёк"
+ "Ожидает доставки"
+ "Получено"
+ "Загрузка…"
+ "Вы"
+ "Селфи"
+ "Обычная"
+ "Широкая"
+ "Макро"
+ "Теле"
+ "%1$s мм"
+ "Входящие звонки"
+ "Уведомление о входящем звонке"
+ "Входящий звонок"
+ "Завершить"
+ "Нужно разрешение поверх приложений"
+ "Нужен доступ к микрофону"
+ "Кнопка появится при включении рации"
+ "Для запуска кнопки откройте приложение"
+ "Запуск плавающей кнопки…"
+ "Не удалось запустить плавающую кнопку"
+ "Не удалось запустить сервис микрофона"
+ "Не удалось показать плавающую кнопку"
+ "Плавающая кнопка активна"
+ "Рация PTT"
+ "Оверлей PTT рации"
+ "Рация активна"
+ "Рация: PTT / автоматическая передача по голосу"
+ "Плавающая кнопка остановлена"
+ "Текущий звонок"
+ "Собеседник, длительность и завершение звонка на экране блокировки"
+ "Разговор"
+ "Звоним…"
+ "Установка звонка"
+ " (авто)"
+ " (свой узел)"
+ "Ссылка-приглашение"
+ "Выбор узла…"
+ "Генерация ссылки…"
+ "QR-код"
+ "Не удалось собрать ссылку"
+ "Скопировано!"
+ "Копировать ссылку"
+ "Закрыть"
+ "Фото"
+ "Подсветка"
+ "Нужно разрешение камеры"
+ "Камера"
+ "Переснять"
+ "Отправить"
+ "Подключиться"
+ "Логи"
+ "Новый канал"
+ "Каналов пока нет.\nНажмите +, чтобы создать канал."
+ "Параметры"
+ "Удалить канал"
+ "Личные"
+ "Удалить «%1$s» и все его локальные сообщения и файлы? У других участников канал останется."
+ "Удалить"
+ "Звук"
+ "Название канала"
+ "Создать"
+ "Разрешить"
+ "Не удалось сканировать QR-код. Вставьте ссылку-приглашение."
+ "Камера недоступна. Вставьте ссылку-приглашение."
+ "Пытаемся связаться…"
+ "Завершён"
+ "Подключение: %1$s"
+ "Динамик"
+ "Громкая связь"
+ "Громкая"
+ "Гарнитура"
+ "Включить микрофон"
+ "Выключить микрофон"
+ "Вкл. микрофон"
+ "Выкл. микрофон"
+ "Свернуть звонок"
+ "Экран заблокирован"
+ "Отведите телефон от уха, чтобы разблокировать"
+ "Добро пожаловать"
+ "Задайте имя устройства (ник), которое увидят другие участники."
+ "Имя устройства (ник)"
+ "Не удалось сохранить имя. Попробуйте ещё раз."
+ "Сохранение…"
+ "Продолжить"
+ "Некорректная ссылка-приглашение"
+ "Подключиться к каналу"
+ "Вставить ссылку-приглашение"
+ "Подключение к узлу…"
+ "Не удалось открыть файл"
+ "Назад"
+ "Позвонить"
+ "Собеседник недоступен"
+ "Участники 🔑 (%1$s)"
+ "Участники (%1$s)"
+ "Суперузел"
+ "Администратор"
+ "Модератор"
+ "Хранилище"
+ "Не подтверждён"
+ "свой узел"
+ ":%1$s мс"
+ "непрямое↑"
+ "подключение…"
+ "не в сети"
+ "Сообщение"
+ "Прямое: %1$s"
+ " %1$s мс"
+ "Непрямое: %1$s"
+ "Подключить"
+ "Изменить"
+ "%1$s с"
+ "%1$s мин"
+ "%1$s ч"
+ "%1$s дн"
+ "был в сети %1$s назад"
+ "%1$s мес"
+ "создан %1$s назад"
+ "Слушающие сокеты:"
+ "Адреса:"
+ "Соединения:"
+ "Изменить %1$s"
+ "Ник"
+ "Подтверждён"
+ "Удалён"
+ "Передать права администратора"
+ "Для администратора/суперузла нужен приватный ключ канала"
+ "Сохранить"
+ "Входящий звонок…"
+ "Отклонить"
+ "Принять"
+ "Видеосообщение"
+ "Нужны разрешения камеры и микрофона"
+ "Микрофон"
+ "Ошибка воспроизведения видео"
+ "Не удалось открыть видео"
+ "Пауза"
+ "Воспроизвести"
+ "Перевернуть"
+ "Звук"
+ "Рация"
+ "Узел"
+ "Имя узла"
+ "Порт прослушивания"
+ "IP управления"
+ "Порт"
+ "Ключи"
+ "Публичный ключ"
+ "Генерация…"
+ "Создать ключи"
+ "Очистить БД"
+ "Сетевые интерфейсы"
+ "Авто"
+ "Вручную"
+ "Сокеты не обнаружены"
+ "Удалить"
+ "Добавить интерфейс"
+ "Режим ожидания"
+ "Активность (с)"
+ "Сон (с)"
+ "Мин. сон (с)"
+ "Keepalive во сне (мс)"
+ "Чат"
+ "Автоподключение к узлам группы"
+ "Отладка"
+ "Общий уровень"
+ "Файл логов"
+ "IP для UDP-логов"
+ "Категории"
+ "Свернуть"
+ "Развернуть"
+ "(общий)"
+ "Сервис"
+ "Работает"
+ "Остановлен"
+ "Запустить сервис"
+ "Остановить сервис"
+ "Перезапустить ядро"
+ "Остановить всё и выйти"
+ "Заменить ключи?"
+ "Ключи уже существуют. Создание новых ключей сделает все существующие подключения и каналы недействительными."
+ "Заменить"
+ "Очистить базу данных?"
+ "Будут удалены все каналы, сообщения и узлы.\nЭто действие нельзя отменить."
+ "Удалить всё"
+ "Автоматическая передача по голосу (VAD)"
+ "Включить VAD"
+ "VAD активен: микрофон включён"
+ "VAD неактивен"
+ "Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически."
+ "Порог речи:"
+ "Задержка после тишины:"
+ "%1$s мс"
+ "Кнопки громкости (удержание) — передача"
+ "PTT-кнопка поверх приложений"
+ "Ожидание включения"
+ "Голос (VOICE_COMMUNICATION)"
+ "Микрофон (MIC)"
+ "Без обработки (UNPROCESSED)"
+ "Камера/видео (CAMCORDER)"
+ "Источник микрофона"
+ "Захват стерео"
+ "Воспроизведение всегда стерео; моно-пиры апмиксуются"
+ "Компрессор (AGC) рации"
+ "Включить компрессор"
+ "Макс. усиление:"
+ "Голос (VOIP)"
+ "Музыка (AUDIO)"
+ "Opus (рация)"
+ "%1$s кбит/с"
+ "Низкое (16 кбит/с)"
+ "Стандартное (32 кбит/с)"
+ "Высокое (64 кбит/с)"
+ "Звук уведомлений"
+ "Звук при получении сообщения"
+ "Показывать поверх экрана блокировки"
+ "Всегда разрешено"
+ "Разрешено"
+ "Отключено — нажмите, чтобы разрешить"
+ "Проверка микрофона"
+ "Остановить"
+ "Проверить"
+ "Качество голосового кодека"
+ "Звонки: максимальный запас джиттер-буфера"
+ "%1$s с · применяется к следующему звонку"
+ "Голосовой компрессор"
+ "Время анализа:"
+ "Время упреждения:"
+ "Скорость роста (×/с):"
+ "Целевой уровень:"
+ "Хранилище медиа"
+ "Автозагрузка медиа"
+ "Макс. размер автозагрузки (МБ)"
+ "Макс. объём хранилища (ГБ)"
+ "Уровень: %1$s"
+ "Рация: %1$s"
+ "Пригласить"
+ "Участники"
+ "🎙 %1$s говорит"
+ "Авто: %1$s"
+ "Микрофон и динамик телефона"
+ "Микрофон и звук проводной гарнитуры"
+ "Микрофон и звук Bluetooth-гарнитуры"
+ "Плавающая PTT-кнопка"
+ "Загрузка…"
+ "Нажмите, чтобы скачать"
+ "Нажмите, чтобы сохранить"
+ "Ошибка загрузки, нажмите для повтора"
+ "Ошибка, нажмите для повтора"
+ "Нажмите для воспроизведения"
+ "Нажмите, чтобы открыть"
+ "Сообщение…"
+ "Открыть клавиатуру"
+ "Открыть смайлики"
+ "Разговор "
+ "Звонок"
+ "Смайлики"
+ "Анимации"
+ "Жесты"
+ "Сердца"
+ "Природа"
+ "Еда"
+ "Разное"
+ "Вставить %1$s"
+ "Проиграть снова"
+ "Работа в фоне без ограничений"
+ "Разрешите работу без оптимизации батареи для получения сообщений и звонков в фоне. Это может увеличить расход батареи."
+ "Не разрешено"
+ "Не удалось открыть настройки батареи. Разрешите работу без ограничений в системных настройках приложения."
+ "В сети"
+ "Не в сети"
+ "Подготовка медиа…"
+ "Не удалось подготовить вложение"
+ "Не удалось скопировать вложение"
+ "Выбрать файл"
+ "Записать видео"
+ "Сфотографировать"
+ "Отправить видео"
+ "Выберите способ отображения вложения"
+ "Как видео"
+ "Как файл"
+ "Язык интерфейса"
+ "Как в системе"
+ "обратное"
+ "непрямое"
+ "выключить"
+ "включить"
+ "гарнитура"
+ "динамик"
+ "Сервис работает…"
+ "Фоновый сервис vibechat"
+ "Работает"
+ "Отключено"
+ "Подключение"
+ "локальный"
+ "публичный"
+ "Не удалось сохранить файл"
+ "Передать приватный ключ канала участнику «%1$s»?\n\nКлюч передаётся зашифрованным и подписанным. Получатель станет совладельцем и сможет управлять участниками."
+ "Буфер: %1$s мс · Запас: %2$s мс\nЗа 10 с: минимум %3$s · максимум %4$s мс\nСкорость: %5$s× · RTT: %6$s мс\nПотери буфера: %7$s · Недостачи: %8$s"
+
+ - BGP: %1$d узел
+ - BGP: %1$d узла
+ - BGP: %1$d узлов
+ - BGP: %1$d узла
+
+
diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml
new file mode 100644
index 00000000..d0a5ba50
--- /dev/null
+++ b/tools/chatgui-android/app/src/main/res/values/strings.xml
@@ -0,0 +1,324 @@
+
+
+ "Allow microphone access for calls and PTT"
+ "Could not request microphone access"
+ "Microphone access is denied. Allow it in app settings, then try the call or PTT again."
+ "Settings"
+ "Cancel"
+ "Invalid channel ID"
+ "Microphone allowed. Press PTT again"
+ "Chat service is stopped"
+ "Could not send attachment"
+ "Sending as video on Android requires MP4; send other formats as a file"
+ "Invalid video or service is stopped"
+ "Invalid file name"
+ "Could not create attachment directory"
+ "File exceeds 1 GiB"
+ "Could not open attachment"
+ "no connection"
+ "direct"
+ "Delivered"
+ "Storage period expired"
+ "Pending delivery"
+ "Received"
+ "Downloading…"
+ "You"
+ "Selfie"
+ "Main"
+ "Wide"
+ "Macro"
+ "Telephoto"
+ "%1$s mm"
+ "Incoming calls"
+ "Incoming call notification"
+ "Incoming call"
+ "End call"
+ "Allow display over other apps"
+ "Microphone access required"
+ "The button appears when the radio is on"
+ "Open the app to start the floating button"
+ "Starting floating button…"
+ "Could not start floating button"
+ "Could not start microphone service"
+ "Could not show floating button"
+ "Floating button is active"
+ "PTT radio"
+ "Radio PTT overlay"
+ "Radio is active"
+ "Radio: PTT / automatic voice transmission"
+ "Floating button stopped"
+ "Ongoing call"
+ "Caller, duration and end call on the lock screen"
+ "In call"
+ "Ringing…"
+ "Connecting call"
+ " (auto)"
+ " (self)"
+ "Invite link"
+ "Select node…"
+ "Generating link…"
+ "QR code"
+ "Could not build invite link"
+ "Copied!"
+ "Copy link"
+ "Close"
+ "Photo"
+ "Fill light"
+ "Camera permission required"
+ "Camera"
+ "Retake"
+ "Send"
+ "Join"
+ "Logs"
+ "New channel"
+ "No channels yet.\nTap + to create one."
+ "Options"
+ "Delete channel"
+ "Private chats"
+ "Delete “%1$s” and all its local messages and files? Other members keep the channel."
+ "Delete"
+ "Sound"
+ "Channel name"
+ "Create"
+ "Grant"
+ "QR scanning failed. Paste an invite link."
+ "Camera unavailable. Paste an invite link."
+ "Trying to connect…"
+ "Ended"
+ "Connection: %1$s"
+ "Earpiece"
+ "Speakerphone"
+ "Speaker"
+ "Headset"
+ "Unmute microphone"
+ "Mute microphone"
+ "Unmute"
+ "Mute"
+ "Minimize call"
+ "Screen locked"
+ "Move the phone away from your ear to unlock"
+ "Welcome"
+ "Choose a device name (nickname) that other members will see."
+ "Device name (nickname)"
+ "Could not save the name. Try again."
+ "Saving…"
+ "Continue"
+ "Invalid invite link"
+ "Join channel"
+ "Paste invite link"
+ "Connecting to peer…"
+ "Could not open file"
+ "Back"
+ "Call"
+ "Peer unavailable"
+ "Members 🔑 (%1$s)"
+ "Members (%1$s)"
+ "Supernode"
+ "Admin"
+ "Moderator"
+ "Storage"
+ "Not verified"
+ "self"
+ ":%1$s ms"
+ "indirect↑"
+ "connecting…"
+ "offline"
+ "Message"
+ "Direct: %1$s"
+ " %1$s ms"
+ "Indirect: %1$s"
+ "Connect"
+ "Edit"
+ "%1$s s"
+ "%1$s min"
+ "%1$s h"
+ "%1$s d"
+ "seen %1$s ago"
+ "%1$s mo"
+ "created %1$s ago"
+ "Listen sockets:"
+ "Addresses:"
+ "Connections:"
+ "Edit %1$s"
+ "Nickname"
+ "Verified"
+ "Deleted"
+ "Transfer admin rights"
+ "Admin/Super require channel private key"
+ "Save"
+ "Incoming call…"
+ "Decline"
+ "Accept"
+ "Video message"
+ "Camera and microphone permissions required"
+ "Microphone"
+ "Video playback failed"
+ "Could not open video"
+ "Pause"
+ "Play"
+ "Flip"
+ "Audio"
+ "Radio"
+ "Node"
+ "Node name"
+ "Listen port"
+ "Control bind IP"
+ "Port"
+ "Keys"
+ "Public key"
+ "Generating…"
+ "Generate keys"
+ "Clear DB"
+ "Network interfaces"
+ "Auto"
+ "Manual"
+ "No sockets detected"
+ "Remove"
+ "Add interface"
+ "Standby"
+ "Active (sec)"
+ "Sleep (sec)"
+ "Min sleep (sec)"
+ "Keepalive in sleep (ms)"
+ "Chat"
+ "Auto-connect to group nodes"
+ "Debug"
+ "Global level"
+ "Log file"
+ "UDP log IP"
+ "Categories"
+ "Collapse"
+ "Expand"
+ "(global)"
+ "Service"
+ "Running"
+ "Stopped"
+ "Start service"
+ "Stop service"
+ "Restart instance"
+ "Stop all and exit"
+ "Overwrite keys?"
+ "Keys already exist. Generating new keys will invalidate all existing connections and channels."
+ "Overwrite"
+ "Clear database?"
+ "This will delete all channels, messages, and peers.\nThis cannot be undone."
+ "Delete all"
+ "Automatic voice transmission (VAD)"
+ "Enable VAD"
+ "VAD active: microphone is on"
+ "VAD inactive"
+ "Runs for 15 minutes while the screen is on. Starts automatically when the screen turns on again."
+ "Speech threshold:"
+ "Delay after silence:"
+ "%1$s ms"
+ "Hold volume buttons to transmit"
+ "PTT button over other apps"
+ "Waiting to start"
+ "Voice (VOICE_COMMUNICATION)"
+ "Microphone (MIC)"
+ "Unprocessed (UNPROCESSED)"
+ "Camera/video (CAMCORDER)"
+ "Microphone source"
+ "Stereo capture"
+ "Playback is always stereo; mono peers are upmixed"
+ "Radio compressor (AGC)"
+ "Enable compressor"
+ "Max gain:"
+ "Voice (VOIP)"
+ "Music (AUDIO)"
+ "Opus (radio)"
+ "%1$s kbps"
+ "Low (16 kbps)"
+ "Standard (32 kbps)"
+ "High (64 kbps)"
+ "Notification sound"
+ "Play sound on incoming message"
+ "Show over lock screen"
+ "Always allowed"
+ "Allowed"
+ "Disabled — tap to enable"
+ "Microphone test"
+ "Stop"
+ "Test"
+ "Voice codec quality"
+ "Calls: maximum jitter buffer reserve"
+ "%1$s s · applies to the next call"
+ "Voice compressor"
+ "Lookback time:"
+ "Lookahead time:"
+ "Rise rate (×/sec):"
+ "Target level:"
+ "Media storage"
+ "Autoload media"
+ "Autoload max size (MB)"
+ "Max storage size (GB)"
+ "%1$s level"
+ "Radio: %1$s"
+ "Invite"
+ "Members"
+ "🎙 %1$s is speaking"
+ "Auto: %1$s"
+ "Phone microphone and speaker"
+ "Wired headset microphone and audio"
+ "Bluetooth headset microphone and audio"
+ "Floating PTT button"
+ "Downloading…"
+ "Tap to download"
+ "Tap to save"
+ "Download failed, tap to retry"
+ "Failed, tap to retry"
+ "Tap to play"
+ "Tap to open"
+ "Message…"
+ "Open keyboard"
+ "Open emoji"
+ "In call "
+ "Call"
+ "Faces"
+ "Animations"
+ "Gestures"
+ "Hearts"
+ "Nature"
+ "Food"
+ "Other"
+ "Insert %1$s"
+ "Replay"
+ "Unrestricted background operation"
+ "Allow unrestricted battery use to receive messages and calls in the background. This may increase battery consumption."
+ "Not allowed"
+ "Could not open battery settings. Allow unrestricted battery use in the app’s system settings."
+ "Online"
+ "Offline"
+ "Preparing media…"
+ "Could not prepare attachment"
+ "Could not copy attachment"
+ "Select file"
+ "Record video"
+ "Take photo"
+ "Send video"
+ "Choose how to display the attachment"
+ "As video"
+ "As file"
+ "Interface language"
+ "Follow system"
+ "reverse"
+ "indirect"
+ "turn off"
+ "turn on"
+ "headset"
+ "speaker"
+ "Service running…"
+ "vibechat background service"
+ "UP"
+ "DOWN"
+ "CONNECTING"
+ "local"
+ "public"
+ "Could not save file"
+ "Send the channel private key to “%1$s”?\n\nThe key is transferred encrypted and signed. The recipient will become a co-owner and will be able to manage members."
+ "Buffer: %1$s ms · Reserve: %2$s ms\nOver 10 s: min %3$s · max %4$s ms\nSpeed: %5$s× · RTT: %6$s ms\nBuffer drops: %7$s · Underruns: %8$s"
+
+ - BGP: %1$d node
+ - BGP: %1$d nodes
+
+
diff --git a/tools/chatgui-android/app/src/main/res/values/themes.xml b/tools/chatgui-android/app/src/main/res/values/themes.xml
index 774a62d4..fca01f58 100644
--- a/tools/chatgui-android/app/src/main/res/values/themes.xml
+++ b/tools/chatgui-android/app/src/main/res/values/themes.xml
@@ -1,6 +1,6 @@
-