From fb1bdb20446dc52de5bb0dde4df055c330fd4a84 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sat, 14 Feb 2026 13:56:41 -0600 Subject: [PATCH] feat(settings): Only show homoglyph setting for Cyrillic locales (#4559) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../firmware/ota/BleOtaTransportErrorTest.kt | 80 +++++++++++-------- feature/settings/build.gradle.kts | 10 ++- .../feature/settings/SettingsScreen.kt | 27 +++++-- .../feature/settings/HomoglyphSettingTest.kt | 68 ++++++++++++++++ 4 files changed, 144 insertions(+), 41 deletions(-) create mode 100644 feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt diff --git a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportErrorTest.kt b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportErrorTest.kt index 9ad49d93e..8e95d21e4 100644 --- a/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportErrorTest.kt +++ b/feature/firmware/src/test/kotlin/org/meshtastic/feature/firmware/ota/BleOtaTransportErrorTest.kt @@ -106,13 +106,15 @@ class BleOtaTransportErrorTest { centralManager.simulatePeripherals(listOf(otaPeripheral)) val transport = BleOtaTransport(centralManager, address, testDispatcher) - transport.connect().getOrThrow() + try { + transport.connect().getOrThrow() - val result = transport.startOta(1024, "badhash") {} - assertTrue(result.isFailure) - assertTrue(result.exceptionOrNull() is OtaProtocolException.HashRejected) - - transport.close() + val result = transport.startOta(1024, "badhash") {} + assertTrue(result.isFailure) + assertTrue(result.exceptionOrNull() is OtaProtocolException.HashRejected) + } finally { + transport.close() + } } @Test @@ -166,25 +168,27 @@ class BleOtaTransportErrorTest { centralManager.simulatePeripherals(listOf(otaPeripheral)) val transport = BleOtaTransport(centralManager, address, testDispatcher) - transport.connect().getOrThrow() - transport.startOta(1024, "hash") {}.getOrThrow() + try { + transport.connect().getOrThrow() + transport.startOta(1024, "hash") {}.getOrThrow() - // Find the connected peripheral and disconnect it - // We use isBonded=true to ensure it shows up in getBondedPeripherals() - val peripheral = centralManager.getBondedPeripherals().first { it.address == address } - peripheral.disconnect() + // Find the connected peripheral and disconnect it + // We use isBonded=true to ensure it shows up in getBondedPeripherals() + val peripheral = centralManager.getBondedPeripherals().first { it.address == address } + peripheral.disconnect() - // Wait for state propagation - delay(100.milliseconds) + // Wait for state propagation + delay(100.milliseconds) - val data = ByteArray(1024) { it.toByte() } - val result = transport.streamFirmware(data, 512) {} + val data = ByteArray(1024) { it.toByte() } + val result = transport.streamFirmware(data, 512) {} - assertTrue("Should fail due to connection loss", result.isFailure) - assertTrue(result.exceptionOrNull() is OtaProtocolException.TransferFailed) - assertTrue(result.exceptionOrNull()?.message?.contains("Connection lost") == true) - - transport.close() + assertTrue("Should fail due to connection loss", result.isFailure) + assertTrue(result.exceptionOrNull() is OtaProtocolException.TransferFailed) + assertTrue(result.exceptionOrNull()?.message?.contains("Connection lost") == true) + } finally { + transport.close() + } } @Test @@ -245,21 +249,27 @@ class BleOtaTransportErrorTest { centralManager.simulatePeripherals(listOf(otaPeripheral)) val transport = BleOtaTransport(centralManager, address, testDispatcher) - transport.connect().getOrThrow() - transport.startOta(1024, "hash") {}.getOrThrow() + try { + transport.connect().getOrThrow() + transport.startOta(1024, "hash") {}.getOrThrow() - // Setup final response to be a Hash Mismatch error after chunks are sent - backgroundScope.launch { - delay(1000.milliseconds) - otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Mismatch\n".toByteArray()) + // Setup final response to be a Hash Mismatch error after chunks are sent + backgroundScope.launch { + delay(1000.milliseconds) + otaPeripheral.simulateValueUpdate(txCharHandle, "ERR Hash Mismatch\n".toByteArray()) + } + + val data = ByteArray(1024) { it.toByte() } + val result = transport.streamFirmware(data, 512) {} + + val exception = result.exceptionOrNull() + assertTrue("Expected failure, but succeeded", result.isFailure) + assertTrue( + "Expected OtaProtocolException.VerificationFailed but got $exception", + exception is OtaProtocolException.VerificationFailed, + ) + } finally { + transport.close() } - - val data = ByteArray(1024) { it.toByte() } - val result = transport.streamFirmware(data, 512) {} - - assertTrue("Should fail due to hash mismatch, but got ${result.exceptionOrNull()}", result.isFailure) - assertTrue(result.exceptionOrNull() is OtaProtocolException.VerificationFailed) - - transport.close() } } diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index d6d4cf528..55da3191a 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -23,7 +23,10 @@ plugins { alias(libs.plugins.meshtastic.hilt) } -configure { namespace = "org.meshtastic.feature.settings" } +configure { + namespace = "org.meshtastic.feature.settings" + testOptions { unitTests { isIncludeAndroidResources = true } } +} dependencies { implementation(projects.core.common) @@ -51,6 +54,11 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.kermit) + testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) + testImplementation(libs.androidx.test.ext.junit) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.ext.junit) } diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt index 772a899e9..4a14a0404 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/SettingsScreen.kt @@ -25,6 +25,7 @@ import android.provider.Settings import android.provider.Settings.ACTION_APP_LOCALE_SETTINGS import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AppCompatActivity.RESULT_OK import androidx.appcompat.app.AppCompatDelegate import androidx.compose.foundation.layout.Column @@ -53,9 +54,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.net.toUri +import androidx.core.os.ConfigurationCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -313,11 +316,10 @@ fun SettingsScreen( val homoglyphEncodingEnabled by viewModel.homoglyphEncodingEnabledFlow.collectAsStateWithLifecycle(false) - SwitchListItem( - text = stringResource(Res.string.use_homoglyph_characters_encoding), - checked = homoglyphEncodingEnabled, - leadingIcon = Icons.Default.Abc, - onClick = { viewModel.toggleHomoglyphCharactersEncodingEnabled() }, + + HomoglyphSetting( + homoglyphEncodingEnabled = homoglyphEncodingEnabled, + onToggle = { viewModel.toggleHomoglyphCharactersEncodingEnabled() }, ) val settingsLauncher = @@ -535,3 +537,18 @@ private fun ThemePickerDialog(onClickTheme: (Int) -> Unit, onDismiss: () -> Unit }, ) } + +@VisibleForTesting +@Composable +fun HomoglyphSetting(homoglyphEncodingEnabled: Boolean, onToggle: () -> Unit) { + val currentLocale = ConfigurationCompat.getLocales(LocalConfiguration.current).get(0) + val supportedLanguages = listOf("ru", "uk", "be", "bg", "sr", "mk", "kk", "ky", "tg", "mn") + if (currentLocale?.language in supportedLanguages) { + SwitchListItem( + text = stringResource(Res.string.use_homoglyph_characters_encoding), + checked = homoglyphEncodingEnabled, + leadingIcon = Icons.Default.Abc, + onClick = onToggle, + ) + } +} diff --git a/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt new file mode 100644 index 000000000..62e7da340 --- /dev/null +++ b/feature/settings/src/test/kotlin/org/meshtastic/feature/settings/HomoglyphSettingTest.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.settings + +import android.content.res.Configuration +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import com.meshtastic.core.strings.getString +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.use_homoglyph_characters_encoding +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Locale + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HomoglyphSettingTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun homoglyphSetting_isVisible_forRussianLocale() { + val russianConfig = Configuration().apply { setLocale(Locale.forLanguageTag("ru")) } + + composeTestRule.setContent { + CompositionLocalProvider(LocalConfiguration provides russianConfig) { + HomoglyphSetting(homoglyphEncodingEnabled = false, onToggle = {}) + } + } + + val expectedText = getString(Res.string.use_homoglyph_characters_encoding) + composeTestRule.onNodeWithText(expectedText).assertIsDisplayed() + } + + @Test + fun homoglyphSetting_isNotVisible_forEnglishLocale() { + val englishConfig = Configuration().apply { setLocale(Locale.forLanguageTag("en")) } + + composeTestRule.setContent { + CompositionLocalProvider(LocalConfiguration provides englishConfig) { + HomoglyphSetting(homoglyphEncodingEnabled = false, onToggle = {}) + } + } + + val expectedText = getString(Res.string.use_homoglyph_characters_encoding) + composeTestRule.onNodeWithText(expectedText).assertDoesNotExist() + } +}