From 02618dbcb901a8d9ee3307c94988007ecce960fd Mon Sep 17 00:00:00 2001 From: Seb3thehacker Date: Mon, 26 Oct 2026 12:23:20 -0700 Subject: [PATCH] phone: the app's screens take their text from strings.xml, ready for translation, with per-app language support and pseudo-locales in debug builds --- gearslip/build.gradle.kts | 15 + .../gearslip/stats/WorkedPrompt.kt | 11 +- .../seb3thehacker/gearslip/ui/AboutLinks.kt | 39 +- .../gearslip/ui/AudioChoiceDialog.kt | 26 +- .../gearslip/ui/CarScreenSettings.kt | 53 +-- .../gearslip/ui/CertSetupScreen.kt | 43 +- .../seb3thehacker/gearslip/ui/CertSummary.kt | 15 +- .../gearslip/ui/CopyableCommand.kt | 6 +- .../seb3thehacker/gearslip/ui/DonatePrompt.kt | 15 +- .../seb3thehacker/gearslip/ui/HelpScreen.kt | 68 ++- .../gearslip/ui/HomeCarSettings.kt | 148 +++--- .../seb3thehacker/gearslip/ui/HomeScreen.kt | 23 +- .../gearslip/ui/ManageSpaceActivity.kt | 43 +- .../gearslip/ui/PhoneTypingBar.kt | 8 +- .../gearslip/ui/RecommendedApps.kt | 40 +- .../gearslip/ui/SettingsScreen.kt | 80 ++-- .../gearslip/ui/SetupGuideScreen.kt | 129 +++--- .../seb3thehacker/gearslip/ui/UpdateViews.kt | 51 ++- .../gearslip/ui/UsageStatsChoices.kt | 59 +-- .../gearslip/ui/VoiceModelViews.kt | 31 +- .../gearslip/ui/WhatsNewScreen.kt | 8 +- .../gearslip/ui/WirelessButton.kt | 18 +- gearslip/src/main/res/resources.properties | 1 + gearslip/src/main/res/values/strings.xml | 431 ++++++++++++++++++ 24 files changed, 910 insertions(+), 451 deletions(-) create mode 100644 gearslip/src/main/res/resources.properties diff --git a/gearslip/build.gradle.kts b/gearslip/build.gradle.kts index 9aaa285..fd51f01 100644 --- a/gearslip/build.gradle.kts +++ b/gearslip/build.gradle.kts @@ -74,6 +74,9 @@ android { applicationIdSuffix = ".dev" signingConfigs.findByName("release")?.let { signingConfig = it } manifestPlaceholders["appLabel"] = "Gearslip Debug" + // English (XA) and Arabic (XB) under the phone's languages: stretched, accented and + // right-to-left text, which shows what's still hardcoded and what doesn't fit. + isPseudoLocalesEnabled = true // Shrunk with R8 like a release, so a debug install behaves like what ships (a keep // rule missing for release shows up here first). -PnoR8 skips it for a quicker build. if (!project.hasProperty("noR8")) { @@ -88,6 +91,18 @@ android { buildConfig = true } + // Lists each language that has a translation in the app's locale config, so Android 13+ + // offers it under the system's per-app languages. English is the unqualified default + // (res/resources.properties). + androidResources { + generateLocaleConfig = true + } + + lint { + // Weblate fills languages in a string at a time; the rest fall back to English. + disable += "MissingTranslation" + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt index ac4f487..7903e6f 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/stats/WorkedPrompt.kt @@ -26,7 +26,7 @@ object WorkedPrompt { private var loaded = false private val flow = MutableStateFlow(null) - /** The car's name while the ask is waiting for an answer, otherwise null. */ + /** The car's name while the ask is waiting for an answer (empty when the car didn't say), otherwise null. */ fun pending(context: Context): StateFlow { if (!loaded) { loaded = true @@ -99,17 +99,14 @@ object WorkedPrompt { private fun saved(context: Context): JSONObject? = prefs(context).getString(KEY_CAR, null)?.let { runCatching { JSONObject(it) }.getOrNull() } - /** "your 2019 Clio", "your Renault Media Nav", or "your car" when it didn't say. */ + /** "2019 Clio", "Renault Media Nav", or empty when the car didn't say. */ private fun carName(car: JSONObject): String { val model = car.optString("car").trim() val year = car.optString("year").trim().takeIf { it.isNotEmpty() && it != "0" } val unit = "${car.optString("make")} ${car.optString("model")}".trim() return when { - model.isNotEmpty() -> "your " + listOfNotNull(year, model).joinToString(" ") - unit.isNotEmpty() -> "your $unit" - else -> "your car" + model.isNotEmpty() -> listOfNotNull(year, model).joinToString(" ") + else -> unit } } - - const val TITLE_START = "Gearslip worked with " } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AboutLinks.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AboutLinks.kt index 8c0f373..2baa8a4 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AboutLinks.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AboutLinks.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -59,14 +61,14 @@ internal object GearslipLinks { runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("monero:$MONERO")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) }.onFailure { - Toast.makeText(context, "No Monero wallet app found. Copy the address instead.", Toast.LENGTH_LONG).show() + Toast.makeText(context, context.getString(R.string.about_no_wallet), Toast.LENGTH_LONG).show() } } fun copyMonero(context: Context) { context.getSystemService(ClipboardManager::class.java) ?.setPrimaryClip(ClipData.newPlainText("Monero address", MONERO)) - Toast.makeText(context, "Monero address copied", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.about_monero_copied), Toast.LENGTH_SHORT).show() } } @@ -84,7 +86,7 @@ internal fun DonateRows() { SettingsRow( "Liberapay", Modifier.clickable { GearslipLinks.open(context, GearslipLinks.LIBERAPAY) }, - subtitle = "Give weekly or monthly", + subtitle = stringResource(R.string.about_give_regularly), icon = Icons.Filled.Favorite, trailing = arrow, ) @@ -92,16 +94,16 @@ internal fun DonateRows() { SettingsRow( "Buy Me a Coffee", Modifier.clickable { GearslipLinks.open(context, GearslipLinks.BUY_ME_A_COFFEE) }, - subtitle = "Give once or monthly, by card. Buy Me a Coffee keeps 5%", + subtitle = stringResource(R.string.about_bmac_sub), icon = Icons.Filled.Favorite, trailing = arrow, ) if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) { SettingsDivider() SettingsRow( - "Give once", + stringResource(R.string.about_give_once), Modifier.clickable { GearslipLinks.open(context, GearslipLinks.PAYMENT_LINK) }, - subtitle = "Any amount, by card", + subtitle = stringResource(R.string.about_any_amount), icon = Icons.Filled.Favorite, trailing = arrow, ) @@ -112,7 +114,7 @@ internal fun DonateRows() { SettingsRow( "Monero", Modifier.clickable { moneroOpen = true }, - subtitle = "Open your wallet or copy the address", + subtitle = stringResource(R.string.about_monero_sub), icon = Icons.Filled.Favorite, trailing = arrow, ) @@ -127,10 +129,10 @@ internal fun MoneroDialog(onDismiss: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, icon = { Icon(Icons.Filled.Favorite, contentDescription = null) }, - title = { Text("Support with Monero") }, + title = { Text(stringResource(R.string.about_monero_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("Send any amount to this address from your Monero wallet.") + Text(stringResource(R.string.about_monero_body)) SelectionContainer { Text( GearslipLinks.MONERO, @@ -143,13 +145,13 @@ internal fun MoneroDialog(onDismiss: () -> Unit) { confirmButton = { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { GearslipLinks.openMoneroWallet(context) }, Modifier.fillMaxWidth()) { - Text("Open in wallet") + Text(stringResource(R.string.about_open_wallet)) } OutlinedButton(onClick = { GearslipLinks.copyMonero(context) }, Modifier.fillMaxWidth()) { - Text("Copy address") + Text(stringResource(R.string.about_copy_address)) } TextButton(onClick = onDismiss, Modifier.fillMaxWidth()) { - Text("Close") + Text(stringResource(R.string.close)) } } }, @@ -168,25 +170,24 @@ internal fun AboutLinkRows() { ) } SettingsRow( - "Join the chat on Matrix", + stringResource(R.string.about_matrix), Modifier.clickable { GearslipLinks.open(context, GearslipLinks.MATRIX) }, - subtitle = "Talk with other drivers about cars, apps, and Gearslip", + subtitle = stringResource(R.string.about_matrix_sub), icon = Icons.AutoMirrored.Filled.Send, trailing = arrow, ) SettingsDivider() SettingsRow( - "Source code", + stringResource(R.string.about_source), Modifier.clickable { GearslipLinks.open(context, GearslipLinks.SOURCE) }, - subtitle = "Read or change Gearslip's code under the AGPL", + subtitle = stringResource(R.string.about_source_sub), icon = Icons.Filled.Build, trailing = arrow, ) SettingsDivider() SettingsRow( - "Voice credits", - subtitle = "Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo " + - "(CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0)", + stringResource(R.string.about_voice_credits), + subtitle = stringResource(R.string.about_voice_credits_sub), icon = Icons.Filled.Favorite, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt index 0314586..745814b 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/AudioChoiceDialog.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog @@ -24,27 +26,25 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) { var showCommand by remember { mutableStateOf(false) } AlertDialog( onDismissRequest = onKeepAsking, - title = { Text("Play car audio over Bluetooth?") }, + title = { Text(stringResource(R.string.audio_bt_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "Android treats music sent over USB like sharing your screen, so it asks every " + - "drive. Over Bluetooth it never asks. You can change this later in Settings.", + stringResource(R.string.audio_bt_body), ) if (showCommand) { Text( - "Or keep USB and stop the question for good: plug the phone into a " + - "computer with USB debugging on, and run this once.", + stringResource(R.string.audio_keep_usb), style = MaterialTheme.typography.bodyMedium, ) CopyableCommand("adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow") } else { - TextButton(onClick = { showCommand = true }) { Text("Never ask, using a computer") } + TextButton(onClick = { showCommand = true }) { Text(stringResource(R.string.audio_never_ask)) } } } }, - confirmButton = { TextButton(onClick = onUseBluetooth) { Text("Use Bluetooth") } }, - dismissButton = { TextButton(onClick = onKeepAsking) { Text("Keep asking") } }, + confirmButton = { TextButton(onClick = onUseBluetooth) { Text(stringResource(R.string.audio_use_bt)) } }, + dismissButton = { TextButton(onClick = onKeepAsking) { Text(stringResource(R.string.audio_keep_asking)) } }, ) } @@ -57,15 +57,13 @@ fun AudioChoiceDialog(onUseBluetooth: () -> Unit, onKeepAsking: () -> Unit) { fun MicBlockedDialog(onOpenSettings: () -> Unit, onDismiss: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Car audio needs the microphone permission") }, + title = { Text(stringResource(R.string.audio_mic_title)) }, text = { Text( - "Android lets an app send other apps' sound to the car only if it has the " + - "microphone permission. It's turned off for Gearslip, so music plays on the " + - "phone. Turn it on under Permissions, then Microphone.", + stringResource(R.string.audio_mic_body), ) }, - confirmButton = { TextButton(onClick = onOpenSettings) { Text("Open app settings") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Not now") } }, + confirmButton = { TextButton(onClick = onOpenSettings) { Text(stringResource(R.string.audio_open_settings)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.worked_not_now)) } }, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarScreenSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarScreenSettings.kt index 7f48c80..1af3df1 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarScreenSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CarScreenSettings.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -41,19 +43,18 @@ internal fun CarScreenSection() { if (editingCar) { SizeDialog( - title = "Car screen size", - note = "Use this if the car's picture looks stretched. Enter the screen's real size, " + - "such as 1280x480. It takes effect the next time you connect.", + title = stringResource(R.string.screen_car_size), + note = stringResource(R.string.screen_car_size_note), initial = size, - clearLabel = "Use the car's", + clearLabel = stringResource(R.string.screen_use_cars), onDismiss = { editingCar = false }, onSave = { CarSettings.setScreenSize(it); editingCar = false }, ) } if (editingPreview) { SizeDialog( - title = "Preview size", - note = "The size the car preview draws at, such as 1920x720.", + title = stringResource(R.string.screen_preview_size), + note = stringResource(R.string.screen_preview_note), initial = previewCustom, clearLabel = null, onDismiss = { editingPreview = false }, @@ -65,62 +66,62 @@ internal fun CarScreenSection() { } SettingsSection( - "Car screen", - footer = "Gearslip uses the size the car reports unless you set one.", + stringResource(R.string.screen_car_screen), + footer = stringResource(R.string.screen_footer), ) { SettingsRow( - "Screen size", + stringResource(R.string.screen_size), Modifier.clickable { editingCar = true }, - subtitle = size?.let { "$it, set by you" } ?: "From the car", + subtitle = size?.let { stringResource(R.string.screen_set_by_you, it.toString()) } ?: stringResource(R.string.screen_from_car), ) if (size != null) { SettingsDivider() SettingsRow( - "Crop the edges", + stringResource(R.string.screen_crop), Modifier.selectable(fit == ScreenFit.CROP, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.CROP) }, - subtitle = "For a car that cuts off the top and bottom, or the sides", + subtitle = stringResource(R.string.screen_crop_sub), trailing = { RadioButton(selected = fit == ScreenFit.CROP, onClick = null) }, ) SettingsDivider() SettingsRow( - "Squash to fit", + stringResource(R.string.screen_squash), Modifier.selectable(fit == ScreenFit.SQUASH, role = Role.RadioButton) { CarSettings.setScreenFit(ScreenFit.SQUASH) }, - subtitle = "For a car that squeezes the whole picture, so circles look like ovals", + subtitle = stringResource(R.string.screen_squash_sub), trailing = { RadioButton(selected = fit == ScreenFit.SQUASH, onClick = null) }, ) } } - SettingsSection("Car preview size") { + SettingsSection(stringResource(R.string.screen_preview_title)) { SettingsRow( - "Last car", + stringResource(R.string.screen_last_car), Modifier.selectable(preview == PreviewSize.LAST_CAR, role = Role.RadioButton) { CarSettings.setPreviewSize(PreviewSize.LAST_CAR) }, - subtitle = "The size of the last car you connected to", + subtitle = stringResource(R.string.screen_last_car_sub), trailing = { RadioButton(selected = preview == PreviewSize.LAST_CAR, onClick = null) }, ) SettingsDivider() SettingsRow( - "This screen", + stringResource(R.string.screen_this_screen), Modifier.selectable(preview == PreviewSize.FILL, role = Role.RadioButton) { CarSettings.setPreviewSize(PreviewSize.FILL) }, - subtitle = "Fills this device's screen, for a head unit running Gearslip itself", + subtitle = stringResource(R.string.screen_this_screen_sub), trailing = { RadioButton(selected = preview == PreviewSize.FILL, onClick = null) }, ) SettingsDivider() SettingsRow( - "Custom", + stringResource(R.string.screen_custom), Modifier.selectable(preview == PreviewSize.CUSTOM, role = Role.RadioButton) { if (previewCustom == null) editingPreview = true else CarSettings.setPreviewSize(PreviewSize.CUSTOM) }, - subtitle = previewCustom?.toString() ?: "Enter a size", + subtitle = previewCustom?.toString() ?: stringResource(R.string.screen_enter_size), trailing = { RadioButton(selected = preview == PreviewSize.CUSTOM, onClick = null) }, ) if (preview == PreviewSize.CUSTOM) { SettingsDivider() - SettingsRow("Change the custom size", Modifier.clickable { editingPreview = true }) + SettingsRow(stringResource(R.string.screen_change_custom), Modifier.clickable { editingPreview = true }) } } } @@ -145,14 +146,14 @@ private fun SizeDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text(note) - OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text("Width") }, singleLine = true, keyboardOptions = number) - OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text("Height") }, singleLine = true, keyboardOptions = number) + OutlinedTextField(width, { width = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_width)) }, singleLine = true, keyboardOptions = number) + OutlinedTextField(height, { height = it.filter(Char::isDigit) }, label = { Text(stringResource(R.string.screen_height)) }, singleLine = true, keyboardOptions = number) } }, - confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text("Save") } }, + confirmButton = { TextButton(onClick = { onSave(parsed) }, enabled = parsed != null) { Text(stringResource(R.string.save)) } }, dismissButton = { if (clearLabel != null) TextButton(onClick = { onSave(null) }) { Text(clearLabel) } - else TextButton(onClick = onDismiss) { Text("Cancel") } + else TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt index 37a2ad2..f9c4302 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSetupScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -80,7 +82,7 @@ fun CertSetupScreen(onDone: () -> Unit) { verticalArrangement = Arrangement.Center, ) { Text( - "Certificate Setup", + stringResource(R.string.certsetup_title), style = MaterialTheme.typography.headlineLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, @@ -88,9 +90,9 @@ fun CertSetupScreen(onDone: () -> Unit) { Spacer(Modifier.height(8.dp)) Text( if (installed) { - "Cars accept only Google-issued certificates, and you have one loaded." + stringResource(R.string.certsetup_loaded) } else { - "Cars accept only Google-issued certificates. Gearslip doesn't include one, so choose where yours comes from." + stringResource(R.string.certsetup_choose) }, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, @@ -100,14 +102,14 @@ fun CertSetupScreen(onDone: () -> Unit) { if (installed) { Text( - if (manualProgress) "Certificate imported." else "Certificate installed.", + if (manualProgress) stringResource(R.string.cert_imported) else stringResource(R.string.certsetup_installed), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, ) Spacer(Modifier.height(20.dp)) Button(onClick = onDone, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text("Continue") + Text(stringResource(R.string.certsetup_continue)) } Spacer(Modifier.height(40.dp)) return@Column @@ -130,11 +132,10 @@ fun CertSetupScreen(onDone: () -> Unit) { }, modifier = Modifier.fillMaxWidth().height(56.dp), ) { - Text("Download from aasdk") + Text(stringResource(R.string.cert_download)) } Text( - "The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. " + - "Most cars accept it; some from around 2020 on don't.", + stringResource(R.string.certsetup_aasdk_sub), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -146,7 +147,7 @@ fun CertSetupScreen(onDone: () -> Unit) { Box(Modifier.fillMaxWidth().height(56.dp), contentAlignment = Alignment.Center) { DownloadingRow { CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) - Text("Downloading…", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.cert_downloading), style = MaterialTheme.typography.bodyLarge) } } } @@ -157,7 +158,7 @@ fun CertSetupScreen(onDone: () -> Unit) { Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.medium) { Column(Modifier.fillMaxWidth().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - "Download failed", + stringResource(R.string.certsetup_failed), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onErrorContainer, @@ -168,7 +169,7 @@ fun CertSetupScreen(onDone: () -> Unit) { color = MaterialTheme.colorScheme.onErrorContainer, ) Button(onClick = { downloadState = DownloadState.Idle }, modifier = Modifier.fillMaxWidth()) { - Text("Retry") + Text(stringResource(R.string.certsetup_retry)) } } } @@ -180,9 +181,9 @@ fun CertSetupScreen(onDone: () -> Unit) { OutlinedButton( onClick = { importLauncher.launch(arrayOf("*/*")) }, modifier = Modifier.fillMaxWidth().height(52.dp), - ) { Text("Import my own file") } + ) { Text(stringResource(R.string.cert_import)) } Text( - "A .p12 file with its private key, from wherever you choose.", + stringResource(R.string.certsetup_import_sub), style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -197,7 +198,7 @@ fun CertSetupScreen(onDone: () -> Unit) { }, modifier = Modifier.fillMaxWidth().height(52.dp), ) { - Text("Skip car connection setup") + Text(stringResource(R.string.certsetup_skip)) } Spacer(Modifier.height(40.dp)) @@ -216,7 +217,7 @@ fun CertSetupScreen(onDone: () -> Unit) { pendingUri = null manualProgress = true }.onFailure { - reportError("Wrong password or not a valid PKCS#12 file with a private key.") + reportError(context.getString(R.string.cert_import_failed)) } } }, @@ -224,7 +225,7 @@ fun CertSetupScreen(onDone: () -> Unit) { } } -/** A tight horizontal row for the inline "Downloading…" spinner state. */ +/** A tight horizontal row for the inline stringResource(R.string.cert_downloading) spinner state. */ @Composable private fun DownloadingRow(content: @Composable RowScope.() -> Unit) { Row( @@ -248,20 +249,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) - AlertDialog( onDismissRequest = onDismiss, - title = { Text("Certificate password") }, + title = { Text(stringResource(R.string.cert_password_title)) }, text = { OutlinedTextField( value = password, onValueChange = { password = it; error = null }, singleLine = true, - label = { Text("Password") }, + label = { Text(stringResource(R.string.cert_password)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), isError = error != null, - supportingText = { Text(error ?: "Leave empty if the file has none.") }, + supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) }, ) }, - confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt index 48716ca..c1245cb 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CertSummary.kt @@ -1,5 +1,6 @@ package app.seb3thehacker.gearslip.ui +import app.seb3thehacker.gearslip.R import android.content.Context import app.seb3thehacker.gearslip.CertProvider import java.text.DateFormat @@ -28,20 +29,20 @@ data class CertSummary( val identity = CertProvider.loadSupplied(context) ?: return CertSummary( CertProvider.Kind.SELF_SIGNED, - "None loaded (self-signed; cars reject it)", + context.getString(R.string.cert_none), null, null, null, ) val cert = identity.certificate val name = friendlyName(cert.subjectX500Principal.name) - val prefix = when (identity.kind) { - CertProvider.Kind.IMPORTED -> "Imported" - CertProvider.Kind.DOWNLOADED -> "Downloaded" - CertProvider.Kind.ADB_STAGED -> "Staged over adb" - CertProvider.Kind.SELF_SIGNED -> "Self-signed" + val headline = when (identity.kind) { + CertProvider.Kind.IMPORTED -> R.string.cert_kind_imported + CertProvider.Kind.DOWNLOADED -> R.string.cert_kind_downloaded + CertProvider.Kind.ADB_STAGED -> R.string.cert_kind_adb + CertProvider.Kind.SELF_SIGNED -> R.string.cert_kind_self } return CertSummary( identity.kind, - "$prefix: $name", + context.getString(headline, name), cert.subjectX500Principal.name, cert.issuerX500Principal.name, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(cert.notAfter.time)), diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CopyableCommand.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CopyableCommand.kt index 9667541..a907c28 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CopyableCommand.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/CopyableCommand.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -29,7 +31,7 @@ fun CopyableCommand(command: String) { shape = MaterialTheme.shapes.small, onClick = { clipboard.setText(AnnotatedString(command)) - Toast.makeText(context, "Command copied", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.command_copied), Toast.LENGTH_SHORT).show() }, modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) { @@ -41,7 +43,7 @@ fun CopyableCommand(command: String) { color = MaterialTheme.colorScheme.onSurface, ) Text( - "Tap to copy", + stringResource(R.string.command_tap_copy), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, ) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/DonatePrompt.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/DonatePrompt.kt index c4c8da3..c7c3c9d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/DonatePrompt.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/DonatePrompt.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.content.Context import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -100,31 +102,30 @@ internal fun DonateDialog(live: Boolean) { AlertDialog( onDismissRequest = { DonatePrompt.answer(context, donated = false) }, icon = { Icon(Icons.Filled.Favorite, contentDescription = null) }, - title = { Text("Glad that drive worked") }, + title = { Text(stringResource(R.string.donate_title)) }, text = { Text( - "Gearslip is free, with no ads and no tracking. If it gets you on the road, your " + - "support keeps it going.", + stringResource(R.string.donate_body), ) }, confirmButton = { Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { give(GearslipLinks.LIBERAPAY) }, Modifier.fillMaxWidth()) { - Text("Support on Liberapay") + Text(stringResource(R.string.donate_liberapay)) } OutlinedButton(onClick = { give(GearslipLinks.BUY_ME_A_COFFEE) }, Modifier.fillMaxWidth()) { - Text("Buy me a coffee") + Text(stringResource(R.string.donate_bmac)) } if (GearslipLinks.PAYMENT_LINK.isNotEmpty()) { OutlinedButton(onClick = { give(GearslipLinks.PAYMENT_LINK) }, Modifier.fillMaxWidth()) { - Text("Give once by card") + Text(stringResource(R.string.donate_card)) } } OutlinedButton(onClick = { monero = true }, Modifier.fillMaxWidth()) { Text("Monero") } TextButton(onClick = { DonatePrompt.answer(context, donated = false) }, Modifier.fillMaxWidth()) { - Text("Not now") + Text(stringResource(R.string.worked_not_now)) } } }, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt index f2152b9..c56414d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HelpScreen.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.ui +import androidx.annotation.StringRes +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.car.CarSettings import androidx.compose.foundation.layout.Arrangement @@ -40,54 +43,48 @@ import androidx.compose.ui.unit.dp * that can, in one tap, in which case it is shown as a button instead. */ private class Step( - val action: String, - val detail: String? = null, + @param:StringRes val action: Int, + @param:StringRes val detail: Int? = null, val command: String? = null, - val buttonLabel: String? = null, + @param:StringRes val buttonLabel: Int? = null, val onClick: (() -> Unit)? = null, ) private val FIRST_DRIVE = listOf( - Step("Turn off Android Auto.", "Disable or uninstall the Android Auto app, so the car talks to Gearslip instead."), - Step("Load a certificate.", "In Settings, download one or import your own."), + Step(R.string.help_aa_off, R.string.help_aa_off_detail), + Step(R.string.help_cert, R.string.help_cert_detail), Step( - "Allow the microphone.", - "Android won't let an app pass music to the car without it. Gearslip listens only when " + - "you tap the mic or press the talk button on your wheel.", + R.string.help_mic, + R.string.help_mic_detail, ), ) private fun inTheCar(onRequestCallScreening: () -> Unit) = listOf( Step( - "Plug in, or connect wirelessly.", - "Gearslip opens by itself when the car connects. For wireless, pair the phone with the " + - "car first, then tap Connect wirelessly on Home.", + R.string.help_plug_in, + R.string.help_plug_in_detail, ), Step( - "Pair over Bluetooth for calls.", - "Calls play through the car once your phone and the car are paired. Gearslip offers to " + - "pair the first time you plug in.", + R.string.help_pair, + R.string.help_pair_detail, ), Step( - "Answer calls from the car screen.", - "Set Gearslip as your caller ID and spam app. Without it, calls still ring, but only " + - "the phone can answer them.", - buttonLabel = "Set up", + R.string.help_answer, + R.string.help_answer_detail, + buttonLabel = R.string.guide_set_up, onClick = onRequestCallScreening, ), ) private val USB_AUDIO = listOf( Step( - "Stop the audio prompt each drive.", - "Connect the phone to a computer with USB debugging on, and run this once. Skip it and " + - "you tap Start each time you set off.", + R.string.help_prompt, + R.string.help_prompt_detail, command = "adb shell appops set ${BuildConfig.APPLICATION_ID} PROJECT_MEDIA allow", ), Step( - "Keep notifications readable.", - "Android hides what notifications say while it shares audio. Run this too, and Gearslip " + - "lifts that only while music goes to the car.", + R.string.help_readable, + R.string.help_readable_detail, command = "adb shell pm grant ${BuildConfig.APPLICATION_ID} android.permission.WRITE_SECURE_SETTINGS", ), ) @@ -104,10 +101,10 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text("How to connect") }, + title = { Text(stringResource(R.string.help_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -122,21 +119,20 @@ fun HelpScreen(onBack: () -> Unit, onRequestCallScreening: () -> Unit) { verticalArrangement = Arrangement.spacedBy(24.dp), ) { Spacer(Modifier.height(0.dp)) - SettingsSection("Which cars work") { + SettingsSection(stringResource(R.string.help_which_cars)) { Text( - "Most cars work. Starting around 2020, some cars added newer security " + - "standards that reject Gearslip's certificate, and Gearslip can't run in those cars.", + stringResource(R.string.help_which_cars_body), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp), ) FilledTonalButton( onClick = { GearslipLinks.open(context, GearslipLinks.CARS) }, modifier = Modifier.padding(16.dp), - ) { Text("See the full list") } + ) { Text(stringResource(R.string.help_full_list)) } } - Steps("Before your first drive", FIRST_DRIVE) - Steps("In the car", car) - if (usb) Steps("USB audio", USB_AUDIO) + Steps(stringResource(R.string.help_first_drive), FIRST_DRIVE) + Steps(stringResource(R.string.help_in_car), car) + if (usb) Steps(stringResource(R.string.help_usb_audio), USB_AUDIO) Spacer(Modifier.height(16.dp)) } } @@ -160,14 +156,14 @@ private fun Steps(title: String, steps: List) { modifier = Modifier.width(16.dp), ) Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(step.action, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) + Text(stringResource(step.action), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold) step.detail?.let { - Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } step.command?.let { CopyableCommand(it) } if (step.onClick != null) { FilledTonalButton(onClick = step.onClick, modifier = Modifier.padding(top = 4.dp)) { - Text(step.buttonLabel ?: "Open") + Text(stringResource(step.buttonLabel ?: R.string.guide_open)) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt index 0330366..f25922c 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeCarSettings.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.ui +import androidx.annotation.StringRes +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.car.theme.DarkStyle import androidx.compose.foundation.clickable import androidx.compose.foundation.selection.selectable @@ -39,30 +42,37 @@ import app.seb3thehacker.gearslip.car.Units import app.seb3thehacker.gearslip.car.theme.GsThemes /** The car's setting groups, each opening its own screen from the phone's home screen. */ -enum class CarGroup(val title: String) { - SCREEN("Screen"), - NAV_BAR("Nav bar and player"), - SOUND("Sound and voice"), - WARNINGS("Warnings"), - MORE("More"), +enum class CarGroup(@param:StringRes val title: Int) { + SCREEN(R.string.car_group_screen), + NAV_BAR(R.string.car_group_nav_bar), + SOUND(R.string.car_group_sound), + WARNINGS(R.string.car_group_warnings), + MORE(R.string.car_group_more), } -private val sizeChoices = listOf(1f to "Normal", 1.25f to "Large", 1.5f to "Extra large") -private val themeChoices = listOf(AppTheme.PHONE to "Follow phone", AppTheme.LIGHT to "Light", AppTheme.DARK to "Dark") -private val darkChoices = listOf( - DarkStyle.BLACK to "Black", - DarkStyle.BLACK_COLOUR to "Black and colour", - DarkStyle.DEEP_COLOUR to "Deep colour", - DarkStyle.GREY to "Grey", +@Composable +private fun sizeChoices() = listOf(1f to stringResource(R.string.choice_size_normal), 1.25f to stringResource(R.string.choice_size_large), 1.5f to stringResource(R.string.choice_size_xlarge)) +@Composable +private fun themeChoices() = listOf(AppTheme.PHONE to stringResource(R.string.choice_theme_phone), AppTheme.LIGHT to stringResource(R.string.choice_theme_light), AppTheme.DARK to stringResource(R.string.choice_theme_dark)) +@Composable +private fun darkChoices() = listOf( + DarkStyle.BLACK to stringResource(R.string.choice_dark_black), + DarkStyle.BLACK_COLOUR to stringResource(R.string.choice_dark_black_colour), + DarkStyle.DEEP_COLOUR to stringResource(R.string.choice_dark_deep_colour), + DarkStyle.GREY to stringResource(R.string.choice_dark_grey), ) -private val nightChoices = listOf(NightMode.AUTO to "Auto", NightMode.DAY to "Day", NightMode.NIGHT to "Night") -private val unitChoices = listOf(Units.AUTO to "Auto", Units.IMPERIAL to "Imperial", Units.METRIC to "Metric") -private val navBarChoices = listOf( - NavBarMode.SHOW to "Always show", - NavBarMode.HIDE_NAVIGATING to "Hide while navigating", - NavBarMode.HIDE to "Hide until tapped", +@Composable +private fun nightChoices() = listOf(NightMode.AUTO to stringResource(R.string.choice_auto), NightMode.DAY to stringResource(R.string.choice_night_day), NightMode.NIGHT to stringResource(R.string.choice_night_night)) +@Composable +private fun unitChoices() = listOf(Units.AUTO to stringResource(R.string.choice_auto), Units.IMPERIAL to stringResource(R.string.choice_units_imperial), Units.METRIC to stringResource(R.string.choice_units_metric)) +@Composable +private fun navBarChoices() = listOf( + NavBarMode.SHOW to stringResource(R.string.choice_nav_show), + NavBarMode.HIDE_NAVIGATING to stringResource(R.string.choice_nav_hide_navigating), + NavBarMode.HIDE to stringResource(R.string.choice_nav_hide), ) -private fun openChoices() = listOf(CarSettings.HOME to "Home") + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label } +@Composable +private fun openChoices() = listOf(CarSettings.HOME to stringResource(R.string.choice_open_home)) + CarApps.all.filter { it.id != "settings" }.map { it.id to it.label } /** * The car's settings on the phone's home screen: one card per group with a summary of where it @@ -82,20 +92,32 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo val theme by CarSettings.appTheme.collectAsState() val experimental by CarSettings.experimentalFeaturesEnabled.collectAsState() + val opens = openChoices() + val sizes = sizeChoices() + val themes = themeChoices() + val navBars = navBarChoices() + val context = androidx.compose.ui.platform.LocalContext.current + fun str(id: Int) = context.getString(id) fun summary(group: CarGroup) = when (group) { - CarGroup.SCREEN -> listOf(openChoices().label(open), sizeChoices.label(scale), themeChoices.label(theme)) - CarGroup.NAV_BAR -> listOf(navBarChoices.label(navBar), "Player on the ${if (playerLeft) "left" else "right"}") - CarGroup.SOUND -> listOf(if (pipeAudio) "USB audio" else "Bluetooth audio", "Directions ${if (speakDirections) "on" else "off"}") + CarGroup.SCREEN -> listOf(opens.label(open), sizes.label(scale), themes.label(theme)) + CarGroup.NAV_BAR -> listOf(navBars.label(navBar), str(if (playerLeft) R.string.summary_player_left else R.string.summary_player_right)) + CarGroup.SOUND -> listOf( + str(if (pipeAudio) R.string.summary_usb_audio else R.string.summary_bluetooth_audio), + str(if (speakDirections) R.string.summary_directions_on else R.string.summary_directions_off), + ) CarGroup.WARNINGS -> listOf( - "Bluetooth ${if (bluetoothWarning && !pipeAudio) "on" else "off"}", - "Location ${if (locationWarning) "on" else "off"}", + str(if (bluetoothWarning && !pipeAudio) R.string.summary_bluetooth_on else R.string.summary_bluetooth_off), + str(if (locationWarning) R.string.summary_location_on else R.string.summary_location_off), + ) + CarGroup.MORE -> listOf( + str(R.string.web_page), + str(if (experimental) R.string.summary_experimental_shown else R.string.summary_experimental_hidden), ) - CarGroup.MORE -> listOf("Web page", "Experimental features ${if (experimental) "shown" else "hidden"}") }.filter { it.isNotEmpty() }.joinToString(" · ") Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "Car", + stringResource(R.string.home_car), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onBackground, @@ -105,7 +127,7 @@ internal fun HomeCarSettings(onOpen: (CarGroup) -> Unit, modifier: Modifier = Mo CarGroup.entries.forEachIndexed { i, group -> if (i > 0) SettingsDivider() SettingsRow( - group.title, + stringResource(group.title), Modifier.clickable { onOpen(group) }, subtitle = summary(group), trailing = { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null) }, @@ -123,10 +145,10 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) { containerColor = MaterialTheme.colorScheme.background, topBar = { androidx.compose.material3.TopAppBar( - title = { Text(group.title) }, + title = { Text(stringResource(group.title)) }, navigationIcon = { androidx.compose.material3.IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -149,7 +171,7 @@ internal fun CarGroupScreen(group: CarGroup, onBack: () -> Unit) { else -> Unit } Text( - "These are the same settings as Settings in the car. A change here shows there too.", + stringResource(R.string.car_group_same), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp), @@ -183,78 +205,78 @@ private fun GroupRows(group: CarGroup) { when (group) { CarGroup.SCREEN -> { - Choice("Open on connect", openChoices(), open, CarSettings::setOpenOnConnect) + Choice(stringResource(R.string.car_open_on_connect), openChoices(), open, CarSettings::setOpenOnConnect) SettingsDivider() - Choice("Size", sizeChoices, scale, CarSettings::setScale) + Choice(stringResource(R.string.car_size), sizeChoices(), scale, CarSettings::setScale) SettingsDivider() - Choice("App theme", themeChoices, theme, CarSettings::setAppTheme) + Choice(stringResource(R.string.car_app_theme), themeChoices(), theme, CarSettings::setAppTheme) SettingsDivider() if (theme != AppTheme.LIGHT) { - Choice("Dark mode", darkChoices, darkStyle, CarSettings::setDarkStyle) + Choice(stringResource(R.string.car_dark_mode), darkChoices(), darkStyle, CarSettings::setDarkStyle) SettingsDivider() } - Choice("Buttons", GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme) + Choice(stringResource(R.string.car_buttons), GsThemes.builtIn.map { it.id to it.name }, uiTheme, CarSettings::setUiTheme) SettingsDivider() - Choice("Map day/night", nightChoices, night, CarSettings::setNightMode) + Choice(stringResource(R.string.car_map_day_night), nightChoices(), night, CarSettings::setNightMode) SettingsDivider() - Choice("Units", unitChoices, units, CarSettings::setUnits) + Choice(stringResource(R.string.car_units), unitChoices(), units, CarSettings::setUnits) } CarGroup.NAV_BAR -> { - Choice("Nav bar", navBarChoices, navBar, CarSettings::setNavBarMode) + Choice(stringResource(R.string.car_nav_bar), navBarChoices(), navBar, CarSettings::setNavBarMode) SettingsDivider() - Choice("Player in the nav bar", listOf(false to "Skip buttons", true to "Song title"), playerTitle, CarSettings::setPlayerTitle) + Choice(stringResource(R.string.car_player_nav_bar), listOf(false to stringResource(R.string.car_skip_buttons), true to stringResource(R.string.car_song_title)), playerTitle, CarSettings::setPlayerTitle) SettingsDivider() - Choice("Player beside the map", listOf(false to "Right", true to "Left"), playerLeft, CarSettings::setPlayerLeft) + Choice(stringResource(R.string.car_player_beside_map), listOf(false to stringResource(R.string.car_right), true to stringResource(R.string.car_left)), playerLeft, CarSettings::setPlayerLeft) SettingsDivider() - Toggle("Autoplay on connect", autoplay, CarSettings::setAutoplay) + Toggle(stringResource(R.string.car_autoplay), autoplay, CarSettings::setAutoplay) SettingsDivider() Toggle( - "More lyrics sources", + stringResource(R.string.car_more_lyrics), moreLyrics, CarSettings::setMoreLyrics, - note = "Also asks BetterLyrics and KuGou, which then get the song's name and artist. Off asks LRCLIB only.", + note = stringResource(R.string.car_more_lyrics_note), ) } CarGroup.SOUND -> { Toggle( - "Spoken directions", + stringResource(R.string.car_spoken_directions), speakDirections, CarSettings::setSpeakDirections, - note = "Turn it off if your map app's own voice already plays in the car.", + note = stringResource(R.string.car_spoken_directions_note), ) SettingsDivider() Toggle( - "Use the phone's speech recognition", + stringResource(R.string.car_phone_speech), systemSpeech, CarSettings::setSystemSpeech, - note = "On most phones that's Google's, which may send your voice to Google.", + note = stringResource(R.string.car_phone_speech_note), ) } CarGroup.WARNINGS -> { if (!pipeAudio) { - Toggle("Warn when Bluetooth isn't connected", bluetoothWarning, CarSettings::setBluetoothWarning) + Toggle(stringResource(R.string.car_warn_bluetooth), bluetoothWarning, CarSettings::setBluetoothWarning) SettingsDivider() } - Toggle("Warn when location is off", locationWarning, CarSettings::setLocationWarning) + Toggle(stringResource(R.string.car_warn_location), locationWarning, CarSettings::setLocationWarning) } CarGroup.MORE -> { WebPageRow() SettingsDivider() - Toggle("Experimental features", experimental, CarSettings::setExperimentalFeaturesEnabled) + Toggle(stringResource(R.string.car_experimental), experimental, CarSettings::setExperimentalFeaturesEnabled) if (experimental) { SettingsDivider() Toggle( - "Vehicle data", + stringResource(R.string.car_vehicle_data), carSensors, CarSettings::setCarSensorsEnabled, - note = "Adds a Vehicle data tile: a live dump of the car's sensors. The field names are guesses until you've watched one change.", + note = stringResource(R.string.car_vehicle_data_note), ) SettingsDivider() Toggle( - "Adaptive video quality", + stringResource(R.string.car_adaptive_video), adaptiveVideo, CarSettings::setAdaptiveVideoEnabled, - note = "Lowers the picture's quality when the car or its Wi-Fi can't keep up, and raises it again once it can. Applies on the next connection.", + note = stringResource(R.string.car_adaptive_video_note), ) } } @@ -312,14 +334,14 @@ private fun WebPageRow() { url = it app.seb3thehacker.gearslip.AppSettings.setStartupUrl(context, it) }, - label = { Text("Web page") }, + label = { Text(stringResource(R.string.web_page)) }, placeholder = { Text("DuckDuckGo") }, singleLine = true, keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), ) Text( - "Shown by the Browser app in the car. Empty opens DuckDuckGo. Applies on the next connection.", + stringResource(R.string.web_page_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 6.dp), @@ -332,20 +354,20 @@ private fun WebPageRow() { internal fun CarAudioSection() { val usb by CarSettings.pipeAudio.collectAsState() SettingsSection( - "Car audio", - footer = if (usb) "Run the command in Help once, and USB stops asking." else "Calls go over Bluetooth either way.", + stringResource(R.string.car_audio), + footer = stringResource(if (usb) R.string.car_audio_usb_footer else R.string.car_audio_bt_footer), ) { SettingsRow( - "Bluetooth (Recommended)", + stringResource(R.string.car_audio_bt), Modifier.selectable(!usb, role = androidx.compose.ui.semantics.Role.RadioButton) { CarSettings.setPipeAudio(false) }, - subtitle = "No prompt each drive, and your map app's voice reaches the car", + subtitle = stringResource(R.string.car_audio_bt_sub), trailing = { androidx.compose.material3.RadioButton(selected = !usb, onClick = null) }, ) SettingsDivider() SettingsRow( - "USB", + stringResource(R.string.guide_audio_usb), Modifier.selectable(usb, role = androidx.compose.ui.semantics.Role.RadioButton) { CarSettings.setPipeAudio(true) }, - subtitle = "Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound", + subtitle = stringResource(R.string.car_audio_usb_sub), trailing = { androidx.compose.material3.RadioButton(selected = usb, onClick = null) }, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt index 56df73b..26ab79e 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/HomeScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.speech.SpeechModels import androidx.compose.material3.LinearProgressIndicator @@ -115,15 +117,15 @@ fun HomeScreen( // Sits at the bottom, where a thumb reaches it. Column(Modifier.padding(vertical = 16.dp)) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - HomeShortcut(Icons.Filled.Info, "Help", onOpenHelp, Modifier.weight(1f)) + HomeShortcut(Icons.Filled.Info, stringResource(R.string.home_help), onOpenHelp, Modifier.weight(1f)) // The preview draws the car screen on the phone, so it waits until no car is using it. - if (!live) HomeShortcut(Icons.Filled.PlayArrow, "Preview", onOpenCarPreview, Modifier.weight(1f)) - HomeShortcut(Icons.AutoMirrored.Filled.List, "Logs", onOpenLogs, Modifier.weight(1f)) + if (!live) HomeShortcut(Icons.Filled.PlayArrow, stringResource(R.string.home_preview), onOpenCarPreview, Modifier.weight(1f)) + HomeShortcut(Icons.AutoMirrored.Filled.List, stringResource(R.string.home_logs), onOpenLogs, Modifier.weight(1f)) } if (live) { Spacer(Modifier.height(16.dp)) Button(onClick = onDisconnect, modifier = Modifier.fillMaxWidth().height(56.dp)) { - Text("Disconnect") + Text(stringResource(R.string.home_disconnect)) } } } @@ -156,12 +158,12 @@ private fun HomeHeader(onOpenSettings: () -> Unit) { modifier = Modifier.weight(1f), ) IconButton(onClick = onOpenSettings, modifier = Modifier.size(56.dp)) { - Icon(Icons.Filled.Settings, contentDescription = "Settings", modifier = Modifier.size(32.dp)) + Icon(Icons.Filled.Settings, contentDescription = stringResource(R.string.settings_title), modifier = Modifier.size(32.dp)) } } val version = "v${LocalContext.current.appVersionName()}" Text( - if (debug) "$version · debug build" else version, + if (debug) stringResource(R.string.home_debug_build, version) else version, style = MaterialTheme.typography.labelLarge, color = if (debug) content else MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -206,10 +208,9 @@ private fun BluetoothMissingCard() { Spacer(Modifier.height(16.dp)) Surface(color = container, contentColor = content, shape = MaterialTheme.shapes.extraLarge) { Column(Modifier.fillMaxWidth().padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("Bluetooth isn't connected", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.home_bt_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Text( - "Music plays over Bluetooth, but your phone isn't connected to the car. Connect it " + - "here or from the car's Bluetooth menu.", + stringResource(R.string.home_bt_body), style = MaterialTheme.typography.bodyMedium, ) Button( @@ -220,7 +221,7 @@ private fun BluetoothMissingCard() { } }, modifier = Modifier.padding(top = 4.dp), - ) { Text("Bluetooth settings") } + ) { Text(stringResource(R.string.bluetooth_settings)) } } } } @@ -233,7 +234,7 @@ private fun VoiceDownloadCard() { Spacer(Modifier.height(16.dp)) SettingsCard { Column(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Text("Downloading voice, $percent%", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.home_voice_downloading, percent), style = MaterialTheme.typography.bodyLarge) LinearProgressIndicator(progress = { percent / 100f }, modifier = Modifier.fillMaxWidth()) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/ManageSpaceActivity.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/ManageSpaceActivity.kt index 472fe46..6f4feca 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/ManageSpaceActivity.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/ManageSpaceActivity.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.app.ActivityManager import android.content.Context import android.os.Bundle @@ -108,9 +111,9 @@ private fun StorageScreen(onBack: () -> Unit) { containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text("Gearslip storage") }, + title = { Text(stringResource(R.string.storage_title)) }, navigationIcon = { - IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) }, @@ -121,23 +124,23 @@ private fun StorageScreen(onBack: () -> Unit) { ) { val u = usage Text( - if (u == null) "Measuring…" else "Gearslip's data takes ${size(u.total)} of the phone's storage.", + if (u == null) stringResource(R.string.storage_measuring) else stringResource(R.string.storage_total, size(u.total)), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) SettingsCard { StorageRow( - "Lyrics", - u?.let { "${it.lyricsSongs} songs · ${size(it.lyrics)}. Cleared lyrics download again when you open them." }, + stringResource(R.string.storage_lyrics), + u?.let { pluralStringResource(R.plurals.storage_lyrics_detail, it.lyricsSongs, it.lyricsSongs, size(it.lyrics)) }, enabled = (u?.lyrics ?: 0) > 0, - action = "Clear", + action = stringResource(R.string.storage_clear), ) { clear { Lyrics.clearCache(context) } } SettingsDivider() StorageRow( - "Voice models", - u?.let { "${size(it.voice)}. Voice commands need them; download them again in Settings." }, + stringResource(R.string.storage_voice), + u?.let { stringResource(R.string.storage_voice_detail, size(it.voice)) }, enabled = (u?.voice ?: 0) > 0, - action = "Remove", + action = stringResource(R.string.storage_remove), ) { clear { voiceDir(context).deleteRecursively() @@ -146,17 +149,17 @@ private fun StorageScreen(onBack: () -> Unit) { } SettingsDivider() StorageRow( - "Old logs", - u?.let { "${size(it.logs)}. Logs from earlier launches; this launch's log stays." }, + stringResource(R.string.storage_logs), + u?.let { stringResource(R.string.storage_logs_detail, size(it.logs)) }, enabled = (u?.logs ?: 0) > 0, - action = "Clear", + action = stringResource(R.string.storage_clear), ) { clear { oldLogs(context).forEach { it.deleteRecursively() } } } SettingsDivider() StorageRow( - "Other temporary files", - u?.let { "${size(it.other)}. Downloaded updates and other files Gearslip can make again." }, + stringResource(R.string.storage_other), + u?.let { stringResource(R.string.storage_other_detail, size(it.other)) }, enabled = (u?.other ?: 0) > 0, - action = "Clear", + action = stringResource(R.string.storage_clear), ) { clear { otherCache(context).forEach { it.deleteRecursively() } } } } DeleteEverything() @@ -168,7 +171,7 @@ private fun StorageScreen(onBack: () -> Unit) { private fun StorageRow(title: String, detail: String?, enabled: Boolean, action: String, onClick: () -> Unit) { SettingsRow( title, - subtitle = detail ?: "Measuring…", + subtitle = detail ?: stringResource(R.string.storage_measuring), trailing = { TextButton(onClick = onClick, enabled = enabled) { Text(action) } }, ) } @@ -180,18 +183,18 @@ private fun DeleteEverything() { var armed by remember { mutableStateOf(false) } SettingsCard { SettingsRow( - "Delete all Gearslip data", + stringResource(R.string.storage_delete_all), subtitle = if (armed) { - "Tap Delete again to erase settings, certificates, voice models and logs. Gearslip closes, and starts from setup next time." + stringResource(R.string.storage_delete_armed) } else { - "Settings, certificates, voice models, lyrics and logs." + stringResource(R.string.storage_delete_sub) }, titleColor = MaterialTheme.colorScheme.error, trailing = { TextButton(onClick = { if (armed) context.getSystemService(ActivityManager::class.java)?.clearApplicationUserData() else armed = true - }) { Text(if (armed) "Delete" else "Delete…", color = MaterialTheme.colorScheme.error) } + }) { Text(stringResource(if (armed) R.string.storage_delete else R.string.storage_delete_ellipsis), color = MaterialTheme.colorScheme.error) } }, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt index c71ecc3..5d806f6 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/PhoneTypingBar.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding @@ -49,7 +51,7 @@ fun PhoneTypingBar(modifier: Modifier = Modifier) { Surface(color = MaterialTheme.colorScheme.surfaceContainerHigh, modifier = modifier.fillMaxWidth().imePadding()) { Row(Modifier.padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = PhoneTyping::dismiss) { - Icon(Icons.Filled.Close, contentDescription = "Close the car keyboard") + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.typing_close)) } OutlinedTextField( value = value, @@ -57,14 +59,14 @@ fun PhoneTypingBar(modifier: Modifier = Modifier) { value = it PhoneTyping.type(it.text) }, - label = { Text("Type for the car screen") }, + label = { Text(stringResource(R.string.typing_label)) }, singleLine = true, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions(onSearch = { PhoneTyping.submit() }), modifier = Modifier.weight(1f).focusRequester(focus), ) IconButton(onClick = PhoneTyping::submit) { - Icon(Icons.Filled.Search, contentDescription = "Search on the car screen") + Icon(Icons.Filled.Search, contentDescription = stringResource(R.string.typing_search)) } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt index f641f4a..2af7386 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/RecommendedApps.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.ui +import androidx.annotation.StringRes +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.content.Context import android.content.Intent import android.graphics.drawable.Drawable @@ -43,22 +46,22 @@ import androidx.compose.ui.unit.dp import androidx.core.graphics.drawable.toBitmap /** A free app that runs on Gearslip's car screen, and where to get it. */ -private class RecommendedApp(val name: String, val packageName: String, val url: String, val about: String) +private class RecommendedApp(val name: String, val packageName: String, val url: String, @param:StringRes val about: Int) private val recommended = listOf( - "Maps" to listOf( - RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", "Offline maps and routing."), - RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", "Turn-by-turn navigation without Google."), + R.string.recommended_maps to listOf( + RecommendedApp("Organic Maps", "app.organicmaps", "https://github.com/organicmaps/organicmaps", R.string.recommended_about_organic), + RecommendedApp("Vela Maps", "app.vela", "https://github.com/PimpinPumpkin/Vela", R.string.recommended_about_vela), ), - "Music" to listOf( - RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", "A YouTube Music player."), - RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", "A YouTube Music player."), + R.string.recommended_music to listOf( + RecommendedApp("ViVi Music", "com.vivi.vivimusic", "https://github.com/vivizzz007/vivi-music", R.string.recommended_about_ytm), + RecommendedApp("Metrolist", "com.metrolist.music", "https://github.com/MetrolistGroup/Metrolist", R.string.recommended_about_ytm), ), - "Podcasts" to listOf( - RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", "Podcasts, chapters and all."), + R.string.recommended_podcasts to listOf( + RecommendedApp("AntennaPod", "de.danoeh.antennapod", "https://github.com/AntennaPod/AntennaPod", R.string.recommended_about_antennapod), ), - "Other cool apps" to listOf( - RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", "Spots trackers and cameras nearby."), + R.string.recommended_other to listOf( + RecommendedApp("RF Sentinel", "com.rfsentinel.app", "https://github.com/CIS-C0/RFSentinel", R.string.recommended_about_rfsentinel), ), ) @@ -70,10 +73,10 @@ fun RecommendedAppsScreen(onBack: () -> Unit) { containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text("Recommended apps") }, + title = { Text(stringResource(R.string.recommended_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -87,7 +90,7 @@ fun RecommendedAppsScreen(onBack: () -> Unit) { .verticalScroll(rememberScrollState()), ) { Text( - RECOMMENDED_INTRO, + stringResource(R.string.recommended_intro), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -101,9 +104,6 @@ fun RecommendedAppsScreen(onBack: () -> Unit) { /** Hidden for now: off, neither Settings nor the setup guide shows the list. The code stays. */ internal const val SHOW_RECOMMENDED_APPS = false -internal const val RECOMMENDED_INTRO = - "These free apps run on Gearslip's car screen. Each button opens the app's page on GitHub." - /** The same green as the launcher's "works" check. */ private val INSTALLED_GREEN = Color(0xFF43A047) @@ -115,7 +115,7 @@ internal fun RecommendedAppsList(modifier: Modifier = Modifier) { for ((section, apps) in recommended) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - section, + stringResource(section), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, @@ -145,12 +145,12 @@ private fun AppRow(app: RecommendedApp, onOpen: () -> Unit) { // Colours set outright: the setup guide draws this on its own background, with no // Surface to hand down a content colour, and unset text came out dark on dark. Text(app.name, style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface) - Text(app.about, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(app.about), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) if (icon != null) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) { Icon(Icons.Filled.CheckCircle, contentDescription = null, tint = INSTALLED_GREEN, modifier = Modifier.size(16.dp)) Text( - "Installed", + stringResource(R.string.recommended_installed), style = MaterialTheme.typography.labelLarge, color = INSTALLED_GREEN, modifier = Modifier.padding(start = 6.dp), diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt index bf00b56..03f765d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SettingsScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.runtime.withFrameNanos import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult @@ -107,10 +109,10 @@ fun SettingsScreen( containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text("Settings") }, + title = { Text(stringResource(R.string.settings_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -128,25 +130,25 @@ fun SettingsScreen( SettingsSection { val context = LocalContext.current SettingsRow( - "What's new", + stringResource(R.string.settings_whats_new), Modifier.clickable { GearslipLinks.open(context, GearslipLinks.RELEASE_NOTES) }, - subtitle = "Read the ${BuildConfig.VERSION_NAME} release notes on GitHub", + subtitle = stringResource(R.string.settings_whats_new_sub, BuildConfig.VERSION_NAME), icon = Icons.Filled.Star, trailing = { Arrow() }, ) } SettingsSection( - "Support Gearslip", - footer = "Gearslip is free, with no ads and no tracking. Your support keeps it going.", + stringResource(R.string.settings_support), + footer = stringResource(R.string.settings_support_footer), ) { DonateRows() } if (ready(2)) ConnectionSection() if (SHOW_RECOMMENDED_APPS && ready(6)) AppsSection(onOpenRecommendedApps) if (ready(7)) SettingsSection( - "Usage notes", + stringResource(R.string.settings_usage_notes), Modifier.onGloballyPositioned { usageNotesY = it.positionInParent().y.toInt() }, - header = USAGE_STATS_INTRO, + header = usageStatsIntro(LocalContext.current), ) { UsageStatsRows() } - if (ready(8)) SettingsSection("Updates") { UpdateRow() } + if (ready(8)) SettingsSection(stringResource(R.string.settings_updates)) { UpdateRow() } if (ready(9)) CertificateSection() if (ready(10)) AboutSection(onReplayTutorial) if (BuildConfig.DEBUG && ready(11)) DebugSection() @@ -168,11 +170,11 @@ private fun Arrow() = Icon( /** The setup guide replay, and where Gearslip lives outside the app. */ @Composable private fun AboutSection(onReplayTutorial: () -> Unit) { - SettingsSection("About") { + SettingsSection(stringResource(R.string.settings_about)) { SettingsRow( - "Replay the setup guide", + stringResource(R.string.settings_replay), Modifier.clickable(onClick = onReplayTutorial), - subtitle = "Go through the first-run steps again", + subtitle = stringResource(R.string.settings_replay_sub), icon = Icons.Filled.Refresh, trailing = { Arrow() }, ) @@ -186,14 +188,14 @@ private fun AboutSection(onReplayTutorial: () -> Unit) { private fun ConnectionSection() { val context = LocalContext.current var enabled by remember { mutableStateOf(AppSettings.gearslipEnabled(context)) } - SettingsSection("Connection") { + SettingsSection(stringResource(R.string.settings_connection)) { SettingsRow( - "Use Gearslip in the car", + stringResource(R.string.settings_use_in_car), Modifier.toggleable(enabled, role = Role.Switch) { enabled = it AppSettings.setGearslipEnabled(context, it) }, - subtitle = if (enabled) "Gearslip answers when you plug into a car" else "Off: Android Auto can take the connection instead", + subtitle = stringResource(if (enabled) R.string.settings_use_in_car_on else R.string.settings_use_in_car_off), icon = Icons.Filled.Settings, trailing = { Switch(checked = enabled, onCheckedChange = null) }, ) @@ -224,9 +226,9 @@ private fun DebugSection() { @Composable private fun AppsSection(onOpenRecommendedApps: () -> Unit) { - SettingsSection("Apps") { + SettingsSection(stringResource(R.string.settings_apps)) { SettingsRow( - "Recommended apps", + stringResource(R.string.recommended_title), Modifier.clickable(onClick = onOpenRecommendedApps), icon = Icons.Filled.Star, trailing = { Arrow() }, @@ -253,21 +255,21 @@ private fun CertificateSection() { if (uri != null) pendingUri = uri } - SettingsSection("Certificate", footer = notice ?: "Changes take effect on the next connection.") { + SettingsSection(stringResource(R.string.settings_certificate), footer = notice ?: stringResource(R.string.cert_next_connection)) { val s = summary val loaded = s != null && s.kind != CertProvider.Kind.SELF_SIGNED val hasDetails = s?.subject != null || s?.issuer != null SettingsRow( - s?.headline ?: "Checking…", + s?.headline ?: stringResource(R.string.cert_checking), if (hasDetails) Modifier.clickable { detailsOpen = !detailsOpen } else Modifier, - subtitle = s?.validUntil?.let { "Valid until $it" }, + subtitle = s?.validUntil?.let { stringResource(R.string.cert_valid_until, it) }, icon = if (s == null || loaded) Icons.Filled.Lock else Icons.Filled.Warning, tint = if (s == null || loaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, trailing = if (hasDetails) { { Icon( if (detailsOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, - contentDescription = if (detailsOpen) "Hide details" else "Show details", + contentDescription = stringResource(if (detailsOpen) R.string.cert_hide_details else R.string.cert_show_details), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -278,39 +280,39 @@ private fun CertificateSection() { Modifier.padding(start = 56.dp, end = 16.dp, bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - s?.subject?.let { Detail("Subject", it) } - s?.issuer?.let { Detail("Issuer", it) } + s?.subject?.let { Detail(stringResource(R.string.cert_subject), it) } + s?.issuer?.let { Detail(stringResource(R.string.cert_issuer), it) } } } SettingsDivider() SettingsRow( - if (downloading) "Downloading…" else "Download from aasdk", + stringResource(if (downloading) R.string.cert_downloading else R.string.cert_download), Modifier.clickable(enabled = !downloading) { downloading = true scope.launch { val result = withContext(Dispatchers.IO) { runCatching { CertProvider.downloadAndImport(context) } } downloading = false result.onSuccess { - notice = "Certificate downloaded." + notice = context.getString(R.string.cert_downloaded) refresh++ }.onFailure { - notice = "Download failed: ${it.message}" + notice = context.getString(R.string.cert_download_failed, it.message.toString()) } } }, - subtitle = "The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn't include one", + subtitle = stringResource(R.string.cert_download_sub), icon = Icons.Filled.Refresh, ) SettingsDivider() SettingsRow( - "Import my own file", + stringResource(R.string.cert_import), Modifier.clickable { picker.launch(arrayOf("*/*")) }, - subtitle = "A .p12 file with its private key, from wherever you choose", + subtitle = stringResource(R.string.cert_import_sub), icon = Icons.Filled.Add, ) val removable = when (s?.kind) { - CertProvider.Kind.IMPORTED -> "Remove imported certificate" - CertProvider.Kind.DOWNLOADED -> "Remove downloaded certificate" + CertProvider.Kind.IMPORTED -> stringResource(R.string.cert_remove_imported) + CertProvider.Kind.DOWNLOADED -> stringResource(R.string.cert_remove_downloaded) else -> null } if (removable != null) { @@ -319,7 +321,7 @@ private fun CertificateSection() { removable, Modifier.clickable { if (s?.kind == CertProvider.Kind.IMPORTED) CertProvider.removeImported(context) else CertProvider.removeDownloaded(context) - notice = if (s?.kind == CertProvider.Kind.IMPORTED) "Removed the imported certificate." else "Removed the downloaded certificate." + notice = context.getString(if (s?.kind == CertProvider.Kind.IMPORTED) R.string.cert_removed_imported else R.string.cert_removed_downloaded) refresh++ }, icon = Icons.Filled.Delete, @@ -339,10 +341,10 @@ private fun CertificateSection() { } result.onSuccess { pendingUri = null - notice = "Certificate imported." + notice = context.getString(R.string.cert_imported) refresh++ }.onFailure { - reportError("Could not import: wrong password, or not a PKCS#12 file with a private key.") + reportError(context.getString(R.string.cert_import_failed)) } } }, @@ -365,20 +367,20 @@ private fun PasswordDialog(onDismiss: () -> Unit, onConfirm: (String, (String) - AlertDialog( onDismissRequest = onDismiss, - title = { Text("Certificate password") }, + title = { Text(stringResource(R.string.cert_password_title)) }, text = { OutlinedTextField( value = password, onValueChange = { password = it; error = null }, singleLine = true, - label = { Text("Password") }, + label = { Text(stringResource(R.string.cert_password)) }, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), isError = error != null, - supportingText = { Text(error ?: "Leave empty if the file has none.") }, + supportingText = { Text(error ?: stringResource(R.string.cert_password_hint)) }, ) }, - confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text("Import") } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = { onConfirm(password) { error = it } }) { Text(stringResource(R.string.cert_import_button)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt index 6e8ba41..98d9465 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/SetupGuideScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import app.seb3thehacker.gearslip.speech.SpeechModels import androidx.compose.material3.MaterialTheme @@ -160,8 +162,8 @@ private fun hasNotificationAccess(context: Context): Boolean { * who set up before the notes existed see once. Leaving it without turning them on is a no. */ private fun usageNotesPage(context: Context) = GuidePage( - title = "Help other drivers.", - explanation = USAGE_STATS_INTRO, + title = context.getString(R.string.guide_usage_title), + explanation = usageStatsIntro(context), icon = { HeartIcon(it) }, content = { UsageStatsChoices() @@ -175,9 +177,8 @@ private fun pages(context: Context, onRequestCallScreening: () -> Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List Unit): List "Downloading, $progress%. You can carry on; it keeps going." - states.all { it == SpeechModels.State.Ready } -> "Voice is ready." - states.any { it is SpeechModels.State.Failed } -> "The download didn't finish. Tap Download voice to try again." + progress != null -> stringResource(R.string.guide_voice_downloading, progress ?: 0) + states.all { it == SpeechModels.State.Ready } -> stringResource(R.string.guide_voice_ready) + states.any { it is SpeechModels.State.Failed } -> stringResource(R.string.guide_voice_failed) else -> null } LaunchedEffect(Unit) { SpeechModels.init(context) } @@ -421,13 +408,13 @@ private fun AudioChoice() { LaunchedEffect(Unit) { if (!CarSettings.pipeAudioChosen) CarSettings.setPipeAudio(false) } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { AudioCard( - "Bluetooth", "Recommended", - "No prompt each drive, and your map app's voice reaches the car.", + stringResource(R.string.guide_audio_bluetooth), stringResource(R.string.guide_recommended), + stringResource(R.string.guide_audio_bluetooth_body), selected = !usb, ) { CarSettings.setPipeAudio(false) } AudioCard( - "USB", null, - "Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound.", + stringResource(R.string.guide_audio_usb), null, + stringResource(R.string.guide_audio_usb_body), selected = usb, ) { CarSettings.setPipeAudio(true) } } @@ -535,7 +522,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl Spacer(Modifier.height(cq(7f))) if (page.optional) { Text( - "OPTIONAL", color = c.primary, + stringResource(R.string.guide_optional), color = c.primary, style = TextStyle(fontSize = cqSp(3.4f), fontWeight = FontWeight.Bold, letterSpacing = cqSp(0.4f)), modifier = Modifier.up(entrance(index, 150, 700), cq(7f)), ) @@ -564,7 +551,7 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl page.content?.invoke(GuideScale(c, ::cq, ::cqSp)) if (page.permissions != null && alreadyGranted) { Text( - "Already allowed.", color = c.primary, + stringResource(R.string.guide_already_allowed), color = c.primary, style = TextStyle(fontSize = cqSp(4.2f), fontWeight = FontWeight.SemiBold), ) } @@ -585,14 +572,14 @@ fun SetupGuideScreen(onRequestCallScreening: () -> Unit, onDone: () -> Unit, onl val next = { index += 1 } when { page.permissions != null && !alreadyGranted -> { - PillRow(c, ::cq, ::cqSp, back, "Allow") { launcher.launch(page.permissions) } - Pill(c, ::cqSp, "Skip for now", PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next) + PillRow(c, ::cq, ::cqSp, back, stringResource(R.string.guide_allow)) { launcher.launch(page.permissions) } + Pill(c, ::cqSp, stringResource(R.string.guide_skip), PillStyle.TEXT, Modifier.align(Alignment.CenterHorizontally), next) } page.onAction != null -> { - Pill(c, ::cqSp, page.actionLabel ?: "Open", PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction) - PillRow(c, ::cq, ::cqSp, back, "Next", next) + Pill(c, ::cqSp, page.actionLabel ?: stringResource(R.string.guide_open), PillStyle.OUTLINED, Modifier.fillMaxWidth().padding(bottom = cq(3f)), page.onAction) + PillRow(c, ::cq, ::cqSp, back, stringResource(R.string.guide_next), next) } - else -> PillRow(c, ::cq, ::cqSp, back, if (index == pages.lastIndex) "Done" else "Next", next) + else -> PillRow(c, ::cq, ::cqSp, back, stringResource(if (index == pages.lastIndex) R.string.guide_done else R.string.guide_next), next) } Spacer(Modifier.height(cq(6f))) } @@ -613,7 +600,7 @@ private fun ScrollHint(c: IntroColors, cq: (Float) -> Dp, cqSp: (Float) -> TextU contentAlignment = Alignment.BottomCenter, ) { Text( - "Scroll for more ↓", + stringResource(R.string.guide_scroll_more), color = c.primary, style = TextStyle(fontSize = cqSp(4f), fontWeight = FontWeight.Bold), modifier = Modifier.padding(bottom = cq(2f)).offset(y = cq(1.2f) * bob), @@ -657,7 +644,7 @@ internal fun PillRow( onBack: (() -> Unit)?, label: String, onClick: () -> Unit, ) { Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(cq(3f))) { - if (onBack != null) Pill(c, cqSp, "Back", PillStyle.OUTLINED, Modifier.weight(1f), onBack) + if (onBack != null) Pill(c, cqSp, stringResource(R.string.guide_back), PillStyle.OUTLINED, Modifier.weight(1f), onBack) Pill(c, cqSp, label, PillStyle.FILLED, Modifier.weight(if (onBack != null) 1.4f else 1f), onClick) } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt index c4b8774..2a91c8d 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UpdateViews.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.content.Intent import android.net.Uri import androidx.compose.foundation.clickable @@ -42,7 +44,6 @@ import app.seb3thehacker.gearslip.SessionStatus import app.seb3thehacker.gearslip.update.UpdateChecker import app.seb3thehacker.gearslip.update.UpdateChecker.State -private const val UNPLUG_FIRST = "Unplug from the car to install. Installing closes Gearslip." /** Shown on Home once a newer version is found; hidden otherwise. */ @Composable @@ -64,37 +65,37 @@ fun UpdateCard() { shape = MaterialTheme.shapes.extraLarge, ) { Column(Modifier.fillMaxWidth().padding(20.dp)) { - Text("Gearslip ${release.version} is out", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.update_card_title, release.version), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold) Spacer(Modifier.height(4.dp)) when (val s = state) { is State.Downloading -> { - Text("Downloading… ${(s.fraction * 100).toInt()}%", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.update_downloading, (s.fraction * 100).toInt()), style = MaterialTheme.typography.bodyMedium) Spacer(Modifier.height(12.dp)) LinearProgressIndicator(progress = { s.fraction }, modifier = Modifier.fillMaxWidth()) } is State.Ready -> { val connected = UpdateChecker.carConnected Text( - if (connected) UNPLUG_FIRST else "Downloaded. Android asks before it installs.", + if (connected) stringResource(R.string.update_unplug_first) else stringResource(R.string.update_downloaded_ask), style = MaterialTheme.typography.bodyMedium, ) Spacer(Modifier.height(12.dp)) - Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text("Install") } + Button(onClick = { UpdateChecker.install(context) }, enabled = !connected) { Text(stringResource(R.string.update_install)) } } else -> { Text( - (s as? State.Failed)?.message ?: "Download it now. Android asks before it installs.", + (s as? State.Failed)?.message ?: stringResource(R.string.update_download_now), style = MaterialTheme.typography.bodyMedium, ) Spacer(Modifier.height(12.dp)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { UpdateChecker.download(context, release) }) { - Text(if (s is State.Failed) "Try again" else "Update") + Text(stringResource(if (s is State.Failed) R.string.update_try_again else R.string.update_update)) } if (release.pageUrl.isNotBlank()) { TextButton(onClick = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(release.pageUrl))) - }) { Text("What's new") } + }) { Text(stringResource(R.string.settings_whats_new)) } } } } @@ -106,13 +107,14 @@ fun UpdateCard() { } /** What the update check found, in a line. */ +@Composable private fun statusLine(state: State): String = when (state) { - State.Idle -> if (BuildConfig.DEBUG) "Debug builds check only when you tap Check now" else "Gearslip checks once a day" - State.Checking -> "Checking…" - State.UpToDate -> "You have the latest version" - is State.Available -> "Version ${state.release.version} is out" - is State.Downloading -> "Downloading… ${(state.fraction * 100).toInt()}%" - is State.Ready -> if (UpdateChecker.carConnected) UNPLUG_FIRST else "Version ${state.release.version} is ready to install" + State.Idle -> stringResource(if (BuildConfig.DEBUG) R.string.update_debug_manual else R.string.update_checks_daily) + State.Checking -> stringResource(R.string.update_checking) + State.UpToDate -> stringResource(R.string.update_latest) + is State.Available -> stringResource(R.string.update_version_out, state.release.version) + is State.Downloading -> stringResource(R.string.update_downloading, (state.fraction * 100).toInt()) + is State.Ready -> if (UpdateChecker.carConnected) stringResource(R.string.update_unplug_first) else stringResource(R.string.update_version_ready, state.release.version) is State.Failed -> state.message } @@ -135,7 +137,7 @@ internal fun UpdateRow() { var open by remember { mutableStateOf(false) } val channelName = CHANNELS.firstOrNull { it.first == channel }?.second SettingsRow( - "Updates", + stringResource(R.string.settings_updates), Modifier.clickable { open = true }, subtitle = listOfNotNull(channelName, statusLine(state)).joinToString(" · "), icon = Icons.Filled.CheckCircle, @@ -169,22 +171,23 @@ private fun UpdateDialog( ) { val context = LocalContext.current var auto by remember { mutableStateOf(UpdateChecker.autoCheckEnabled(context)) } + val actionLabels = listOf(R.string.update_download, R.string.update_install, R.string.update_try_again, R.string.update_check_now).map { stringResource(it) } val action: Pair Unit>? = when (state) { State.Checking, is State.Downloading -> null - is State.Available -> "Download" to { UpdateChecker.download(context, state.release) } - is State.Ready -> if (UpdateChecker.carConnected) null else "Install" to { UpdateChecker.install(context) } - is State.Failed -> "Try again" to { + is State.Available -> actionLabels[0] to { UpdateChecker.download(context, state.release) } + is State.Ready -> if (UpdateChecker.carConnected) null else actionLabels[1] to { UpdateChecker.install(context) } + is State.Failed -> actionLabels[2] to { state.release?.let { UpdateChecker.download(context, it) } ?: UpdateChecker.check(context) } - else -> "Check now" to { UpdateChecker.check(context) } + else -> actionLabels[3] to { UpdateChecker.check(context) } } AlertDialog( onDismissRequest = onDismiss, - title = { Text("Updates") }, + title = { Text(stringResource(R.string.settings_updates)) }, text = { Column { Text( - "Version ${BuildConfig.VERSION_NAME}. ${statusLine(state)}.", + stringResource(R.string.update_dialog_status, BuildConfig.VERSION_NAME, statusLine(state)), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 12.dp), ) @@ -218,9 +221,9 @@ private fun UpdateDialog( verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { - Text("Check automatically", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.update_check_auto), style = MaterialTheme.typography.bodyLarge) Text( - if (BuildConfig.DEBUG) "Debug builds check only when you tap Check now" else "Once a day", + stringResource(if (BuildConfig.DEBUG) R.string.update_debug_manual else R.string.update_once_a_day), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -232,6 +235,6 @@ private fun UpdateDialog( confirmButton = { action?.let { (label, run) -> TextButton(onClick = run) { Text(label) } } }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.guide_done)) } }, ) } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt index c030d99..7e11ebd 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/UsageStatsChoices.kt @@ -1,5 +1,8 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.text.fromHtml +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.animation.AnimatedVisibility import android.content.Context import androidx.compose.foundation.clickable @@ -53,21 +56,9 @@ import androidx.compose.ui.unit.dp import app.seb3thehacker.gearslip.BuildConfig import app.seb3thehacker.gearslip.stats.UsageStats -internal const val USAGE_STATS_TITLE = "Help show which cars work" - -private const val INTRO_START = - "Turn this on, and Gearslip sends a short note when you open it and after each drive. " -private const val INTRO_PRIVACY = - "The note holds no name, account, or location. A random number counts your phone once, and the server keeps no IP address." -private const val INTRO_END = - " The notes show which cars work and where the others fail." - /** The pitch for the usage notes, in setup and Settings, with the privacy promise in bold so it isn't missed. */ -internal val USAGE_STATS_INTRO: AnnotatedString = buildAnnotatedString { - append(INTRO_START) - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(INTRO_PRIVACY) } - append(INTRO_END) -} +internal fun usageStatsIntro(context: Context): AnnotatedString = + AnnotatedString.fromHtml(context.getString(R.string.usage_intro)) /** The usage notes card for the setup guide: the same rows Settings shows. */ @Composable @@ -90,9 +81,9 @@ internal fun ColumnScope.UsageStatsRows() { DisposableEffect(Unit) { onDispose { UsageStats.choicesClosed(context) } } SettingsRow( - "Send usage notes", + stringResource(R.string.usage_send), Modifier.toggleable(on, role = Role.Switch) { on = it; save() }, - subtitle = buildNote(), + subtitle = buildNote(context), icon = Icons.Filled.Send, trailing = { Switch(checked = on, onCheckedChange = null) }, ) @@ -100,31 +91,31 @@ internal fun ColumnScope.UsageStatsRows() { Column { SettingsDivider() Text( - "Also share", + stringResource(R.string.usage_also_share), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(start = 56.dp, end = 16.dp, top = 12.dp, bottom = 4.dp), ) - Choice("Android version and system", choices.android) { choices = choices.copy(android = it); save() } - Choice("Phone model", choices.phone) { choices = choices.copy(phone = it); save() } - Choice("Each car and whether it connected", choices.cars) { + Choice(stringResource(R.string.usage_share_android), choices.android) { choices = choices.copy(android = it); save() } + Choice(stringResource(R.string.usage_share_phone), choices.phone) { choices = choices.copy(phone = it); save() } + Choice(stringResource(R.string.usage_share_cars), choices.cars) { choices = choices.copy(cars = it) save() } - Choice("Each drive and how it ended", choices.failures) { + Choice(stringResource(R.string.usage_share_drives), choices.failures) { choices = choices.copy(failures = it) save() } SettingsDivider() SettingsRow( - "What gets sent", + stringResource(R.string.usage_what_sent), Modifier.clickable { noteOpen = !noteOpen }, - subtitle = "Every field, exactly as it goes out", + subtitle = stringResource(R.string.usage_what_sent_sub), icon = Icons.Filled.Info, trailing = { Icon( if (noteOpen) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, - contentDescription = if (noteOpen) "Hide the note" else "Show the note", + contentDescription = stringResource(if (noteOpen) R.string.usage_hide_note else R.string.usage_show_note), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) }, @@ -167,15 +158,15 @@ private fun notePreview(context: Context): String { val preview = UsageStats.preview(context, everything = true) // Until the first drive the list is empty, so show what one entry looks like. val example = if (preview.optJSONArray("sessions")?.length() == 0) { - "\n\nExample, not sent: one drive adds this to \"sessions\"\n" + UsageStats.exampleSession.toString(2) + "\n\n" + context.getString(R.string.usage_example) + "\n" + UsageStats.exampleSession.toString(2) } else "" return preview.toString(2) + example } /** What's different about where this build's notes go; null for a normal release. */ -private fun buildNote(): String? = when { - BuildConfig.STATS_URL.isBlank() -> "This build has no stats server, so it sends nothing" - BuildConfig.DEBUG -> "Debug build: notes are kept apart and never counted in the stats" +private fun buildNote(context: Context): String? = when { + BuildConfig.STATS_URL.isBlank() -> context.getString(R.string.usage_no_server) + BuildConfig.DEBUG -> context.getString(R.string.usage_debug_build) else -> null } @@ -197,18 +188,18 @@ internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modif Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Filled.Check, contentDescription = null) Text( - WorkedPrompt.TITLE_START + car, + if (car.isEmpty()) stringResource(R.string.worked_title_unknown) else stringResource(R.string.worked_title, car), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(start = 12.dp), ) } Text( - "Share that so the next driver knows it works? No name, account, or location.", + stringResource(R.string.worked_body), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 8.dp), ) - buildNote()?.let { + buildNote(context)?.let { Text(it, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(top = 4.dp)) } Row( @@ -217,11 +208,11 @@ internal fun WorkedCard(car: String, onSeeWhatsSent: () -> Unit, modifier: Modif horizontalArrangement = Arrangement.spacedBy(8.dp), ) { TextButton(onClick = onSeeWhatsSent, contentPadding = PaddingValues(horizontal = 0.dp)) { - Text("What gets sent") + Text(stringResource(R.string.usage_what_sent)) } Spacer(Modifier.weight(1f)) - OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text("Not now") } - Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text("Share") } + OutlinedButton(onClick = { WorkedPrompt.answer(context, share = false) }) { Text(stringResource(R.string.worked_not_now)) } + Button(onClick = { WorkedPrompt.answer(context, share = true) }) { Text(stringResource(R.string.worked_share)) } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/VoiceModelViews.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/VoiceModelViews.kt index da48810..4987994 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/VoiceModelViews.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/VoiceModelViews.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -35,11 +37,9 @@ internal fun VoiceModelSection() { remember { SpeechModels.init(context) } val selected by SpeechModels.selected.collectAsState() val final by SpeechModels.final.collectAsState() - SettingsSection(title = "Voice: live words") { + SettingsSection(title = stringResource(R.string.voice_live_title)) { Text( - "The voice assistant turns speech into text on this phone, and nothing you say leaves it. " + - "It needs two models, about 270 MB together, downloaded once. " + - "This one writes your words as you speak.", + stringResource(R.string.voice_live_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), @@ -49,10 +49,9 @@ internal fun VoiceModelSection() { ModelRow(model, model == selected) } } - SettingsSection(title = "Voice: final words") { + SettingsSection(title = stringResource(R.string.voice_final_title)) { Text( - "When you stop talking, this model reads the whole phrase again. It catches names and " + - "places the live model misses.", + stringResource(R.string.voice_final_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), @@ -67,9 +66,9 @@ internal fun VoiceModelSection() { ) { RadioButton(final == null, onClick = null, modifier = Modifier.padding(8.dp)) Column { - Text("None", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.voice_none), style = MaterialTheme.typography.titleMedium) Text( - "Keep the live words. Fastest, least accurate", + stringResource(R.string.voice_none_sub), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -95,12 +94,12 @@ private fun ModelRow(model: Model, chosen: Boolean) { Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(chosen, onClick = null, modifier = Modifier.padding(8.dp)) Column(Modifier.weight(1f)) { - Text("${model.label} · ${model.downloadMb} MB", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.voice_model_size, model.label, model.downloadMb), style = MaterialTheme.typography.titleMedium) Text( when (val s = state) { - State.Ready -> "Downloaded. ${model.detail}" - is State.Downloading -> "Downloading, ${s.percent}%" - is State.Failed -> "Download failed: ${s.reason}" + State.Ready -> stringResource(R.string.voice_downloaded, model.detail) + is State.Downloading -> stringResource(R.string.voice_downloading, s.percent) + is State.Failed -> stringResource(R.string.cert_download_failed, s.reason) State.Missing -> model.detail }, style = MaterialTheme.typography.bodySmall, @@ -109,9 +108,9 @@ private fun ModelRow(model: Model, chosen: Boolean) { } Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { when (state) { - State.Ready -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text("Delete") } - is State.Downloading -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text("Cancel") } - else -> OutlinedButton(onClick = { SpeechModels.download(context, model) }) { Text("Download") } + State.Ready -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text(stringResource(R.string.storage_delete)) } + is State.Downloading -> TextButton(onClick = { SpeechModels.delete(context, model) }) { Text(stringResource(R.string.cancel)) } + else -> OutlinedButton(onClick = { SpeechModels.download(context, model) }) { Text(stringResource(R.string.update_download)) } } } } diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WhatsNewScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WhatsNewScreen.kt index 6bdea7b..459444a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WhatsNewScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WhatsNewScreen.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -37,10 +39,10 @@ fun WhatsNewScreen(onBack: () -> Unit) { containerColor = MaterialTheme.colorScheme.background, topBar = { TopAppBar( - title = { Text("What's new") }, + title = { Text(stringResource(R.string.settings_whats_new)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -58,7 +60,7 @@ fun WhatsNewScreen(onBack: () -> Unit) { Changelog.releases.forEach { release -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - if (release.versionCode == BuildConfig.VERSION_CODE) "${release.versionName} (this version)" else release.versionName, + if (release.versionCode == BuildConfig.VERSION_CODE) stringResource(R.string.whats_new_this_version, release.versionName) else release.versionName, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt index 32e6d2b..5f3c9a2 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/ui/WirelessButton.kt @@ -1,5 +1,7 @@ package app.seb3thehacker.gearslip.ui +import androidx.compose.ui.res.stringResource +import app.seb3thehacker.gearslip.R import android.Manifest import android.content.Intent import android.net.Uri @@ -64,7 +66,7 @@ internal fun WirelessButton() { if (denied) { Text( - "Gearslip needs Bluetooth and nearby Wi-Fi access for this.", + stringResource(R.string.wireless_needs_permission), style = MaterialTheme.typography.bodyMedium, ) // Once Android stops asking, the only way back is the app's own permission settings. @@ -77,7 +79,7 @@ internal fun WirelessButton() { } }, modifier = Modifier.padding(bottom = 4.dp), - ) { Text("Enable Nearby devices permission") } + ) { Text(stringResource(R.string.wireless_enable_permission)) } } Button( onClick = { @@ -88,17 +90,17 @@ internal fun WirelessButton() { ) { Box(Modifier.size(28.dp)) { WifiIcon(MaterialTheme.colorScheme.onPrimary) } Spacer(Modifier.width(12.dp)) - Text("Connect wirelessly", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.wireless_connect), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) } devices?.let { list -> AlertDialog( onDismissRequest = { devices = null }, - title = { Text("Which car?") }, + title = { Text(stringResource(R.string.wireless_which_car)) }, text = { Column(Modifier.verticalScroll(rememberScrollState())) { if (list.isEmpty()) { - Text("Your phone isn't paired with anything yet. Pair it with the car first.") + Text(stringResource(R.string.wireless_none_paired)) } list.forEach { device -> Text( @@ -118,7 +120,7 @@ internal fun WirelessButton() { // The car has to be paired before it shows up here, and pairing happens in Android's settings. if (list.isNotEmpty()) { Text( - "Car not listed? Pair it with your phone first.", + stringResource(R.string.wireless_not_listed), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp), @@ -132,9 +134,9 @@ internal fun WirelessButton() { runCatching { context.startActivity(Intent(Settings.ACTION_BLUETOOTH_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - }) { Text("Bluetooth settings") } + }) { Text(stringResource(R.string.bluetooth_settings)) } }, - confirmButton = { TextButton(onClick = { devices = null }) { Text("Cancel") } }, + confirmButton = { TextButton(onClick = { devices = null }) { Text(stringResource(R.string.cancel)) } }, ) } } diff --git a/gearslip/src/main/res/resources.properties b/gearslip/src/main/res/resources.properties new file mode 100644 index 0000000..467b3ef --- /dev/null +++ b/gearslip/src/main/res/resources.properties @@ -0,0 +1 @@ +unqualifiedResLocale=en-US diff --git a/gearslip/src/main/res/values/strings.xml b/gearslip/src/main/res/values/strings.xml index c7c843a..6e96201 100644 --- a/gearslip/src/main/res/values/strings.xml +++ b/gearslip/src/main/res/values/strings.xml @@ -1,4 +1,435 @@ Lets Gearslip replay touches from the car screen onto the phone while mirroring. Gearslip does not read screen content. + + + Help other drivers. + Drive safely. + Gearslip is unofficial. No carmaker made or checked it, and it can fail without warning. Set it up before you go, then keep your eyes on the road. + Microphone. + Gearslip uses it to send your music to the car and to hear you when you ask for something or reply to a message. What you say stays on the phone. + Location. + The car\'s map and nearby searches need it. Without it, the dashboard shows no map. + Calendar. + The car screen shows your next event. Gearslip only reads your calendar. + Calls and contacts. + The car shows who\'s calling by name, and its Phone app can dial a number or call one of your contacts. Gearslip only reads them. + Bluetooth. + The first time you plug in, Gearslip offers to pair your phone with the car, so calls play through its speakers. + Notifications. + While Gearslip is connected, it shows a notification with a button to stop. Without this, Android hides it. + Messages. + The car shows your messages, reads them aloud and lets you reply. Turn Gearslip on in the list this opens. + Turn off Android Auto. + Android Auto is still installed, and it fights Gearslip for the car\'s USB connection. Disable or uninstall it on the screen this opens. + Choose Gearslip. + If your phone asks which app to open when you plug into the car, pick Gearslip and tap Always. + Car audio. + Pick how music reaches the car. Calls go over Bluetooth either way. + Voice. + Ask for directions, music, or a call by voice. Your phone turns speech into text by itself, and nothing you say leaves it. It needs %1$d MB, downloaded once. Use Wi-Fi if you can. + No audio prompt. + Run this from a computer with USB debugging on, and Android trusts Gearslip with your audio for good. Without it, you tap Start once each drive. + Readable notifications. + While audio goes to the car, Android hides what notifications say, as if you were sharing your screen. This lets Gearslip show them during a drive and hide them again after. + Answer calls. + Make Gearslip your caller ID app, and the car can answer and decline calls. Without it, calls still ring, but only the phone can answer them. + Good company. + Your buttons. + Pick how the car\'s buttons look. Change it any time in the car\'s settings. + Open notification access + Open Android Auto\'s app info + Download voice + Set up + Downloading, %1$d%%. You can carry on; it keeps going. + Voice is ready. + The download didn\'t finish. Tap Download voice to try again. + Bluetooth + Recommended + No prompt each drive, and your map app\'s voice reaches the car. + USB + Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound. + OPTIONAL + Already allowed. + Scroll for more ↓ + Allow + Skip for now + Open + Next + Done + Back + + + Turn this on, and Gearslip sends a short note when you open it and after each drive. <b>The note holds no name, account, or location. A random number counts your phone once, and the server keeps no IP address.</b> The notes show which cars work and where the others fail. + Send usage notes + Also share + Android version and system + Phone model + Each car and whether it connected + Each drive and how it ended + What gets sent + Every field, exactly as it goes out + Hide the note + Show the note + Example, not sent: one drive adds this to \"sessions\" + This build has no stats server, so it sends nothing + Debug build: notes are kept apart and never counted in the stats + Gearslip worked with your %1$s + Gearslip worked with your car + Share that so the next driver knows it works? No name, account, or location. + Not now + Share + + + + + These free apps run on Gearslip\'s car screen. Each button opens the app\'s page on GitHub. + Recommended apps + Back + Installed + Maps + Music + Podcasts + Other cool apps + Offline maps and routing. + Turn-by-turn navigation without Google. + A YouTube Music player. + Podcasts, chapters and all. + Spots trackers and cameras nearby. + + + Settings + What\'s new + Read the %1$s release notes on GitHub + Support Gearslip + Gearslip is free, with no ads and no tracking. Your support keeps it going. + Usage notes + Updates + About + Replay the setup guide + Go through the first-run steps again + Connection + Use Gearslip in the car + Gearslip answers when you plug into a car + Off: Android Auto can take the connection instead + Apps + Certificate + Changes take effect on the next connection. + Checking… + Valid until %1$s + Hide details + Show details + Subject + Issuer + Downloading… + Download from aasdk + Certificate downloaded. + Download failed: %1$s + The certificate the open-source aasdk project publishes on GitHub. Gearslip doesn\'t include one + Import my own file + A .p12 file with its private key, from wherever you choose + Remove imported certificate + Remove downloaded certificate + Removed the imported certificate. + Removed the downloaded certificate. + Certificate imported. + Could not import: wrong password, or not a PKCS#12 file with a private key. + Certificate password + Password + Leave empty if the file has none. + Import + + + Cancel + None loaded (self-signed; cars reject it) + Imported: %1$s + Downloaded: %1$s + Staged over adb: %1$s + Self-signed: %1$s + + + Screen + Nav bar and player + Sound and voice + Warnings + More + Normal + Large + Extra large + Follow phone + Light + Dark + Black + Black and colour + Deep colour + Grey + Auto + Day + Night + Imperial + Metric + Always show + Hide while navigating + Hide until tapped + Home + Player on the left + Player on the right + USB audio + Bluetooth audio + Directions on + Directions off + Bluetooth on + Bluetooth off + Location on + Location off + Web page + Experimental features shown + Experimental features hidden + Car + These are the same settings as Settings in the car. A change here shows there too. + Open on connect + Size + App theme + Dark mode + Buttons + Map day/night + Units + Nav bar + Player in the nav bar + Skip buttons + Song title + Player beside the map + Right + Left + Autoplay on connect + More lyrics sources + Also asks BetterLyrics and KuGou, which then get the song\'s name and artist. Off asks LRCLIB only. + Spoken directions + Turn it off if your map app\'s own voice already plays in the car. + Use the phone\'s speech recognition + On most phones that\'s Google\'s, which may send your voice to Google. + Warn when Bluetooth isn\'t connected + Warn when location is off + Experimental features + Vehicle data + Adds a Vehicle data tile: a live dump of the car\'s sensors. The field names are guesses until you\'ve watched one change. + Adaptive video quality + Lowers the picture\'s quality when the car or its Wi-Fi can\'t keep up, and raises it again once it can. Applies on the next connection. + Shown by the Browser app in the car. Empty opens DuckDuckGo. Applies on the next connection. + Car audio + Bluetooth (Recommended) + No prompt each drive, and your map app\'s voice reaches the car + Clearer sound with no delay. Each drive, Android asks you to share your screen, which lets Gearslip send the sound + Run the command in Help once, and USB stops asking. + Calls go over Bluetooth either way. + + + Car screen size + Use the car\'s + Preview size + The size the car preview draws at, such as 1920x720. + Car screen + Gearslip uses the size the car reports unless you set one. + Screen size + From the car + Crop the edges + For a car that cuts off the top and bottom, or the sides + Squash to fit + For a car that squeezes the whole picture, so circles look like ovals + Car preview size + Last car + The size of the last car you connected to + This screen + Fills this device\'s screen, for a head unit running Gearslip itself + Custom + Enter a size + Change the custom size + Width + Height + Save + + + Use this if the car\'s picture looks stretched. Enter the screen\'s real size, such as 1280x480. It takes effect the next time you connect. + %1$s, set by you + + + How to connect + Which cars work + See the full list + Before your first drive + In the car + USB audio + + + Turn off Android Auto. + Disable or uninstall the Android Auto app, so the car talks to Gearslip instead. + Load a certificate. + In Settings, download one or import your own. + Allow the microphone. + Android won\'t let an app pass music to the car without it. Gearslip listens only when you tap the mic or press the talk button on your wheel. + Plug in, or connect wirelessly. + Gearslip opens by itself when the car connects. For wireless, pair the phone with the car first, then tap Connect wirelessly on Home. + Pair over Bluetooth for calls. + Calls play through the car once your phone and the car are paired. Gearslip offers to pair the first time you plug in. + Answer calls from the car screen. + Set Gearslip as your caller ID and spam app. Without it, calls still ring, but only the phone can answer them. + Stop the audio prompt each drive. + Connect the phone to a computer with USB debugging on, and run this once. Skip it and you tap Start each time you set off. + Keep notifications readable. + Android hides what notifications say while it shares audio. Run this too, and Gearslip lifts that only while music goes to the car. + Most cars work. Starting around 2020, some cars added newer security standards that reject Gearslip\'s certificate, and Gearslip can\'t run in those cars. + + + Downloaded. Android asks before it installs. + Install + Download it now. Android asks before it installs. + Try again + Update + Checking… + You have the latest version + Download + Check now + Check automatically + Once a day + + + Unplug from the car to install. Installing closes Gearslip. + Gearslip %1$s is out + Downloading… %1$d%% + Debug builds check only when you tap Check now + Gearslip checks once a day + Version %1$s is out + Version %1$s is ready to install + Version %1$s. %2$s. + + + Give weekly or monthly + Give once or monthly, by card. Buy Me a Coffee keeps 5% + Give once + Any amount, by card + Open your wallet or copy the address + Support with Monero + Send any amount to this address from your Monero wallet. + Open in wallet + Copy address + Close + Join the chat on Matrix + Talk with other drivers about cars, apps, and Gearslip + Source code + Read or change Gearslip\'s code under the AGPL + Voice credits + + + No Monero wallet app found. Copy the address instead. + Monero address copied + Speech recognition by sherpa-onnx (Apache 2.0), with Kroko ASR by Banafo (CC BY-SA 4.0), and Parakeet and Canary by NVIDIA (CC BY 4.0) + + + Certificate Setup + Certificate installed. + Continue + Download failed + Retry + A .p12 file with its private key, from wherever you choose. + Skip car connection setup + + + Cars accept only Google-issued certificates, and you have one loaded. + Cars accept only Google-issued certificates. Gearslip doesn\'t include one, so choose where yours comes from. + The open-source aasdk project publishes this certificate at github.com/opencardev/aasdk. Most cars accept it; some from around 2020 on don\'t. + + + Gearslip storage + Measuring… + Lyrics + Clear + Voice models + Remove + Old logs + Other temporary files + Delete all Gearslip data + + + Gearslip\'s data takes %1$s of the phone\'s storage. + + %1$d song · %2$s. Cleared lyrics download again when you open them. + %1$d songs · %2$s. Cleared lyrics download again when you open them. + + %1$s. Voice commands need them; download them again in Settings. + %1$s. Logs from earlier launches; this launch\'s log stays. + %1$s. Downloaded updates and other files Gearslip can make again. + Tap Delete again to erase settings, certificates, voice models and logs. Gearslip closes, and starts from setup next time. + Settings, certificates, voice models, lyrics and logs. + Delete + Delete… + + + Glad that drive worked + Support on Liberapay + Buy me a coffee + Give once by card + + + Gearslip is free, with no ads and no tracking. If it gets you on the road, your support keeps it going. + + + Gearslip needs Bluetooth and nearby Wi-Fi access for this. + Enable Nearby devices permission + Connect wirelessly + Which car? + Your phone isn\'t paired with anything yet. Pair it with the car first. + Car not listed? Pair it with your phone first. + Bluetooth settings + + + Voice: live words + Voice: final words + None + Keep the live words. Fastest, least accurate + + + The voice assistant turns speech into text on this phone, and nothing you say leaves it. It needs two models, about 270 MB together, downloaded once. This one writes your words as you speak. + When you stop talking, this model reads the whole phrase again. It catches names and places the live model misses. + %1$s · %2$d MB + Downloaded. %1$s + Downloading, %1$d%% + + + Help + Preview + Logs + Disconnect + Bluetooth isn\'t connected + + + %1$s · debug build + Music plays over Bluetooth, but your phone isn\'t connected to the car. Connect it here or from the car\'s Bluetooth menu. + Downloading voice, %1$d%% + + + Play car audio over Bluetooth? + Never ask, using a computer + Use Bluetooth + Keep asking + Car audio needs the microphone permission + Open app settings + + + Android treats music sent over USB like sharing your screen, so it asks every drive. Over Bluetooth it never asks. You can change this later in Settings. + Or keep USB and stop the question for good: plug the phone into a computer with USB debugging on, and run this once. + Android lets an app send other apps\' sound to the car only if it has the microphone permission. It\'s turned off for Gearslip, so music plays on the phone. Turn it on under Permissions, then Microphone. + + + Close the car keyboard + Type for the car screen + Search on the car screen + + + Tap to copy + + + Command copied + + + %1$s (this version)