From 66afcc8625c17114e50b7549c73ddd134427e300 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 2 Oct 2026 16:05:33 +0300 Subject: [PATCH] Add extensible Russian and English interface languages on Android --- tools/chatgui-android/AGENTS.md | 11 + tools/chatgui-android/app/build.gradle.kts | 2 + .../app/src/main/AndroidManifest.xml | 6 + .../java/com/utun/chat/ChatApplication.kt | 7 + .../main/java/com/utun/chat/MainActivity.kt | 68 +++- .../java/com/utun/chat/data/AppLanguage.kt | 69 ++++ .../java/com/utun/chat/data/CallController.kt | 2 + .../java/com/utun/chat/data/CallService.kt | 23 +- .../main/java/com/utun/chat/data/CallState.kt | 9 +- .../com/utun/chat/data/CameraLensManager.kt | 27 +- .../java/com/utun/chat/data/ChatRepository.kt | 32 +- .../com/utun/chat/data/IncomingCallManager.kt | 26 +- .../com/utun/chat/data/RadioAudioEngine.kt | 3 +- .../com/utun/chat/data/RadioOverlayService.kt | 53 +-- .../com/utun/chat/headless/HeadlessService.kt | 46 ++- .../chat/ui/components/ActiveCallBanner.kt | 12 +- .../ui/components/ConversationComposer.kt | 36 +- .../chat/ui/components/LanguageSetting.kt | 41 +++ .../utun/chat/ui/components/MessageBubble.kt | 43 ++- .../com/utun/chat/ui/screens/CallScreen.kt | 49 +-- .../utun/chat/ui/screens/ChannelListScreen.kt | 40 ++- .../com/utun/chat/ui/screens/ChatScreen.kt | 26 +- .../com/utun/chat/ui/screens/DmChatScreen.kt | 9 +- .../utun/chat/ui/screens/FirstLaunchScreen.kt | 16 +- .../chat/ui/screens/IncomingCallDialog.kt | 14 +- .../utun/chat/ui/screens/InviteLinkDialog.kt | 21 +- .../utun/chat/ui/screens/JoinChannelDialog.kt | 15 +- .../com/utun/chat/ui/screens/LogScreen.kt | 6 +- .../utun/chat/ui/screens/MemberListScreen.kt | 105 +++--- .../utun/chat/ui/screens/PhotoRecordScreen.kt | 19 +- .../utun/chat/ui/screens/PhotoViewerScreen.kt | 4 +- .../utun/chat/ui/screens/QrScannerPreview.kt | 13 +- .../utun/chat/ui/screens/SettingsScreen.kt | 200 +++++------ .../utun/chat/ui/screens/VideoPlayerScreen.kt | 15 +- .../utun/chat/ui/screens/VideoRecordScreen.kt | 21 +- .../com/utun/chat/viewmodel/ChatViewModel.kt | 20 +- .../app/src/main/res/values-ru/strings.xml | 326 ++++++++++++++++++ .../app/src/main/res/values/strings.xml | 324 +++++++++++++++++ .../app/src/main/res/values/themes.xml | 2 +- .../app/src/main/res/xml/locales_config.xml | 5 + 40 files changed, 1354 insertions(+), 412 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/AppLanguage.kt create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/ui/components/LanguageSetting.kt create mode 100644 tools/chatgui-android/app/src/main/res/values-ru/strings.xml create mode 100644 tools/chatgui-android/app/src/main/res/values/strings.xml create mode 100644 tools/chatgui-android/app/src/main/res/xml/locales_config.xml 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 @@ -