diff --git a/app/src/main/java/com/zhousl/aether/ChatImePolicy.kt b/app/src/main/java/com/zhousl/aether/ChatImePolicy.kt new file mode 100644 index 00000000..02cd9335 --- /dev/null +++ b/app/src/main/java/com/zhousl/aether/ChatImePolicy.kt @@ -0,0 +1,20 @@ +package com.zhousl.aether + +import android.os.Build +import android.view.WindowManager + +/** + * Enables AndroidX's legacy IME detection on API 29 and below with adjustResize. + * API 30+ supplies native IME insets, so Compose alone moves the composer and + * adjustNothing retains the Russian build's fix for the collapsed chat layout. + * Replace only adjustment bits; preserve keyboard visibility and navigation flags. + */ +@Suppress("DEPRECATION") +internal fun chatSoftInputMode(sdkInt: Int, currentMode: Int): Int { + val adjustment = if (sdkInt < Build.VERSION_CODES.R) { + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + } else { + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING + } + return (currentMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST.inv()) or adjustment +} diff --git a/app/src/main/java/com/zhousl/aether/MainActivity.kt b/app/src/main/java/com/zhousl/aether/MainActivity.kt index d2fd17b9..2514959a 100644 --- a/app/src/main/java/com/zhousl/aether/MainActivity.kt +++ b/app/src/main/java/com/zhousl/aether/MainActivity.kt @@ -16,9 +16,11 @@ class MainActivity : AppCompatActivity() { ActivityResultContracts.RequestPermission(), ) { } + /** Configures version-aware keyboard adjustment before Compose starts consuming IME insets. */ override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() + window.setSoftInputMode(chatSoftInputMode(Build.VERSION.SDK_INT, window.attributes.softInputMode)) setContent { AetherApp(onNotificationPermissionRequested = ::maybeRequestNotificationPermission) } diff --git a/app/src/main/java/com/zhousl/aether/ui/AetherExtensionUi.kt b/app/src/main/java/com/zhousl/aether/ui/AetherExtensionUi.kt index 775584c4..8dc1861d 100644 --- a/app/src/main/java/com/zhousl/aether/ui/AetherExtensionUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/AetherExtensionUi.kt @@ -367,6 +367,7 @@ private fun AetherExtensionView( } } +/** Renders Android extension nodes, translating display text for the selected app language while preserving code and action payloads. */ @Composable @OptIn(ExperimentalLayoutApi::class) private fun AetherExtensionNode( @@ -391,7 +392,8 @@ private fun AetherExtensionNode( when (type.lowercase()) { "text", "code" -> { - val text = node.optString("text") + val text = if (type.equals("code", ignoreCase = true)) node.optString("text") + else extensionText(node.optString("text"), LocalAetherLanguage.current) Text( text = text, modifier = clickableModifier, @@ -507,7 +509,7 @@ private fun AetherExtensionNode( Spacer(Modifier.width(8.dp)) } Text( - text = node.optString("label", node.optString("text")), + text = extensionText(node.optString("label", node.optString("text")), LocalAetherLanguage.current), maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt b/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt index 05f7ec28..1cb3aab3 100644 --- a/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt +++ b/app/src/main/java/com/zhousl/aether/ui/AetherViewModel.kt @@ -3691,6 +3691,7 @@ class AetherViewModel( } } + /** Dispatches extension host requests and localizes known notification messages using the Android app language. */ suspend fun handleAetherExtensionHostCall( method: String, args: JSONObject, @@ -3828,7 +3829,7 @@ class AetherViewModel( } "app.notify" -> { - emitTransientMessage(UiText.Raw(args.optString("message"))) + emitTransientMessage(UiText.Raw(extensionText(args.optString("message"), _uiState.value.settings.language))) JSONObject().put("notified", true) } @@ -4072,6 +4073,7 @@ class AetherViewModel( } } + /** Dispatches before-send extension hooks, preserving unknown cancellation reasons and translating known UI messages. */ private fun submitCurrentMessage( runningFollowUpMode: SessionFollowUpMode, ) { @@ -4113,7 +4115,7 @@ class AetherViewModel( ).getOrNull() if (eventResult?.cancelled == true) { eventResult.reason.takeIf(String::isNotBlank)?.let { reason -> - emitTransientMessage(UiText.Raw(reason)) + emitTransientMessage(UiText.Raw(extensionText(reason, _uiState.value.settings.language))) } return@launch } diff --git a/app/src/main/java/com/zhousl/aether/ui/AndroidAlpineFileManagerScreen.kt b/app/src/main/java/com/zhousl/aether/ui/AndroidAlpineFileManagerScreen.kt index d4fadabe..9782d73f 100644 --- a/app/src/main/java/com/zhousl/aether/ui/AndroidAlpineFileManagerScreen.kt +++ b/app/src/main/java/com/zhousl/aether/ui/AndroidAlpineFileManagerScreen.kt @@ -75,6 +75,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.zhousl.aether.R import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog @@ -95,6 +97,7 @@ import kotlinx.coroutines.launch private enum class AndroidFileDialog { None, NewFile, NewFolder, Rename, Delete } private enum class AndroidFileSort { Name, Date, Size } +/** Displays Alpine files on Android with localized controls and delegates filesystem operations to the runtime. */ @Composable internal fun AndroidAlpineFileManagerScreen( runtime: AndroidAlpineFileManagerRuntime, @@ -102,6 +105,16 @@ internal fun AndroidAlpineFileManagerScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val fileManagerLoadDirectoryFailed = stringResource(R.string.file_manager_load_directory_failed) + val fileManagerImportSelectedItemFailed = stringResource(R.string.file_manager_import_selected_item_failed) + val fileManagerDownloadFileFailed = stringResource(R.string.file_manager_download_file_failed) + // Resolve UI fallbacks in composition; coroutines retain the original diagnostic when available. + val fileManagerOpenFileFailed = stringResource(R.string.file_manager_open_file_failed) + val fileManagerSaveFileFailed = stringResource(R.string.file_manager_save_file_failed) + val fileManagerOpenSelectedItemFailed = stringResource(R.string.file_manager_open_selected_item_failed) + val fileManagerOpenDownloadLocationFailed = stringResource(R.string.file_manager_open_download_location_failed) + val fileOperationFailed = stringResource(R.string.file_manager_operation_failed) + var path by remember { mutableStateOf("/") } var entries by remember { mutableStateOf>(emptyList()) } var loading by remember { mutableStateOf(false) } @@ -120,17 +133,19 @@ internal fun AndroidAlpineFileManagerScreen( var imagePreview by remember { mutableStateOf(null) } var pendingDownload by remember { mutableStateOf(null) } + /** Reloads entries for the active directory and updates the loading state. */ fun refresh() { val requestedPath = path loading = true scope.launch { runCatching { runtime.listDirectory(requestedPath) } .onSuccess { if (path == requestedPath) entries = it } - .onFailure { error = it.message ?: "Unable to load directory." } + .onFailure { error = it.message ?: fileManagerLoadDirectoryFailed } if (path == requestedPath) loading = false } } + /** Imports selected Android documents into Alpine without translating their filenames. */ fun importDocuments(uris: List, folders: Boolean) { if (uris.isEmpty()) return scope.launch { @@ -147,10 +162,10 @@ internal fun AndroidAlpineFileManagerScreen( DocumentFile.fromTreeUri(context, uri) } else { DocumentFile.fromSingleUri(context, uri) - } ?: error("Unable to open the selected item.") + } ?: error(fileManagerOpenSelectedItemFailed) importAndroidDocument(context, runtime, path, document) } - }.onFailure { error = it.message ?: "Unable to import the selected item." } + }.onFailure { error = it.message ?: fileManagerImportSelectedItemFailed } refresh() } } @@ -173,11 +188,11 @@ internal fun AndroidAlpineFileManagerScreen( loading = true runCatching { val output = context.contentResolver.openOutputStream(uri, "w") - ?: error("Unable to open the selected download location.") + ?: error(fileManagerOpenDownloadLocationFailed) output.use { runtime.exportFile(entry.path, it) } }.onFailure { failure -> runCatching { context.contentResolver.delete(uri, null, null) } - error = failure.message ?: "Unable to download the file." + error = failure.message ?: fileManagerDownloadFileFailed } loading = false } @@ -185,6 +200,7 @@ internal fun AndroidAlpineFileManagerScreen( }, ) + /** Opens the selected file or directory using the appropriate Android preview. */ fun open(entry: AndroidAlpineFileEntry) { selected = null if (entry.isDirectory) { @@ -202,10 +218,11 @@ internal fun AndroidAlpineFileManagerScreen( editorFile = entry } } - .onFailure { error = it.message ?: "Unable to open file." } + .onFailure { error = it.message ?: fileManagerOpenFileFailed } } } + /** Closes a preview or moves to the parent directory before leaving the file manager. */ fun navigateBack(): Boolean { if (editorFile != null) { editorFile = null @@ -230,7 +247,7 @@ internal fun AndroidAlpineFileManagerScreen( val target = editorFile ?: return@SoraEditorScreen scope.launch { runCatching { runtime.fileSystem.write(target.path, content.encodeToByteArray()) } - .onFailure { error = it.message ?: "Unable to save file." } + .onFailure { error = it.message ?: fileManagerSaveFileFailed } } }, onBack = { editorFile = null }, @@ -259,10 +276,10 @@ internal fun AndroidAlpineFileManagerScreen( ) { Row(verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") + Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.common_back)) } Column(Modifier.weight(1f)) { - Text("Alpine Files", style = MaterialTheme.typography.titleMedium, color = AetherOnSurface) + Text(stringResource(R.string.file_manager_title), style = MaterialTheme.typography.titleMedium, color = AetherOnSurface) Text(path, style = MaterialTheme.typography.labelSmall, color = AetherOnSurfaceVariant, maxLines = 1) } IconButton( @@ -270,30 +287,34 @@ internal fun AndroidAlpineFileManagerScreen( if (path != "/") path = path.substringBeforeLast('/', "").ifBlank { "/" } }, enabled = path != "/", - ) { Icon(Icons.Rounded.ArrowUpward, "Parent folder") } + ) { Icon(Icons.Rounded.ArrowUpward, stringResource(R.string.file_manager_parent_folder)) } IconButton(onClick = { showSearch = !showSearch; if (!showSearch) query = "" }) { - Icon(if (showSearch) Icons.Rounded.Close else Icons.Rounded.Search, "Search") + Icon(if (showSearch) Icons.Rounded.Close else Icons.Rounded.Search, stringResource(R.string.file_manager_search)) } if (loading) { Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) } } else { - IconButton(onClick = ::refresh) { Icon(Icons.Rounded.Refresh, "Refresh") } + IconButton(onClick = ::refresh) { Icon(Icons.Rounded.Refresh, stringResource(R.string.common_refresh)) } } Box { IconButton(onClick = { optionsExpanded = true }) { - Icon(Icons.Rounded.MoreVert, "View options") + Icon(Icons.Rounded.MoreVert, stringResource(R.string.file_manager_view_options)) } DropdownMenu(expanded = optionsExpanded, onDismissRequest = { optionsExpanded = false }) { DropdownMenuItem( - text = { Text(if (grid) "List view" else "Grid view") }, + text = { Text(stringResource(if (grid) R.string.file_manager_view_list else R.string.file_manager_view_grid)) }, onClick = { grid = !grid; optionsExpanded = false }, leadingIcon = { Icon(if (grid) Icons.Rounded.ViewList else Icons.Rounded.GridView, null) }, ) AndroidFileSort.entries.forEach { option -> DropdownMenuItem( - text = { Text("Sort by ${option.name.lowercase()}") }, + text = { Text(stringResource(R.string.file_manager_sort_by, stringResource(when (option) { + AndroidFileSort.Name -> R.string.file_manager_sort_name + AndroidFileSort.Date -> R.string.file_manager_sort_date + AndroidFileSort.Size -> R.string.file_manager_sort_size + }))) }, onClick = { sort = option; optionsExpanded = false }, leadingIcon = { Icon(Icons.Rounded.Sort, null) }, ) @@ -306,7 +327,7 @@ internal fun AndroidAlpineFileManagerScreen( value = query, onValueChange = { query = it }, modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), - placeholder = { Text("Search this folder") }, + placeholder = { Text(stringResource(R.string.file_manager_search_folder)) }, singleLine = true, ) } @@ -315,26 +336,26 @@ internal fun AndroidAlpineFileManagerScreen( floatingActionButton = { Box { FloatingActionButton(onClick = { createExpanded = true }, containerColor = AetherPrimary) { - Icon(Icons.Rounded.Add, "Create", tint = Color.White) + Icon(Icons.Rounded.Add, stringResource(R.string.file_manager_create), tint = Color.White) } DropdownMenu(expanded = createExpanded, onDismissRequest = { createExpanded = false }) { DropdownMenuItem( - text = { Text("New file") }, + text = { Text(stringResource(R.string.file_manager_create_file)) }, onClick = { draftName = ""; dialog = AndroidFileDialog.NewFile; createExpanded = false }, leadingIcon = { Icon(Icons.Rounded.Description, null) }, ) DropdownMenuItem( - text = { Text("New folder") }, + text = { Text(stringResource(R.string.file_manager_create_folder)) }, onClick = { draftName = ""; dialog = AndroidFileDialog.NewFolder; createExpanded = false }, leadingIcon = { Icon(Icons.Rounded.CreateNewFolder, null) }, ) DropdownMenuItem( - text = { Text("Import files") }, + text = { Text(stringResource(R.string.file_manager_import_files)) }, onClick = { createExpanded = false; fileImportLauncher.launch(arrayOf("*/*")) }, leadingIcon = { Icon(Icons.Rounded.FileUpload, null) }, ) DropdownMenuItem( - text = { Text("Import folder") }, + text = { Text(stringResource(R.string.file_manager_import_folder)) }, onClick = { createExpanded = false; folderImportLauncher.launch(null) }, leadingIcon = { Icon(Icons.Rounded.DriveFolderUpload, null) }, ) @@ -348,7 +369,7 @@ internal fun AndroidAlpineFileManagerScreen( ) { when { loading && entries.isEmpty() -> CircularProgressIndicator(modifier = Modifier.size(28.dp)) - visibleEntries.isEmpty() -> Text(if (query.isBlank()) "This folder is empty" else "No matching files", color = AetherOnSurfaceVariant) + visibleEntries.isEmpty() -> Text(stringResource(if (query.isBlank()) R.string.file_manager_empty else R.string.file_manager_no_matches), color = AetherOnSurfaceVariant) grid -> LazyVerticalGrid( columns = GridCells.Adaptive(140.dp), modifier = Modifier.fillMaxSize().padding(8.dp), @@ -374,18 +395,18 @@ internal fun AndroidAlpineFileManagerScreen( Column(Modifier.fillMaxWidth().padding(vertical = 8.dp)) { Text(entry.name, style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(16.dp)) DropdownMenuItem( - text = { Text("Open") }, + text = { Text(stringResource(R.string.file_manager_open)) }, onClick = { open(entry) }, leadingIcon = { Icon(if (entry.isDirectory) Icons.Rounded.Folder else Icons.Rounded.Description, null) }, ) DropdownMenuItem( - text = { Text("Rename") }, + text = { Text(stringResource(R.string.file_manager_rename)) }, onClick = { draftName = entry.name; dialog = AndroidFileDialog.Rename }, leadingIcon = { Icon(Icons.Rounded.Edit, null) }, ) if (!entry.isDirectory) { DropdownMenuItem( - text = { Text("Download") }, + text = { Text(stringResource(R.string.file_manager_download)) }, onClick = { pendingDownload = entry selected = null @@ -395,7 +416,7 @@ internal fun AndroidAlpineFileManagerScreen( ) } DropdownMenuItem( - text = { Text("Delete", color = MaterialTheme.colorScheme.error) }, + text = { Text(stringResource(R.string.file_manager_delete), color = MaterialTheme.colorScheme.error) }, onClick = { dialog = AndroidFileDialog.Delete }, leadingIcon = { Icon(Icons.Rounded.Delete, null, tint = MaterialTheme.colorScheme.error) }, ) @@ -409,18 +430,18 @@ internal fun AndroidAlpineFileManagerScreen( onDismissRequest = { dialog = AndroidFileDialog.None; selected = null }, title = { Text(when (dialog) { - AndroidFileDialog.NewFile -> "New file" - AndroidFileDialog.NewFolder -> "New folder" - AndroidFileDialog.Rename -> "Rename" - AndroidFileDialog.Delete -> "Delete ${selected?.name.orEmpty()}?" + AndroidFileDialog.NewFile -> stringResource(R.string.file_manager_create_file) + AndroidFileDialog.NewFolder -> stringResource(R.string.file_manager_create_folder) + AndroidFileDialog.Rename -> stringResource(R.string.file_manager_rename) + AndroidFileDialog.Delete -> stringResource(R.string.file_manager_delete_named, selected?.name.orEmpty()) AndroidFileDialog.None -> "" }) }, text = { if (dialog == AndroidFileDialog.Delete) { - Text(if (selected?.isDirectory == true) "The folder and all of its contents will be permanently deleted." else "This item will be permanently deleted.") + Text(stringResource(if (selected?.isDirectory == true) R.string.file_manager_delete_folder_warning else R.string.file_manager_delete_item_warning)) } else { - OutlinedTextField(value = draftName, onValueChange = { draftName = it }, label = { Text("Name") }, singleLine = true) + OutlinedTextField(value = draftName, onValueChange = { draftName = it }, label = { Text(stringResource(R.string.file_manager_new_name)) }, singleLine = true) } }, confirmButton = { @@ -436,12 +457,12 @@ internal fun AndroidAlpineFileManagerScreen( when (operation) { AndroidFileDialog.NewFile -> { val target = joinAndroidFilePath(path, name) - require(!runtime.fileSystem.exists(target)) { "An item named $name already exists." } + require(!runtime.fileSystem.exists(target)) { context.getString(R.string.file_manager_named_item_exists, name) } runtime.fileSystem.write(target, ByteArray(0)) } AndroidFileDialog.NewFolder -> { val target = joinAndroidFilePath(path, name) - require(!runtime.fileSystem.exists(target)) { "An item named $name already exists." } + require(!runtime.fileSystem.exists(target)) { context.getString(R.string.file_manager_named_item_exists, name) } runtime.fileSystem.createDirectories(target) } AndroidFileDialog.Rename -> { @@ -451,14 +472,14 @@ internal fun AndroidAlpineFileManagerScreen( AndroidFileDialog.Delete -> runtime.fileSystem.remove(item!!.path, recursive = item.isDirectory) AndroidFileDialog.None -> Unit } - }.onFailure { error = it.message ?: "File operation failed." } + }.onFailure { error = it.message ?: fileOperationFailed } selected = null refresh() } }, - ) { Text(if (dialog == AndroidFileDialog.Delete) "Delete" else "Confirm") } + ) { Text(stringResource(if (dialog == AndroidFileDialog.Delete) R.string.file_manager_delete else R.string.common_confirm)) } }, - dismissButton = { TextButton(onClick = { dialog = AndroidFileDialog.None; selected = null }) { Text("Cancel") } }, + dismissButton = { TextButton(onClick = { dialog = AndroidFileDialog.None; selected = null }) { Text(stringResource(R.string.common_cancel)) } }, ) } @@ -466,7 +487,7 @@ internal fun AndroidAlpineFileManagerScreen( Dialog(onDismissRequest = { imagePreview = null }) { val bitmap = remember(bytes) { BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap() } androidx.compose.material3.Surface(shape = RoundedCornerShape(8.dp), color = AetherSurface) { - if (bitmap == null) Text("Unable to decode image", modifier = Modifier.padding(24.dp)) + if (bitmap == null) Text(stringResource(R.string.file_manager_decode_image_failed), modifier = Modifier.padding(24.dp)) else Image(bitmap, null, Modifier.fillMaxWidth().padding(12.dp), contentScale = ContentScale.Fit) } } @@ -475,13 +496,14 @@ internal fun AndroidAlpineFileManagerScreen( error?.let { message -> AlertDialog( onDismissRequest = { error = null }, - title = { Text("File operation failed") }, + title = { Text(stringResource(R.string.file_manager_operation_failed)) }, text = { Text(message) }, - confirmButton = { TextButton(onClick = { error = null }) { Text("OK") } }, + confirmButton = { TextButton(onClick = { error = null }) { Text(stringResource(R.string.common_done)) } }, ) } } +/** Displays a file row with localized accessibility labels for opening its actions. */ @Composable private fun AndroidFileListItem(entry: AndroidAlpineFileEntry, onOpen: () -> Unit, onMore: () -> Unit) { Row( @@ -494,10 +516,11 @@ private fun AndroidFileListItem(entry: AndroidAlpineFileEntry, onOpen: () -> Uni Text(entry.name, color = AetherOnSurface, maxLines = 1) Text(if (entry.isDirectory) DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(entry.modifiedAtMillis)) else formatAndroidFileSize(entry.size), color = AetherOnSurfaceVariant, style = MaterialTheme.typography.labelSmall) } - IconButton(onClick = onMore) { Icon(Icons.Rounded.MoreVert, "More") } + IconButton(onClick = onMore) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.common_more)) } } } +/** Displays a file tile with localized accessibility labels for opening its actions. */ @Composable private fun AndroidFileGridItem(entry: AndroidAlpineFileEntry, onOpen: () -> Unit, onMore: () -> Unit) { Column( @@ -505,7 +528,7 @@ private fun AndroidFileGridItem(entry: AndroidAlpineFileEntry, onOpen: () -> Uni horizontalAlignment = Alignment.CenterHorizontally, ) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - IconButton(onClick = onMore, modifier = Modifier.size(32.dp)) { Icon(Icons.Rounded.MoreVert, "More") } + IconButton(onClick = onMore, modifier = Modifier.size(32.dp)) { Icon(Icons.Rounded.MoreVert, stringResource(R.string.common_more)) } } Icon(if (entry.isDirectory) Icons.Rounded.Folder else Icons.Rounded.Description, null, tint = if (entry.isDirectory) AetherPrimary else AetherOnSurfaceVariant, modifier = Modifier.size(40.dp)) Spacer(Modifier.size(8.dp)) @@ -513,6 +536,7 @@ private fun AndroidFileGridItem(entry: AndroidAlpineFileEntry, onOpen: () -> Uni } } +/** Edits an Alpine text file with localized navigation, wrapping, and save controls. */ @Composable private fun SoraEditorScreen( entry: AndroidAlpineFileEntry, @@ -531,18 +555,18 @@ private fun SoraEditorScreen( Modifier.fillMaxWidth().background(AetherSurface).statusBarsPadding().padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, "Back") } + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, stringResource(R.string.common_back)) } Column(Modifier.weight(1f)) { Text(entry.name, style = MaterialTheme.typography.titleMedium, maxLines = 1) Text(entry.path, style = MaterialTheme.typography.labelSmall, color = AetherOnSurfaceVariant, maxLines = 1) } IconButton(onClick = { wrapsLines = !wrapsLines; editor?.setWordwrap(wrapsLines) }) { - Icon(Icons.Rounded.WrapText, "Toggle line wrapping", tint = if (wrapsLines) AetherPrimary else AetherOnSurfaceVariant) + Icon(Icons.Rounded.WrapText, stringResource(R.string.file_manager_toggle_line_wrap), tint = if (wrapsLines) AetherPrimary else AetherOnSurfaceVariant) } IconButton(onClick = { val content = editor?.text?.toString() ?: initialContent onSave(content) - }) { Icon(Icons.Rounded.Save, "Save") } + }) { Icon(Icons.Rounded.Save, stringResource(R.string.file_manager_save)) } } }, ) { padding -> @@ -574,6 +598,7 @@ private fun SoraEditorScreen( private val androidImageExtensions = setOf("png", "jpg", "jpeg", "gif", "webp", "bmp") +/** Copies an Android document tree into Alpine, reporting localized validation errors and rolling back a failed directory import. */ private suspend fun importAndroidDocument( context: Context, runtime: AndroidAlpineFileManagerRuntime, @@ -581,13 +606,13 @@ private suspend fun importAndroidDocument( document: DocumentFile, ) { val name = document.name?.trim().orEmpty() - require(validAndroidFileName(name)) { "The selected item has an invalid name." } + require(validAndroidFileName(name)) { context.getString(R.string.file_manager_invalid_item_name) } val target = joinAndroidFilePath(parentPath, name) - require(!runtime.fileSystem.exists(target)) { "An item named $name already exists." } + require(!runtime.fileSystem.exists(target)) { context.getString(R.string.file_manager_named_item_exists, name) } if (!document.isDirectory) { val input = context.contentResolver.openInputStream(document.uri) - ?: error("Unable to read $name.") + ?: error(context.getString(R.string.file_manager_read_named_item_failed, name)) input.use { runtime.importFile(target, it) } return } @@ -603,12 +628,15 @@ private suspend fun importAndroidDocument( } } +/** Rejects empty, parent-relative, and slash-containing names before creating or importing Alpine files. */ private fun validAndroidFileName(name: String): Boolean = name.trim().let { it.isNotEmpty() && it != "." && it != ".." && '/' !in it } +/** Builds a child path without duplicating the separator at the Alpine filesystem root. */ private fun joinAndroidFilePath(parent: String, name: String): String = if (parent == "/") "/$name" else "${parent.trimEnd('/')}/$name" +/** Formats byte counts into compact binary-size units for the Android file list. */ private fun formatAndroidFileSize(bytes: Long): String { if (bytes < 1024) return "$bytes B" val units = arrayOf("KB", "MB", "GB", "TB") diff --git a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt index 7afc02bb..6e90b49b 100644 --- a/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt +++ b/app/src/main/java/com/zhousl/aether/ui/ConversationUi.kt @@ -2373,6 +2373,7 @@ private fun ConversationComposerOverlay( } } +/** Renders the Android chat input and attachment menu, localizing builtin extension titles without changing their actions. */ @Composable private fun ConversationComposerBar( modifier: Modifier = Modifier, @@ -2998,7 +2999,7 @@ private fun ConversationComposerBar( } extensionUiController?.snapshot?.composerMenuItems.orEmpty().forEach { item -> ComposerPlusMenuRow( - title = item.title, + title = extensionComposerMenuTitle(item.localId, item.title, LocalAetherLanguage.current), icon = Icons.Rounded.Extension, selected = item.selected, iconTint = AetherPrimary, diff --git a/app/src/main/java/com/zhousl/aether/ui/ExtensionLocalization.kt b/app/src/main/java/com/zhousl/aether/ui/ExtensionLocalization.kt new file mode 100644 index 00000000..f6947544 --- /dev/null +++ b/app/src/main/java/com/zhousl/aether/ui/ExtensionLocalization.kt @@ -0,0 +1,139 @@ +package com.zhousl.aether.ui + +import com.zhousl.aether.data.AppLanguage +import androidx.compose.runtime.staticCompositionLocalOf + +/** Supplies the language selected by the Android settings to native extension UI. */ +internal val LocalAetherLanguage = staticCompositionLocalOf { AppLanguage.English } + +/** Translates the two builtin composer entries only for Russian and preserves other extension titles. */ +internal fun extensionComposerMenuTitle(localId: String, title: String, language: AppLanguage): String = + if (language == AppLanguage.Russian) { + when (localId) { + "subagents" -> "Субагенты" + "research-web" -> "Исследование в интернете" + else -> title + } + } else { + title + } + + +/** Translates known extension display text for Russian, preserving unknown text and interpolated error details. */ +internal fun extensionText(value: String, language: AppLanguage): String { + if (language != AppLanguage.Russian) return value + val prefix = "Web Access settings could not read the Pi config: " + if (value.startsWith(prefix)) { + return "Настройки веб-доступа: не удалось прочитать конфигурацию Pi: " + value.removePrefix(prefix) + } + val providerPrefix = "Default provider and " + val providerSuffix = " configuration. Credentials are persisted in the existing Pi config file." + if (value.startsWith(providerPrefix) && value.endsWith(providerSuffix)) { + val provider = value.removePrefix(providerPrefix).removeSuffix(providerSuffix) + return "Настройка провайдера и раздела $provider. Данные доступа сохраняются в существующем файле конфигурации Pi." + } + return russianExtensionText(value) +} + +/** + * Matches builtin display strings exactly, leaving custom extension text unchanged. + * Upstream label changes require updating this table; stable translation IDs would + * require a separate extension API change. + */ +private fun russianExtensionText(value: String): String = when (value) { + "Web Access" -> "Веб-доступ" + "Search, source verification, extraction, and provider routing" -> "Поиск, проверка источников, извлечение данных и маршрутизация провайдеров" + "Web search tools" -> "Инструменты веб-поиска" + "Register search and source-check tools after the next extension reload." -> "Зарегистрировать инструменты поиска и проверки источников после следующей перезагрузки расширений." + "Provider" -> "Провайдер" + "Default search provider, credentials, and base URLs" -> "Провайдер поиска по умолчанию, учётные данные и базовые URL" + "Context Extraction" -> "Извлечение контекста" + "GitHub, video, and PDF handling" -> "Работа с GitHub, видео и PDF" + "Privacy and network" -> "Конфиденциальность и сеть" + "Browser data access, SSRF exceptions, and fetch domain policy" -> "Доступ к данным браузера, исключения SSRF и правила доменов загрузки" + "MCP Servers" -> "MCP-серверы" + "Manage MCP servers, inspect each transport config, and keep only the connections you want active." -> "Управляйте MCP-серверами, проверяйте настройки транспорта и оставляйте только нужные подключения активными." + "Subagents" -> "Субагенты" + "Subagent concurrency, dispatch, persistence, and Aether UI" -> "Параллелизм, распределение задач, сохранение данных и интерфейс Aether для субагентов" + "Subagent Types" -> "Типы субагентов" + "Enable, disable, and reload custom agent definitions" -> "Включение, отключение и перезагрузка пользовательских определений агентов" + "Runtime" -> "Среда выполнения" + "Agents" -> "Агенты" + "Models" -> "Модели" + "Aether and Pi UI" -> "Интерфейс Aether и Pi" + "Max concurrency" -> "Максимальный параллелизм" + "Default max turns" -> "Максимум ходов по умолчанию" + "Grace turns" -> "Дополнительные ходы" + "Nested depth" -> "Глубина вложенности" + "Join mode" -> "Режим объединения" + "Scheduling" -> "Планирование" + "Output transcript" -> "Сохранять расшифровку" + "Disable defaults" -> "Отключить стандартных агентов" + "Fallback agent" -> "Резервный агент" + "Strict agent files" -> "Строгая проверка файлов агентов" + "Tool description" -> "Описание инструментов" + "Scope models" -> "Ограничить модели" + "Widget" -> "Виджет" + "Fleet view" -> "Вид флота" + "Agent mentions" -> "Упоминания агентов" + "Remember agents" -> "Запоминать агентов" + "Manage" -> "Управление" + "Create agent definition" -> "Создать определение агента" + "Reload agent files" -> "Перезагрузить файлы агентов" + "MCP Runtime" -> "Среда выполнения MCP" + "No MCP servers" -> "Нет MCP-серверов" + "Add HTTP or stdio servers to extend capabilities." -> "Добавьте HTTP- или stdio-серверы для расширения возможностей." + "Add server" -> "Добавить сервер" + "Add MCP server" -> "Добавить MCP-сервер" + "New MCP server" -> "Новый MCP-сервер" + "Configuration" -> "Конфигурация" + "Actions" -> "Действия" + "Configured Servers" -> "Настроенные серверы" + "Tap any server to view details, inspect available tools, or reconnect." -> "Нажмите на сервер, чтобы просмотреть сведения, инструменты или подключиться заново." + "Runtime and OAuth" -> "Среда выполнения и OAuth" + "Maximum concurrent background agents. Queued agents start as slots free." -> "Максимальное число фоновых агентов. Ожидающие агенты запускаются по мере освобождения мест." + "Default maximum agentic turns before wrap-up. 0 means unlimited." -> "Максимальное число ходов агента до завершения. 0 означает без ограничений." + "Additional turns after the wrap-up steering message." -> "Дополнительные ходы после сообщения о завершении." + "Hard cap on nested delegation. Main is 0; 0 or 1 disables nesting." -> "Ограничение вложенного делегирования. Основной агент имеет уровень 0; 0 или 1 отключает вложенность." + "Default completion grouping for background agents." -> "Группировка завершения фоновых агентов по умолчанию." + "Enable the schedule parameter and scheduled-job menu. Tool-spec changes apply on the next Pi session." -> "Включить параметр расписания и меню запланированных задач. Изменения применятся в следующем сеансе Pi." + "Write each subagent .output transcript by default. Agent frontmatter can override this." -> "По умолчанию сохранять расшифровку .output каждого субагента. Настройки агента могут изменить это." + "Hide built-in general-purpose, Explore, and Plan agents. Custom agents are unaffected." -> "Скрыть встроенных агентов общего назначения, Explore и Plan. Пользовательские агенты не затрагиваются." + "Agent used when subagent_type is unknown, disabled, or ambiguous. none rejects the call instead." -> "Агент, используемый при неизвестном, отключённом или неоднозначном типе. none отклоняет вызов." + "Fail startup on an unreadable or unparseable agent .md file instead of skipping it." -> "Останавливать запуск при нечитаемом или некорректном файле агента .md вместо его пропуска." + "Agent tool description size/mode. Custom reads .pi/agent-tool-description.md." -> "Размер и режим описания инструментов агента. Custom читает .pi/agent-tool-description.md." + "Validate subagent model choices against Pi scoped models (/scoped-models)." -> "Проверять выбор моделей субагентов по моделям Pi из /scoped-models." + "Live Aether agent card visibility above the composer." -> "Показывать карточку активного агента Aether над полем ввода." + "Keep the TUI FleetView enabled for Pi CLI; Aether renders the same roster as tappable cards." -> "Сохранять FleetView TUI для Pi CLI; Aether показывает тот же список в виде карточек." + "Route @handle messages to that agent. Model starts new agents through an off-screen clone; direct starts them immediately." -> "Направлять сообщения @handle выбранному агенту. Режим Model запускает агентов через скрытую копию, Direct — сразу." + "Persist subagent sessions so @handle can resume them long after completion." -> "Сохранять сеансы субагентов, чтобы @handle мог возобновить их после завершения." + "Subagent complete" -> "Субагент завершил работу" + "Subagent result" -> "Результат субагента" + "Subagent conversation" -> "Диалог с субагентом" + "Agent types" -> "Типы агентов" + "Enable or disable individual agent types. Disabled built-ins get a project stub; disabled custom agents keep their file." -> "Включайте и отключайте отдельные типы агентов. Для отключённых встроенных агентов создаётся заглушка проекта; файлы пользовательских агентов сохраняются." + "Draft a subagent creation request" -> "Создать запрос на добавление субагента" + "Default search provider" -> "Провайдер поиска по умолчанию" + "Used whenever a tool call leaves provider on Auto." -> "Используется, если для вызова инструмента выбран режим «Авто»." + "Comma-separated providers in priority order. Saving a route clears the single-provider override and uses transient, quota, and network fallbacks by default." -> "Провайдеры через запятую в порядке приоритета. Сохранение маршрута отключает одиночного провайдера и включает резервные варианты по сбоям, квотам и сети." + "Review a draft, return an automatic summary, or return raw results." -> "Проверять черновик, возвращать автоматическую сводку или исходные результаты." + "Seconds before an idle review is submitted automatically." -> "Сколько секунд ждать перед автоматической отправкой проверки." + "Open the review UI when a local search starts." -> "Открывать интерфейс проверки при начале локального поиска." + "Pick a specific provider above to configure its API key, base URL, and provider-specific options." -> "Выберите провайдера выше, чтобы настроить API-ключ, базовый URL и дополнительные параметры." + "Master switch for web search and source verification" -> "Главный переключатель веб-поиска и проверки источников" + "Repository cloning, cache path, and size limits" -> "Клонирование репозиториев, путь к кэшу и ограничения размера" + "Transcript and video understanding for YouTube and local files" -> "Расшифровка и анализ видео YouTube и локальных файлов" + "PDF download limits" -> "Ограничения загрузки PDF" + "SSRF exceptions and fetch domain policy" -> "Исключения SSRF и правила доменов загрузки" + "Advanced" -> "Дополнительно" + "Routing, review workflow, and summary model" -> "Маршрутизация, проверка результатов и модель сводки" + "Web research" -> "Исследование в интернете" + "Web content ready" -> "Веб-содержимое готово" + "Web access error" -> "Ошибка веб-доступа" + "Search workflow" -> "Процесс поиска" + "Search workflow updated" -> "Процесс поиска обновлён" + "Gemini Web account" -> "Учётная запись Gemini Web" + "Web Access setting saved. Reload the Pi extension to apply it." -> "Настройки веб-доступа сохранены. Перезагрузите расширение Pi, чтобы применить изменения." + "Draft a multi-source research request" -> "Создание запроса для исследования по нескольким источникам" + else -> value +} diff --git a/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt b/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt index 6fb0d7a8..3b8493af 100644 --- a/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt +++ b/app/src/main/java/com/zhousl/aether/ui/SettingsScreen.kt @@ -337,18 +337,22 @@ private val StatisticsNeutralChartColor = Color(0xFFDCE4FF) +/** Returns the localized display name for each selectable Android interface language. */ @Composable private fun settingsLanguageDisplayName(language: AppLanguage): String = when (language) { AppLanguage.English -> stringResource(R.string.language_english) AppLanguage.SimplifiedChinese -> stringResource(R.string.language_simplified_chinese) AppLanguage.Persian -> stringResource(R.string.language_persian) + AppLanguage.Russian -> stringResource(R.string.language_russian) } +/** Returns the localized description of the selected interface language. */ @Composable private fun settingsLanguageSubtitle(language: AppLanguage): String = when (language) { AppLanguage.English -> stringResource(R.string.settings_language_english_interface) AppLanguage.SimplifiedChinese -> stringResource(R.string.settings_language_simplified_chinese_interface) AppLanguage.Persian -> stringResource(R.string.settings_language_persian_interface) + AppLanguage.Russian -> stringResource(R.string.settings_language_russian_interface) } @Composable @@ -1329,6 +1333,7 @@ fun SettingsScreen( // Hub // ----------------------------------------------------------------------------- +/** Displays Android settings categories and language-aware titles for registered extension pages. */ @Composable private fun SettingsHub( generalSettingsSummary: String, @@ -1430,8 +1435,8 @@ private fun SettingsHub( if (index > 0) CardDivider() SettingsNavRow( icon = extensionIcon(settingPage.icon), - title = settingPage.title, - subtitle = settingPage.subtitle.ifBlank { settingPage.extensionName }, + title = localizedExtensionText(settingPage.title), + subtitle = localizedExtensionText(settingPage.subtitle.ifBlank { settingPage.extensionName }), onClick = { onOpenExtensionSettings(settingPage.id) }, ) } @@ -3006,6 +3011,7 @@ private fun ReliabilityPage( } } +/** Displays an extension settings page and its categories using the selected Android language. */ @Composable private fun AetherExtensionSettingsCategoriesPage( page: com.zhousl.aether.data.AetherAppExtensionSettingsPage, @@ -3015,7 +3021,7 @@ private fun AetherExtensionSettingsCategoriesPage( val controller = LocalAetherExtensionUiController.current val trailingIcon = settingsTrailingIcon(page.trailingIcon) SubPageScaffold( - title = page.title, + title = localizedExtensionText(page.title), onBack = onBack, trailingIcon = trailingIcon, onTrailingAction = { @@ -3029,7 +3035,7 @@ private fun AetherExtensionSettingsCategoriesPage( }, ) { if (page.subtitle.isNotBlank()) { - Text(page.subtitle, style = MaterialTheme.typography.bodySmall, color = AetherOnSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) + Text(localizedExtensionText(page.subtitle), style = MaterialTheme.typography.bodySmall, color = AetherOnSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) Spacer(Modifier.height(12.dp)) } if (page.sections.isNotEmpty()) { @@ -3041,8 +3047,8 @@ private fun AetherExtensionSettingsCategoriesPage( visibleCategories.forEachIndexed { index, category -> SettingsNavRow( icon = extensionIcon(category.icon), - title = category.title, - subtitle = category.subtitle, + title = localizedExtensionText(category.title), + subtitle = localizedExtensionText(category.subtitle), ) { onCategorySelected(category.id) } if (index < visibleCategories.lastIndex) CardDivider() } @@ -3050,6 +3056,7 @@ private fun AetherExtensionSettingsCategoriesPage( } } +/** Renders extension section headings and controls, translating display strings but preserving identifiers. */ @Composable private fun AetherExtensionSettingsSections( sections: List, @@ -3058,8 +3065,8 @@ private fun AetherExtensionSettingsSections( onCategorySelected: (String) -> Unit = {}, ) { sections.forEachIndexed { sectionIndex, section -> - val sectionTitle = section.optString("title") - val sectionDescription = section.optString("description") + val sectionTitle = localizedExtensionText(section.optString("title")) + val sectionDescription = localizedExtensionText(section.optString("description")) if (sectionIndex > 0) Spacer(Modifier.height(16.dp)) if (sectionTitle.isNotBlank() || sectionDescription.isNotBlank()) { if (sectionTitle.isNotBlank()) { @@ -3137,6 +3144,7 @@ private fun AetherExtensionSettingsSections( } } +/** Renders a schema-defined Android setting with localized labels and unchanged option values and action arguments. */ @Composable private fun AetherExtensionControlRow( setting: JSONObject, @@ -3147,8 +3155,8 @@ private fun AetherExtensionControlRow( val uriHandler = LocalUriHandler.current val id = "${page.id}:${setting.optString("id")}" val type = setting.optString("type").ifBlank { "text" } - val label = setting.optString("label").ifBlank { setting.optString("title") } - val description = setting.optString("description").ifBlank { setting.optString("subtitle") } + val label = localizedExtensionText(setting.optString("label").ifBlank { setting.optString("title") }) + val description = localizedExtensionText(setting.optString("description").ifBlank { setting.optString("subtitle") }) val action = setting.optString("action").ifBlank { "settings:${page.localId}:${setting.optString("id")}" } @@ -3191,7 +3199,7 @@ private fun AetherExtensionControlRow( selected = selected == value, onClick = { selected = value; update(value) }, shape = SegmentedButtonDefaults.itemShape(optionIndex, options.length()), - ) { Text(option.optString("label").ifBlank { value }) } + ) { Text(localizedExtensionText(option.optString("label").ifBlank { value })) } } } } @@ -3201,6 +3209,7 @@ private fun AetherExtensionControlRow( .firstOrNull { it.optString("value") == selected } ?.optString("label") .orEmpty() + .let { localizedExtensionText(it) } SelectionDropdownField( label = label, supportingText = description, @@ -3210,8 +3219,8 @@ private fun AetherExtensionControlRow( val value = option.optString("value") SelectionOption( key = value, - title = option.optString("label").ifBlank { value }, - subtitle = option.optString("description"), + title = localizedExtensionText(option.optString("label").ifBlank { value }), + subtitle = localizedExtensionText(option.optString("description")), selected = selected == value, onClick = { selected = value; update(value) }, ) @@ -3562,6 +3571,7 @@ private fun AetherExtensionItemCard( } } +/** Displays localized empty-state text and forwards its button action to the owning extension. */ @Composable private fun AetherExtensionEmptyState( setting: JSONObject, @@ -3569,9 +3579,9 @@ private fun AetherExtensionEmptyState( controller: AetherExtensionUiController?, onCategorySelected: (String) -> Unit, ) { - val title = setting.optString("title").ifBlank { setting.optString("label") } - val description = setting.optString("description").ifBlank { setting.optString("subtitle") } - val buttonLabel = setting.optString("buttonLabel").ifBlank { "Add" } + val title = localizedExtensionText(setting.optString("title").ifBlank { setting.optString("label") }) + val description = localizedExtensionText(setting.optString("description").ifBlank { setting.optString("subtitle") }) + val buttonLabel = localizedExtensionText(setting.optString("buttonLabel").ifBlank { "Add" }) val action = setting.optString("action") val category = setting.optString("category") val args = setting.optJSONObject("args") ?: JSONObject() @@ -3615,6 +3625,7 @@ private fun AetherExtensionEmptyState( } } +/** Displays a selected extension category with localized headings and its original settings schema. */ @Composable private fun AetherExtensionSettingsPage( page: com.zhousl.aether.data.AetherAppExtensionSettingsPage, @@ -3628,7 +3639,7 @@ private fun AetherExtensionSettingsPage( val trailingCategory = category?.trailingCategory?.ifBlank { null } ?: page.trailingCategory val trailingArgs = category?.trailingArgs?.takeIf { it.length() > 0 } ?: page.trailingArgs SubPageScaffold( - title = category?.title ?: page.title, + title = localizedExtensionText(category?.title ?: page.title), onBack = onBack, trailingIcon = trailingIcon, onTrailingAction = { @@ -3642,13 +3653,21 @@ private fun AetherExtensionSettingsPage( }, ) { if (category != null && category.subtitle.isNotBlank()) { - Text(category.subtitle, style = MaterialTheme.typography.bodySmall, color = AetherOnSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) + Text(localizedExtensionText(category.subtitle), style = MaterialTheme.typography.bodySmall, color = AetherOnSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) Spacer(Modifier.height(12.dp)) } AetherExtensionSettingsSections(category?.sections ?: page.sections, page, controller) } } +/** Resolves known builtin extension text using the language provided by the Android theme. */ +@Composable +private fun localizedExtensionText(value: String): String { + return extensionText(value, LocalAetherLanguage.current) +} + + +/** Resolves a schema icon name while allowing an extension to hide its trailing action. */ private fun settingsTrailingIcon(name: String): ImageVector? { if (name.isBlank()) return null return when (name.lowercase()) { diff --git a/app/src/main/java/com/zhousl/aether/ui/theme/Theme.kt b/app/src/main/java/com/zhousl/aether/ui/theme/Theme.kt index df1bf222..0682cf62 100644 --- a/app/src/main/java/com/zhousl/aether/ui/theme/Theme.kt +++ b/app/src/main/java/com/zhousl/aether/ui/theme/Theme.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.unit.sp import com.zhousl.aether.R import com.zhousl.aether.data.AppLanguage import com.zhousl.aether.data.AppThemeMode +import com.zhousl.aether.ui.LocalAetherLanguage import com.zhousl.aether.platform.LocalReduceMotion import com.zhousl.aether.platform.rememberPlatformAccessibilityPreferences @@ -109,6 +110,7 @@ val VazirmatnFontFamily = FontFamily( Font(R.font.vazirmatn_bold, FontWeight.Bold) ) +/** Returns typography appropriate to the selected interface language, keeping locale-specific font choices consistent. */ private fun getAetherTypography(fontFamily: FontFamily) = Typography( headlineLarge = TextStyle( fontFamily = fontFamily, @@ -168,6 +170,7 @@ private fun getAetherTypography(fontFamily: FontFamily) = Typography( ) ) +/** Provides Android colors, typography, layout direction, and the selected language to descendant composables. */ @Composable fun AetherTheme( themeMode: AppThemeMode = AppThemeMode.System, @@ -197,6 +200,7 @@ fun AetherTheme( getAetherTypography(currentFontFamily) } CompositionLocalProvider( + LocalAetherLanguage provides language, LocalLayoutDirection provides layoutDirection, LocalReduceMotion provides accessibility.reduceMotion, ) { diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 7e81cd2e..beb1120f 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -3,4 +3,5 @@ + diff --git a/app/src/test/java/com/zhousl/aether/ChatImePolicyTest.kt b/app/src/test/java/com/zhousl/aether/ChatImePolicyTest.kt new file mode 100644 index 00000000..6430c4ec --- /dev/null +++ b/app/src/test/java/com/zhousl/aether/ChatImePolicyTest.kt @@ -0,0 +1,39 @@ +package com.zhousl.aether + +import android.view.WindowManager.LayoutParams +import org.junit.Assert.assertEquals +import org.junit.Test + +@Suppress("DEPRECATION") +class ChatImePolicyTest { + /** Android 8–10 must expose the resized frame used by the AndroidX IME fallback. */ + @Test + fun legacyAndroidUsesResizeIncludingApi29() { + for (sdk in 26..29) { + assertEquals(LayoutParams.SOFT_INPUT_ADJUST_RESIZE, + chatSoftInputMode(sdk, LayoutParams.SOFT_INPUT_ADJUST_NOTHING)) + } + } + + /** The API 30 boundary retains Compose-owned keyboard spacing on modern Android. */ + @Test + fun modernAndroidKeepsAdjustNothing() { + for (sdk in listOf(30, 31, 35, 36)) { + assertEquals(LayoutParams.SOFT_INPUT_ADJUST_NOTHING, + chatSoftInputMode(sdk, LayoutParams.SOFT_INPUT_ADJUST_RESIZE)) + } + } + + /** Recreating or reconfiguring the window preserves state flags without accumulating adjustments. */ + @Test + fun preservesVisibilityFlagsAndRepeatedApplication() { + val flags = LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN or LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION + for (sdk in listOf(29, 30)) { + val expectedAdjustment = if (sdk == 29) LayoutParams.SOFT_INPUT_ADJUST_RESIZE + else LayoutParams.SOFT_INPUT_ADJUST_NOTHING + val result = chatSoftInputMode(sdk, flags or LayoutParams.SOFT_INPUT_ADJUST_PAN) + assertEquals(flags or expectedAdjustment, result) + assertEquals(result, chatSoftInputMode(sdk, result)) + } + } +} diff --git a/app/src/test/java/com/zhousl/aether/ui/ExtensionLocalizationTest.kt b/app/src/test/java/com/zhousl/aether/ui/ExtensionLocalizationTest.kt new file mode 100644 index 00000000..d57e6d5f --- /dev/null +++ b/app/src/test/java/com/zhousl/aether/ui/ExtensionLocalizationTest.kt @@ -0,0 +1,44 @@ +package com.zhousl.aether.ui + +import com.zhousl.aether.data.AppLanguage +import org.junit.Test +import org.junit.Assert.assertEquals + +class ExtensionLocalizationTest { + /** Verifies that translation affects only Russian and leaves unknown extension text unchanged. */ + @Test + fun keepsOtherLanguagesAndUnknownTextIntact() { + AppLanguage.entries.filter { it != AppLanguage.Russian }.forEach { language -> + assertEquals("Web Access", extensionText("Web Access", language)) + assertEquals("Search workflow updated", extensionText("Search workflow updated", language)) + } + assertEquals("Custom title", extensionText("Custom title", AppLanguage.Russian)) + assertEquals("Веб-доступ", extensionText("Web Access", AppLanguage.Russian)) + assertEquals("Процесс поиска обновлён", extensionText("Search workflow updated", AppLanguage.Russian)) + } + + /** Verifies complete Russian notification text while retaining the original diagnostic error detail. */ + @Test + fun translatesNotificationWithoutChangingErrorDetail() { + val message = "Web Access settings could not read the Pi config: ENOENT /data/pi/config.json" + assertEquals(message, extensionText(message, AppLanguage.English)) + assertEquals("Настройки веб-доступа: не удалось прочитать конфигурацию Pi: ENOENT /data/pi/config.json", extensionText(message, AppLanguage.Russian)) + } + + /** Verifies the Russian builtin menu titles and preserves their titles in every other app language. */ + @Test + fun translatesKnownMenuItemsOnlyInRussian() { + assertEquals("Субагенты", extensionComposerMenuTitle("subagents", "Subagents", AppLanguage.Russian)) + assertEquals("Исследование в интернете", extensionComposerMenuTitle("research-web", "Research on the web", AppLanguage.Russian)) + AppLanguage.entries.filter { it != AppLanguage.Russian }.forEach { language -> + assertEquals("Subagents", extensionComposerMenuTitle("subagents", "Subagents", language)) + assertEquals("Research on the web", extensionComposerMenuTitle("research-web", "Research on the web", language)) + } + } + + /** Verifies that a custom composer entry keeps its own title when Russian is selected. */ + @Test + fun preservesUnknownExtensionTitles() { + assertEquals("Custom title", extensionComposerMenuTitle("custom", "Custom title", AppLanguage.Russian)) + } +} diff --git a/shared/src/androidMain/kotlin/com/zhousl/aether/data/PlatformDefaults.android.kt b/shared/src/androidMain/kotlin/com/zhousl/aether/data/PlatformDefaults.android.kt index 0427004e..42ade273 100644 --- a/shared/src/androidMain/kotlin/com/zhousl/aether/data/PlatformDefaults.android.kt +++ b/shared/src/androidMain/kotlin/com/zhousl/aether/data/PlatformDefaults.android.kt @@ -17,10 +17,20 @@ actual fun platformUptimeMillis(): Long = try { actual fun platformRandomUuid(): String = UUID.randomUUID().toString() +/** Reads the Android device language tag used to select the initial interface language. */ actual fun platformLanguageTag(): String = Locale.getDefault().toLanguageTag() +/** + * Chooses the initial Android agent prompt from the device locale. + * Settings persist this value independently of UI language; changing the language + * must not overwrite a prompt that the user may have edited. + */ actual fun platformDefaultSystemPrompt(): String = - "You are Aether, a local-first Android agent that can call tools and complete tasks on-device. Use available tools instead of guessing local state." + if (platformLanguageTag().startsWith("ru", ignoreCase = true)) { + "Вы — Aether, локальный Android-агент, который умеет вызывать инструменты и выполнять задачи на устройстве. Используйте доступные инструменты вместо предположений о локальном состоянии." + } else { + "You are Aether, a local-first Android agent that can call tools and complete tasks on-device. Use available tools instead of guessing local state." + } actual fun platformDefaultLlmUserAgent(): String = "Aether/1.0 (Android)" @@ -35,8 +45,10 @@ actual fun platformDynamicPromptValues(): Map { ) } +/** Maps Android device locales to supported interface languages, falling back to English. */ fun defaultAppLanguage(locale: Locale): AppLanguage = when { locale.language.equals("zh", ignoreCase = true) -> AppLanguage.SimplifiedChinese locale.language.equals("fa", ignoreCase = true) -> AppLanguage.Persian + locale.language.equals("ru", ignoreCase = true) -> AppLanguage.Russian else -> AppLanguage.English } diff --git a/shared/src/commonMain/composeResources/values-ru/shared_conversation_messages.xml b/shared/src/commonMain/composeResources/values-ru/shared_conversation_messages.xml new file mode 100644 index 00000000..bcb0954d --- /dev/null +++ b/shared/src/commonMain/composeResources/values-ru/shared_conversation_messages.xml @@ -0,0 +1,9 @@ + + + Мыслительный процесс + Предыдущий ответ + Следующий ответ + Кэш + %1$s токенов/с + Интернет + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/values-ru/shared_extensions.xml b/shared/src/commonMain/composeResources/values-ru/shared_extensions.xml new file mode 100644 index 00000000..b7ee0773 --- /dev/null +++ b/shared/src/commonMain/composeResources/values-ru/shared_extensions.xml @@ -0,0 +1,32 @@ + + + Пакеты расширений запускаются как доверенный код сценария с широким доступом к агенту, узлу приложения, локальной среде выполнения и рабочей области. + Пакеты: %1$d · Скрипты: %2$d · Расширения интерфейса Aether: %3$d + Включено средой выполнения скрипта + Расширения скриптов недоступны на этой платформе. + Установите исходный код npm. + npm:имя-пакета + Импортируйте JavaScript, TypeScript или zip. + Обновить расширения + Импортировать расширение + Импортировано + Установленный путь + Расширения Pi: %1$d · Расширения интерфейса Aether: %2$d · Навыки: %3$d · Промпты: %4$d · Темы: %5$d + %1$d загружено записей + Расширения Pi + Расширения пользовательского интерфейса Aether + Навыки + Промпты + Темы + Загруженные записи пользовательского интерфейса Aether + Ошибки выполнения скрипта %1$d + Удалить расширение? + %1$s будет удален из локальной среды выполнения. Это невозможно отменить. + Выберите файл с расширением JavaScript, TypeScript или zip. + Размер выбранного расширения превышает 32 МБ. + Архив расширения содержит небезопасный путь. + Архив должен содержать пакет расширения скрипта или индексный файл JavaScript/TypeScript. + Похоже, этому пакету требуется настольная операционная система или архитектура процессора, недоступная в текущей среде выполнения Aether. + Применение изменений расширения… + Расширения обновлены. + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/values-ru/strings.xml b/shared/src/commonMain/composeResources/values-ru/strings.xml new file mode 100644 index 00000000..ff19bde6 --- /dev/null +++ b/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -0,0 +1,1065 @@ + + + Показать пароль + Скрыть пароль + Aether + Меню + Новый чат + Настройка root завершена + Не удалось настроить root: %1$s + Не удалось загрузить обновление: %1$s. + Приостановите этот сеанс перед его удалением. + Сеанс экспортирован. + Не удалось экспортировать сеанс + Данные приложения экспортированы. + Не удалось экспортировать данные приложения. + Журналы экспортированы + Не удалось экспортировать журналы + Данные приложения импортированы. + Не удалось импортировать данные приложения: %1$s. + Требуется запрос на выполнение запланированного задания. + Приостановите этот сеанс перед редактированием предыдущего сообщения. + Этот сеанс больше не работает. Попробуйте отправить еще раз. + Завершите редактирование, прежде чем сжимать этот разговор. + О компактировании речи пока не идет. + Приостановите этот сеанс перед его сжатием. + Пока недостаточно разговоров для уплотнения. + Нет текста для сжатия. + Настройте поставщика перед сжатием. + Не удалось уплотнить: %1$s. + Сжатие вернуло пустой ответ. + Ни один провайдер не настроен. Откройте «Настройки» > «Поставщики», чтобы добавить его. + Невозможно экспортировать этот разговор. + Установленный навык: %1$s. + Не удалось установить навык: %1$s. + Aether обновлен. + Не удалось проверить наличие обновлений: %1$s + Не удалось получить модели: %1$s. + Ещё + Закрыть + Пропустить + Назад + Выбрать + Продолжить + Готово + Отмена + Далее + Установить + Обновить + Открыть Termux + Предоставить доступ + Начать чат + Отправить + Отправить продолжение + Скрыть + Чат + Переименовать + Экспорт + Удалить + Копировать + Выбрать текст + Редактировать сообщение + Повторить попытку + Удалить + Копировать ответ + Повторить ответ + Удалить ответ + Статистика + Токены сеанса + Токены хода + Токены рассуждений + Скорость вывода + Задержка первого токена + Вход + Выход + Источник + Рассуждение + - + Недоступно + Сохранить + Поиск + Вкл. + Выкл. + Необязательный + Согласиться + Отклонить + Позже + политика конфиденциальности + Пожалуйста, просмотрите и согласитесь с условиями Aether. + перед использованием приложения. + Доступно обновление + Aether %1$s доступен. Вы можете скачать APK и установить его прямо сейчас. + Загрузка... + Загрузите и установите + Открыть + Ошибка + Отмена + Продолжить + Назад + Готово + Повторить + Изменить + Удалить + Невозможно открыть эту ссылку + Невозможно открыть этот файл + Невозможно открыть установщик APK + Невозможно открыть этот экран + Свернуть + Развернуть + Удалить + Чем я могу помочь? + Создать изображение + Мозговой штурм + Анализ изображений + Aether думает... + Ответ на Aether + Спросить Aether + Голос + Изображения + Приложения + теги GPT + Недавний + Разговоров пока нет. + Пустой черновик + Добавить вложение или инструмент + Фотографии + Файлы + Анализ изображения + Код + Помогите мне написать + Обобщить файл + мышление + Отвечая + Работающий + Остановлено + Прервано + Запрос не удался. + Сессия Aether + Aether-файл + Невозможно прикрепить файл. + Контекст сжат + Сжатие контекста + Сжать контекст этой темы + Сжать контекст этого потока (заполнено %1$d%%) + %1$d%% полный контекст + Компактный + Дополнительный контекст + Добавить заметку + Режим «Спросить агента» + Спросите с помощью выбранных инструментов + В очереди + Рулевое управление + 1 вложение + %1$d вложения + Выберите модель + Модель + Усилие рассуждения + Выберите усилие рассуждения + Выключенный + Минимальный + Низкий + Середина + Высокий + XВысокий + Макс + Ваша первая подсказка готова + Нажмите «Отправить», чтобы протестировать Aether. + Редактирование предыдущего сообщения + Отправка заменит ответы, пришедшие после нее. + Виртуальный дисплей режима агента + Предварительный просмотр виртуального дисплея появится после запуска агента. + Жить + поддерживать + Нет вывода + Содержимое урезано для удобства чтения. + Мысль для %1$s + Работаю на %1$s + Жду рассуждений... + Грубые рассуждения + Обобщающие рассуждения + Готовим краткое наглядное резюме. + Свернуть работу + Расширить работу + Мысль для %1$s и реализация инструментов %2$d + Настройки + Общие настройки + Язык + Выберите язык приложения. + English + 简体中文 + فارسی + Русский + Английский интерфейс + Упрощенный китайский интерфейс + Персидский интерфейс + Русский интерфейс + Тема + Выберите внешний вид приложения. + Системная + Светлая + Тёмная + Следите за светлым или темным внешним видом системы. + Сохраните нынешний яркий внешний вид. + Используйте более темный интерфейс в приложении. + Системная подсказка + Инструкции применяются к каждому новому разговору. + Режим рабочей области + Выберите, будет ли каждый сеанс иметь собственное рабочее пространство или все будут использовать одно рабочее пространство. + Переменные среды + Вводится в каждую команду bash Termux, например HTTP_PROXY или HTTPS_PROXY. + Имя + Значение + Удалить переменную + Добавить переменную + Termux уже настроен + Доступ к команде Termux уже работает. Вам не нужно повторно запускать автоматическую настройку Root. + Aether запускает bash через Termux. Завершите настройку здесь, чтобы вызовы инструментов работали для каждого пользователя без ручных действий с adb. + Корневая автоматическая настройка + Termux подключен + Разрешение предоставлено, и проверка установки прошла успешно. + Единое рабочее пространство + Все сеансы начинаются в ~/.aether/workspace, а загрузки сохраняются в папке uploads/. + Независимые рабочие пространства + Каждый сеанс продолжает использовать свой собственный каталог ~/.aether/workspaces/. + Настройка... + Запустите настройку Root еще раз + Попробуйте установку Root + Настроить с рутом + Aether может автоматически разбудить Termux в фоновом режиме с помощью Root, когда Termux еще не запущен. + Aether может использовать su для включения командного доступа Termux и фонового пробуждения. + На рутированных устройствах это позволяет избежать ручного разрешения и настройки Termux. + Повторите попытку после предоставления su для Aether или продолжите действия по ручной настройке, описанные ниже. + Поставщики моделей + Персонализация + Надежность + Навыки агента + Termux + Режим агента + Статистика + Использование еще не зарегистрировано + Токены %1$s · Ходы %2$d + Режим агента недоступен + Режим агента уже настроен + Авторизуйте изолированные инструменты виртуального дисплея с помощью Shizuku или Root. Пропустите это на устройствах без какой-либо опции. + Авторизация в режиме агента + Включает изолированные инструменты виртуального отображения. Требуется Shizuku или Root. + Метод авторизации + Использует службу Shizuku с повышенными правами. + Использует корневую оболочку для привилегированного ввода. + Режим Shizuku создает отображение из пользовательской службы Shizuku, чтобы приложения могли отображать изображение за пределами главного экрана. Корневой режим остается экспериментальным. + Виртуальный дисплей + Отображение %1$s активно при %2$d x %3$d. + Ни один виртуальный дисплей режима агента не активен. + Видимые дисплеи + Для Aether в настоящее время не видны никакие дисплеи. + Безымянный + Остановить виртуальный дисплей + Root-авторизация готова. + На этом устройстве не обнаружен двоичный файл su. + Предоставьте su Aether, а затем обновите этот статус. + В root-авторизации было отказано. Предоставьте su Aether, а затем обновите этот статус. + Невозможно проверить статус root. + Обновите статус root перед использованием режима агента. + Авторизация в режиме агента отключена. + Корневой режим запросит su при запуске режима агента. + Shizuku работает, а Aether авторизован. + Установите Shizuku перед использованием режима Shizuku. + Запустите Shizuku, затем обновите этот статус. + Предоставьте разрешение Aether в Shizuku перед использованием режима агента. + Разрешение Shizuku было отклонено. Запросите его еще раз или включите Aether внутри Shizuku. + Сохраните настройки режима агента, затем обновите статус Shizuku. + Переключитесь в режим Root, чтобы проверить статус Root. + Невозможно проверить статус Shizuku. + Статус авторизации + Установить Shizuku + Открыть Shizuku + Предоставить доступ + Завершите настройку Termux перед настройкой режима агента. + Требуется установка Termux + Корневой доступ + Обнаружить su и дождаться авторизации. + Termux командный доступ + Включите внешние приложения и предоставьте разрешение на выполнение команд. + Режим корневого агента + Выберите Root-авторизацию для локального управления устройством. + Настройка root завершена + Готов начать + Рут недоступен + Root-авторизация не предоставлена + Требуется Termux + Установка не завершена + Подготовка установки + Запустить снова + Обзор + Всего токенов + Сессии + Сообщения + Средняя скорость + Средняя задержка + Ежедневное использование токенов + Последние 7 дней + Недавнее использование токена + Последние 14 дней + Историческое использование токенов + Пиковый день + Самый большой ход + Записанные ходы + Микс токенов + Скорость + Последние образцы скорости вывода + Пока нет образцов скорости + %1$s · Токены %2$s + Инструменты только для разработчиков и элементы управления воспроизведением. + Резервные сообщения разработчика + Эти провайдеры отказались от роли сообщений разработчика, поэтому Aether теперь использует для них системные сообщения. Отключите провайдера здесь, чтобы снова попробовать сообщения разработчика. + Данные приложения + Импортируйте или экспортируйте полный локальный набор данных Aether как JSON. + Импортировать данные приложения + Экспортировать данные приложения + Экспортировать конфиденциальные данные приложения? + Эта полная резервная копия включает учетные данные поставщика, пользовательские заголовки, переменные среды и другие личные настройки. Храните его в надежном месте и не делитесь им. + Журналы + Экспортируйте последние выходные данные logcat и диагностические метаданные Aether. + Экспорт журналов + Обновление тестирования + Загрузите последнюю версию GitHub и покажите подсказку об обновлении, даже если установленная версия актуальна. + Принудительное обновление + Termux переопределение готовности + Тестирование переключателя для входа в режим агента. On рассматривает Termux как готовый; off считает его неготовым. + Считайте Termux готовым. + Включено = готово. Выкл. = не готов. + Повтор повторного тура + Начинается с выбора маршрута, затем проходит локальную настройку и авторизацию в режиме агента. + Переиграть вторую часть + Скачивание %1$d%% + Загрузка обновления + Проверка выпусков GitHub + Aether %1$s доступен + Проверьте выпуски GitHub на наличие более нового APK + Логотип Aether + Автор + Версия + Проверить обновления + Веб-сайт + GitHub + Политика конфиденциальности + Скачать и установить + Aether запрашивает su, разрешает командный доступ Termux и готовит режим корневого агента. + Доступ к командам Termux и авторизация в режиме корневого агента готовы. + Запустите установку, чтобы предоставить su и завершите настройку локального доступа. + На этом устройстве не обнаружен двоичный файл su. + Предоставьте su Aether, затем снова попробуйте автоматическую настройку Root. + Сначала установите Termux, а затем вернитесь к этой настройке. + Команда установки завершилась с ошибкой. Просмотрите приведенную выше информацию и повторите попытку, когда будете готовы. + Настройка Root-установки + Поддерживает {{current_datetime}}, {{current_date}}, {{current_time}}, {{timezone}} и {{unix_timestamp}}. + Сначала настройте Termux. В режиме агента для рабочей области и потока снимков экрана используется мост Termux, поэтому авторизация не может быть включена, пока Termux не будет готов. + Авторизация в режиме корневого агента уже готова. Вам не нужно повторно запускать автоматическую настройку Root. + Авторизация в режиме агента уже готова. Автоматическая установка Root не требуется; продолжение приведет к перенастройке режима агента на root. + Используйте su, чтобы выбрать режим root и автоматически подготовить управление локальным устройством. + Настройки разработчика + О приложении + Начало тура + Язык и внешний вид приложения + %1$s / %2$s + Поставщики %1$d включены + Пользовательские инструкции + Фоновая работа включена + Фоновая работа отключена + Подключено + Требуется настройка + Авторизация и виртуальный показ + Воспроизведите первый запуск и процесс настройки. + Воспроизведите последующий процесс адаптации. + Выпуск %1$s + Модели по умолчанию + Выберите специальные настройки по умолчанию для чата, заголовков, именования и сжатия. + Модель чата по умолчанию + Используется для новых чатов и когда в разговоре еще не выбрана модель. + Модель заголовка по умолчанию + Используется, когда Aether автоматически генерирует заголовки разговоров. + Модель именования по умолчанию + Используется для потоков автоматического именования, таких как навыки агента. + Модель сжатия по умолчанию + Используется, когда /compact суммирует текущий разговор. + Отдавайте приоритет моделям SOTA + Отдавайте приоритет эффективным сводным моделям + Автоматический + Автоматически · %1$s + Поставщики не настроены + Добавьте провайдера для подключения к LLM API. + Добавить поставщика + Изменить поставщика + Включено моделей: %1$d из %2$d + Нет доступных включённых моделей + Сначала включите хотя бы одну модель поставщика, а затем вернитесь сюда, чтобы выбрать модель по умолчанию. + Поиск моделей + Ни одна модель не соответствует этому запросу. + Многозадачность + Запускайте задачи в фоновом режиме + Использует службу переднего плана Android, поэтому активные чаты могут продолжать работать после того, как вы покинете Aether. + Уведомлять о завершении фоновых задач + Показывает предупреждение о завершении, когда выполнение завершается, а Aether не отображается на экране. + Восстановить соединение + Повторное подключение после %1$ds + Тайм-аут поставщика (секунды) + Провайдеры интерпретируют этот таймаут по-разному. Aether повторяет явные неудачные попытки повторной попытки, но не отправляет повторно неоднозначный тайм-аут после начала действия ответа. Диапазон: 30-3600 секунд. + %1$d настроен + Запланированные задачи + %1$d настроен + Управляйте установленными навыками и оставляйте только те пакеты, которые Aether хочет использовать в чате. + Навыки еще не установлены. Для начала просмотрите или импортируйте навык. + Импортируйте навыки из папки, zip-архива или удаленного URL. + Добавить навык + Идентификатор навыка + Файлы + Разрешенные инструменты + Любой + Совместимость + Источник + Путь + Папка + Импортируйте навыки агента из локальной папки, zip-файла или удаленного URL. + Выберите папку, содержащую SKILL.md. + Выберите папку + Выберите ZIP-файл, содержащий SKILL.md. + Выбрать ZIP-архив + Удаленный навык URL + Поддерживаются URL-адреса репозитория/дерева GitHub и прямые zip-ссылки. + Установка... + Установить из URL + Aether просыпается в следующее подходящее время и запускает подсказку задачи в качестве автоматического поворота агента. + Нет запланированных задач + Создайте его вручную или попросите агента создать его с помощью инструмента планирования. + Добавить задачу + Следующий запуск: %1$s + Следующий запуск не запланирован + Создано агентом + Создано вручную + Добавить запланированную задачу + Редактировать запланированное задание + Имя + Запрос + Включено + Отключенные задачи остаются сохраненными, но не пробуждают Aether. + Интервал + Ежедневно + Еженедельно + Каждые N минут + Время начала активности: ЧЧ:мм + Время окончания активности: ЧЧ:мм + Время ЧЧ:мм, через запятую + Дни 1-7 или пн, вт + Время ЧЧ:мм + Интервальные окна не являются обязательными. Оставьте начало и конец пустыми, чтобы работать весь день. + Имя провайдера + Идентификатор поставщика + Требуется идентификатор поставщика. + Используйте только строчные буквы, цифры и символы подчеркивания. + Этот идентификатор поставщика уже используется. + Поставщик + Использует встроенную интеграцию поставщиков и каталог моделей. + Соединяет пользовательскую конечную точку с вашими собственными URL, моделями и заголовками. + Режим совместимости + Отправляет инструкции системе и разработчику в виде пользовательских сообщений для конечных точек с ограниченной поддержкой ролей. + Аутентификация + Ключ API + OAuth + Среда + Использует AWS, Google Cloud или другие учетные данные, уже настроенные во встроенной среде Linux. + %1$s OAuth + Аккаунт авторизован. + Подключен как %1$s + Подключите свою учетную запись подписки, чтобы продолжить. + Ожидание авторизации + Ждём верительные грамоты. + Ключ API настроен. + Полная авторизация в вашем браузере. + Введите код устройства в браузере. + Подключить аккаунт + Переподключить аккаунт + Отключить + Вход в браузер + Вход по коду устройства + Требуется авторизация + Значение авторизации + Среда поставщика + Настроены переменные %1$d + Добавить переменную среды поставщика + Переменная + Значение + Удалить переменную среды поставщика + API Ключ + База URL + Идентификаторы моделей вручную + Пользовательский агент + Включенные модели отображаются в средстве выбора моделей чата. Настройте аутентификацию по ключу API перед обновлением каталога. + Включенные модели отображаются в средстве выбора моделей чата. Обновление загружает модели от провайдера API. + Выберите провайдера + Список моделей + Модели еще не загружены. + %1$d/%2$d включен + Получить модели + Выбрать все + Очистить все + Пользовательские заголовки запросов + %1$d настроен + Добавить заголовок + Заголовок + Значение + Удалить заголовок + Шаг %1$d из %2$d + Выберите аутентификацию + Выберите способ подключения. + Использовать подписку + Используйте это, если вы уже заплатили за поддерживаемую подписку на AI. + Используйте ключ API. + Используйте лимитированный биллинг API или ключ от вашего провайдера. + Использовать учетные данные среды + Используйте учетные данные, уже настроенные для AWS, Google Cloud или другого облака. + Выберите провайдера + Доступно при входе в систему по выбранной подписке. + Введите ключ, выданный провайдером. + Использует учетные данные из встроенной среды Linux. + Поиск поставщиков + Нет подходящих поставщиков. + Настройка учетных данных + Настроить %1$s + Введите учетные данные, выданные этим провайдером. + Выберите способ входа и завершите авторизацию. + %1$s API ключ + Учетные данные настроены. + Введите необходимые учетные данные, чтобы продолжить. + Ожидание ввода + Настроить ключ API + Настройка учетных данных + Перенастроить ключ API + Удалить ключ API + Выберите модели + Выберите, какие модели %1$s должны появиться в Aether. + Расширенные настройки + Скрыть дополнительные настройки + Предварительный просмотр недоступен для этого типа файла. + Невозможно просмотреть это вложение. + Это вложение слишком велико для загрузки в режим предварительного просмотра. + Предварительный просмотр усечен для удобства чтения. + Фото + Файл + Копирование в рабочую область + Не удалось скопировать рабочую область. + Удалить вложение + Закрыть предварительный просмотр + Предварительный просмотр изображения + Предварительный просмотр недоступен. + Открыть оригинал + Не удалось загрузить предварительный просмотр изображения. + Не удалось загрузить предварительный просмотр изображения (HTTP %1$d). + Не удалось прочитать данные изображения. + Не удалось прочитать данные изображения из рабочей области. + Размер изображения превышает 8,0 МБ. + Не удалось отобразить диаграмму Русалки. + Неверный синтаксис Русалки. + Предварительный просмотр русалки + Ни один чат не соответствует + Файл сохранен. + Не удалось сохранить файл + Ответ скопирован + Установите Termux, чтобы включить bash. + Предоставить доступ к команде Termux + Включите внешние приложения в Termux + Завершите настройку Termux. + Установить Termux + Предоставить доступ + Настройки приложения + Скопируйте и откройте Termux + Копировать команду настройки + Открыть Termux + Настройки Termux + Команда настройки Termux скопирована. + Aether использует Termux для запуска команд оболочки на устройстве. + Разрешите Aether использовать разрешение Android «Выполнять команды в среде Termux». + Termux, похоже, не работает в фоновом режиме. + Откройте Termux и оставьте его работать в фоновом режиме, затем вернитесь в Aether и обновите. + Откройте Termux, вставьте команду настройки Aether, затем вернитесь и обновите. + Откройте Termux один раз и проверьте его настройки интеграции. + Доступ Termux предоставлен + Termux доступ не предоставлен + Режим агента + дисплей %1$d + Кадр повтора режима агента + Предварительный просмотр недоступен + Если на этом телефоне есть root, Aether может автоматически настроить Termux и режим агента. + Рут доступен. Предоставьте su Aether, чтобы пропустить ручные шаги Termux и режима агента. + Aether включает внешние приложения Termux, предоставляет разрешения на выполнение команд и выбирает режим корневого агента. + Настройка root завершена. Termux и режим агента готовы. + Если у вас есть Root, попробуйте автоматическую настройку. В противном случае продолжите существующий ручной процесс. + Корневой доступ запрещен или истекло время ожидания. + Сначала установите Termux, затем Aether сможет завершить настройку с помощью Root. + Настройка root не завершена. Вы можете повторить попытку или продолжить вручную. + Локальные инструменты готовы. + Сначала установите Termux, а затем вернитесь сюда. + Предоставьте разрешение «Выполнять команды в среде Termux» в настройках Android. + Откройте Termux один раз, затем вернитесь и обновите. + Скопируйте команду настройки, вставьте ее в Termux, затем вернитесь и обновите. + Откройте Termux один раз, а затем обновите здесь. + Скопируйте команду настройки, вставьте ее в Termux, затем вернитесь и обновите. + Значок Aether + Начать + Ключ API + Загрузка моделей... + Модель + Или введите свою модель + Добро пожаловать в Aether + Агент на устройстве, который работает со всем. + Добро пожаловать + Настраивать + Конфигурация ИИ + Связь + Поставщик + Реквизиты для входа + Модели + Журнал установки + Здесь появится вывод времени выполнения. + Настроить %1$s + Выберите способ подключения. + Подписка + Для существующих платных планов AI. + API Ключ + Оплата по мере использования. + Учетные данные среды + GCP, AWS или пользовательские настройки + Бесплатные и платные планы + Только платно + Ограниченный бесплатный доступ + Только официальный API + Рекомендуется · по индивидуальному заказу + Теперь выберите провайдера, который поддерживает этот метод аутентификации. + Поиск поставщиков + Провайдер: %1$s + Завершите аутентификацию, затем Aether загрузит доступные модели. + Выберите нужную модель, и мы сразу перейдем к чату. + Вы можете изменить это позже в настройках. + Aether размещает лучшие совпадения первыми, если они доступны. + Разрешить уведомления о завершении? + Aether может предупредить вас о завершении фоновой задачи. + Разрешить уведомления + Команда настройки Termux скопирована. + Большой. Теперь давайте предоставим Aether доступ к вашему устройству, чтобы инструменты могли запускаться локально. + Настройки приложения + Скопируйте и откройте Termux + Копировать команду настройки + Настройки Termux + Корневой ярлык + Ожидание root-авторизации... + Продолжить настройку вручную + Ручная настройка + Использовать рут-настройку + Режим агента является необязательным. Для управления изолированным дисплеем Android требуется Root или Shizuku. + Выберите метод авторизации или пока пропустите этот шаг. + Используйте службу Shizuku с повышенными правами при установке приложения. + Используйте корневую оболочку для привилегированного ввода на корневых устройствах. + Оставьте режим агента выключенным и включите его позже в настройках. + Вставьте это сюда + Команда + Результат + Аргументы + Код выхода: %1$d + Управление текущим пробегом + Очередь на следующий ход + Выполнение инструментов %1$d + Исполненные инструменты %1$d + Свернуть инструменты + Развернуть инструменты + Выполнение команды bash + Выполнена команда bash + Получение вывода bash + Получен вывод bash + Остановка команды bash + Остановлена ​​команда bash + Ожидающий + Ждал + Чтение файла + Чтение файла + Редактирование файла + Отредактированный файл + Запись файла + Записал файл + Поиск файлов + Искомые файлы + Поиск файлов + Найденные файлы + Листинг файлов + Перечисленные файлы + Анализ изображения + Анализируемое изображение + %1$s %2$s + Идет поиск + Поиск + Получение + Получено + Чтение + Читать + Обновление + Обновлено + Установка + Установлено + Удаление + Удаленный + Сохранение + Сохранено + Создание + Созданный + Запуск + Запущен + Прессование + Прессованный + Тавили поиск + веб-страница + Настройки Aether + Навыки агента + MCP сервер + запланированное задание + установленные приложения + приложение в режиме агента + ключ в режиме агента + Использование %1$s + Б/у %1$s + Чтение навыков агента + Прочтите навыки агента + Чтение серверов MCP + Чтение серверов MCP + Настройка root-доступа Termux + Настроен root-доступ Termux. + Проверка установки Root + Проверил настройку Root + Проверка настройки Termux + Проверил настройку Termux. + Обновление авторизации в режиме агента + Обновлена ​​авторизация в режиме агента. + Запрос разрешения Shizuku + Запрошено разрешение Shizuku + Остановка экрана режима агента + Экран режима остановленного агента + Обновление экранов режима агента + Обновлены экраны режима агента. + Проверка авторизации в режиме агента + Проверена авторизация в режиме агента. + Чтение запланированных задач + Чтение запланированных задач + Чтение диагностики Aether + Чтение диагностики Aether + Управление Aether + Управляемый Aether + Запуск режима агента + Экран запущенного режима агента + Проверка дисплея режима агента + Проверено отображение режима агента + Нажатие на экран режима агента + Нажатый экран режима агента + Пролистывание экрана режима агента + Проведенный дисплей режима агента + Ввод в режиме агента + Введено в режиме агента + Захват экрана режима агента + Экран режима захваченного агента + Использование экрана режима агента + Экран используемого режима агента + Запуск Chrome + Запустил Chrome + Открытие страницы в Chrome + Открытая страница в Chrome + Открытие %1$s в Chrome + Открыт %1$s в Chrome. + Проверка Chrome + Проверил Хром + Нажатие на Chrome + Нажмите Chrome + Прокрутка Chrome + Прокрутка Chrome + Ввод текста в Chrome + Набрал в Chrome + Нажатие %1$s в Chrome + Нажал %1$s в Chrome + Нажатие клавиши в Chrome + Нажал клавишу в Chrome + Возвращаемся в Chrome + Вернулся в Chrome + Прогресс в Chrome + Продолжил в Chrome + Перезагрузка Chrome + Перезагрузка Chrome + Оценка JavaScript в Chrome + Оценено JavaScript в Chrome + Захват Chrome + Захваченный Chrome + Остановка Chrome + Остановлен Chrome + Использование Chrome + Б/у Хром + Готовая встроенная виртуальная машина Linux + Встроенная настройка виртуальной машины Linux + Параметры времени выполнения по умолчанию + Выберите локальную среду выполнения по умолчанию + Среда выполнения + Оставьте это значение по умолчанию, чтобы следовать настройкам пользователя по умолчанию во время выполнения. + Время выполнения по умолчанию + Используйте «Настройки» > «Параметры времени выполнения по умолчанию». + Встроенная среда Linux Aether является частной для приложений. Используйте его для сценариев оболочки и инструментов разработки. + Открыть терминал + Открыть браузер + Браузер агента + Браузер + Спросить через браузер + Предварительный просмотр появится после запуска браузера. + Браузер готов + Ожидание предварительного просмотра в браузере... + Бег + Неуспешный + Сделанный + Найдите или введите адрес + Дисплей Chrome недоступен. Используйте «Обновить», чтобы повторить попытку. + Скачиваем %1$s + Извлечение %1$s + Установка %1$s + Обработка %1$s + Статус выполнения + Готово + Инициализировать + Сброс данных Alpine + При этом будут удалены данные Alpine, навыки и предустановленное состояние установленной среды, управляемые Aether. Это невозможно отменить. + Сбросить + Использовать в качестве среды выполнения по умолчанию + Пресеты среды + Устанавливайте только те наборы инструментов, которые вам нужны. Каждый пресет можно устанавливать и отслеживать независимо. + Среда Python + Среда Node.js/npm + Инструменты Git и ripgrep + SSH-инструменты + Установлено + Alpine готов. + Alpine еще не установлен. + ABI этого устройства не поддерживается Alpine v1. + Эта сборка пока не включает ресурсы среды выполнения Alpine. + Alpine требует ремонта или сброса. + Alpine не готов. + Корневая файловая система Alpine недоступна. + Установка пакетов + Выберите, где запускаются инструменты, когда в запросе отсутствует параметр среды. Вызовы агента по-прежнему могут явно выбирать Termux или Alpine. + Командный мост на стороне Android и рабочая область режима агента. + Настройте Termux, прежде чем выбирать его по умолчанию. + Встроенная частная виртуальная машина Linux для инструментов разработки. + Инициализируйте Alpine, прежде чем выбрать его по умолчанию. + Хотите настроить Termux + Режим агента? + Виртуальная машина Alpine + Выберите этот вариант для большей совместимости с Linux и более широкого спектра локальных служб. + Termux + Режим агента + Используйте команды Termux и режим агента для управления устройством. + Настраивать + Не сейчас + Настройте встроенную среду Linux Alpine. Он остается в частном хранилище приложений Aether. + Alpine готов и будет использоваться в качестве локальной среды выполнения по умолчанию. + Инициализируйте Alpine, чтобы использовать встроенную виртуальную машину Linux Aether. + Alpine v1 в настоящее время поддерживает только Arm64-v8a. + Эта сборка пока не включает ресурсы среды выполнения Alpine. + Alpine не смог запуститься. Вы можете повторить попытку позже в настройках. + Alpine еще не готов. + Двигатель AI еще не проверялся. + Проверка среды Alpine + Проверка версии Node.js + Установка Node.js внутри Alpine + Подготовка ИИ-движка + Запуск механизма искусственного интеллекта + Проверка механизма искусственного интеллекта + Механизм искусственного интеллекта готов · Узел %1$s + Настройка механизма искусственного интеллекта требует внимания + Шаг %1$d из %2$d + Настройка + Первая установка Node.js загружает пакеты Alpine и может занять несколько минут. Держите Aether открытым. + Подробности + Детали настройки + Выход + Ожидание результатов настройки… + Предварительный просмотр настройки Alpine + Показывает пользовательский интерфейс настройки Alpine и механизма искусственного интеллекта с моделируемым выходом и временем. Никакие данные времени выполнения не изменяются. + Предварительный просмотр настройки воспроизведения + Очистить старый вывод команды + Завершенный вывод с истекшим сроком действия очищается при запуске новых команд bash. Выполняемые команды не затрагиваются. + Сохранить вывод для + По умолчанию: 6 часов. Диапазон: 1–168. + Расширения + %1$d установлен + Пакеты расширений могут изменять агент Pi и пользовательский интерфейс Android или логику приложения Aether. Они запускаются как доверенный код с широким доступом к агенту, узлу приложения, средам выполнения и рабочей области. + Ошибки расширения скрипта + %1$s · %2$s\n%3$s + Обнаружить + Установлено + Поиск пакетов + Загрузка пакетов pi.dev + Загрузка установленных расширений + Подходящие пакеты не найдены. + Расширения не установлены + Установите его с pi.dev или импортируйте JavaScript, TypeScript или zip-пакет. + Импортировать расширение + Этот пакет может быть несовместим с Aether. + Загрузка сведений о пакете + Детали пакета недоступны. + Установить пакет + Источник + Версия + Опубликовано + Загрузки + Автор + Лицензия + Ресурсы + Размер + Зависимости + Расширения %1$d + %1$d Aether Расширения пользовательского интерфейса + %1$d собственные точки входа + %1$d навыки + %1$d подсказок + %1$d темы + npm + Репозиторий + Pi манифест + README + Похоже, что этот пакет использует интерактивные функции TUI Pi, которых нет в Aether. Ресурсы, не относящиеся к пользовательскому интерфейсу, могут по-прежнему работать. + Этот пакет включает тему терминала Pi. Aether не применяет темы TUI Pi, поэтому пакет может работать только частично. + Этот пакет предоставляет только шаблоны подсказок Pi, которые Aether в настоящее время не отображаются. + Похоже, что для этого пакета требуется настольная операционная система или архитектура ЦП, которая может быть недоступна в среде выполнения Android Alpine Aether. + Возможная несовместимость + %1$s может работать не так, как ожидалось. \n\n%2$s\n\nУстановка может привести к неожиданному поведению агента. + Все равно установить + Установить + Обновить + Расширение включено + Расширение отключено + Повторить попытку + Импортированное расширение %1$s + Изменения расширения применены + Не удалось выполнить операцию расширения: %1$s. + Безопасный режим встроенного мода + Предыдущий запуск завершился до того, как Aether стал стабильным, поэтому моды Kotlin/DEX были пропущены, чтобы предотвратить аварийный цикл. Расширения скриптов остаются доступными. + Последняя точка входа для загрузки: %1$s. + Родное ядро ​​мода + Загружены точки входа %1$d · Обнаружены пакеты %2$d. Собственный код может заменять службы и компоненты Compose и требует перезапуска приложения после изменений пакета. + %1$s: %2$s + Перезапустите Aether, чтобы применить настройки Native Mod. + Включить собственные моды при следующем запуске + В следующий раз запустите в безопасном режиме Native Mod. + Aether + Взлетайте вместе с местным ИИ. + Alpine + Подготовка локальной среды выполнения + Alpine готов. + Шаг %1$d/5 - %2$d%% + Подробности + Детали установки + Ожидание результатов установки... + Продолжать + Повторить попытку + Новый чат + Сообщение Aether + Провайдеры + Навыки + Расширения + Alpine Время выполнения + О + Локальная среда выполнения недоступна + Начать + Пропускать + Закрывать + Назад + Меню + Отправлять + Автоматический + Анализ изображения + Код + Помогите мне написать + Обобщить файл + Добавить вложение или инструмент + Чаты + Поиск чатов + Сегодня + Общий + Язык, внешний вид и поведение приложения + Персонализация + Пользовательские инструкции и предпочтения + Надежность + Повторное подключение и история поведения + Настройка моделей и аутентификации + Установленные навыки агента + Расширения скриптов Pi и Aether + Готов к работе с терминалом, узлом и локальными инструментами. + Терминал + оболочка Alpine + Использование, токены и скорость ответа + Начало тура + Воспроизвести настройку Aether + Разработчик + Диагностика и обслуживание во время работы + Aether для iOS + iOS предоставляет только короткое фоновое окно для уже запущенного поворота. Aether сохраняет частичный вывод и позволяет повторить просроченный ход. + Проверка магазина приложений + Версия %1$s доступна в App Store. + Aether обновлен в App Store. + Этот пакет еще не опубликован в App Store. + Открыть магазин приложений + Не удалось проверить наличие обновлений + Всего токенов %1$s + Записано ходов: %1$d + Данные приложения импортированы. + Данные приложения экспортированы. + Не удалось импортировать настройки + Не удалось открыть общий лист + Данные приложения Aether + aether-data.json + Чжоу-Шилинь + Средний оборот + Кэшированный ввод + %1$s · %2$d%% + Давайте подготовим рабочую область Alpine, используемую локальными инструментами Aether. + Alpine, Node 22 и локальная рабочая область готовы. + Остановить + Файлы Alpine + Открыть + Новый файл + Новая папка + Переименовать + Удалить + Имя + Эта папка пуста + Сетка + Список + Подтвердить + Родительская папка + Поиск + Параметры отображения + Сортировать по: %1$s + Поиск в этой папке + Создать + Импортировать файлы + Импортировать папку + Подходящих файлов нет + Скачать + Удалить %1$s? + Папка и всё её содержимое будут удалены без возможности восстановления. + Этот элемент будет удалён без возможности восстановления. + Не удалось открыть изображение + Не удалось выполнить операцию с файлом. + Перенос строк + Сохранить + имени + дате + размеру + Не удалось загрузить содержимое папки. + Не удалось импортировать выбранный объект. + Не удалось скачать файл. + Не удалось открыть файл. + Не удалось сохранить файл. + Не удалось открыть выбранный объект. + Не удалось открыть выбранное место сохранения. + У выбранного объекта недопустимое имя. + Объект с именем %1$s уже существует. + Не удалось прочитать %1$s. + \ No newline at end of file diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 25c6553e..524e765f 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -194,9 +194,11 @@ English 简体中文 فارسی + Русский English interface Simplified Chinese interface Persian interface + Russian interface Theme Choose the app appearance. System @@ -1051,4 +1053,34 @@ Grid view List view Confirm + Parent folder + Search + View options + Sort by %1$s + Search this folder + Create + Import files + Import folder + No matching files + Download + Delete %1$s? + The folder and all of its contents will be permanently deleted. + This item will be permanently deleted. + Unable to decode image + File operation failed + Toggle line wrapping + Save + name + date + size + Unable to load directory. + Unable to import the selected item. + Unable to download the file. + Unable to open file. + Unable to save file. + Unable to open the selected item. + Unable to open the selected download location. + The selected item has an invalid name. + An item named %1$s already exists. + Unable to read %1$s. diff --git a/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt b/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt index db215708..a3fe1556 100644 --- a/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt +++ b/shared/src/commonMain/kotlin/com/zhousl/aether/data/AppSettings.kt @@ -54,9 +54,14 @@ enum class AppLanguage( Persian( storageValue = "fa", languageTag = "fa", + ), + Russian( + storageValue = "ru", + languageTag = "ru", ); companion object { + /** Restores a supported interface language, including Russian, or uses the platform default. */ fun fromStorage( value: String?, defaultValue: AppLanguage = defaultAppLanguage(), @@ -233,9 +238,11 @@ fun defaultAppLanguage(): AppLanguage { return appLanguageForTag(platformLanguageTag()) } +/** Maps language tags, including Russian regional tags, to the corresponding interface language. */ fun appLanguageForTag(languageTag: String): AppLanguage = when { languageTag.startsWith("zh", ignoreCase = true) -> AppLanguage.SimplifiedChinese languageTag.startsWith("fa", ignoreCase = true) -> AppLanguage.Persian + languageTag.startsWith("ru", ignoreCase = true) -> AppLanguage.Russian else -> AppLanguage.English } diff --git a/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt b/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt index 3f54984c..3afcc74a 100644 --- a/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt +++ b/shared/src/commonTest/kotlin/com/zhousl/aether/data/AppSettingsSerializationTest.kt @@ -4,6 +4,15 @@ import kotlin.test.Test import kotlin.test.assertEquals class AppSettingsSerializationTest { + /** Verifies Russian language persistence and detection of Russian regional and uppercase tags. */ + @Test + fun russianLanguageRoundTripsAndMatchesRegionalTags() { + val settings = AppSettings(language = AppLanguage.Russian) + assertEquals(AppLanguage.Russian, parseAppSettings(serializeAppSettings(settings)).language) + assertEquals(AppLanguage.Russian, appLanguageForTag("ru-RU")) + assertEquals(AppLanguage.Russian, appLanguageForTag("RU")) + assertEquals(AppLanguage.Russian, AppLanguage.fromStorage("ru")) + } @Test fun completeSettingsRoundTrip() { val settings = AppSettings(