From 67167de2daacfa237e0873e60af469078fc4d53c Mon Sep 17 00:00:00 2001 From: MM20 <15646950+MM2-0@users.noreply.github.com> Date: Mon, 15 Dec 2025 23:00:40 +0100 Subject: [PATCH] Add transliteration support for non-latin scripts --- app/app/build.gradle.kts | 1 + .../de/mm20/launcher2/LauncherApplication.kt | 2 + .../launcher2/ui/launcher/search/SearchVM.kt | 4 +- .../launcher/sheets/EditFavoritesSheetVM.kt | 9 +- .../ui/launcher/sheets/WidgetPickerSheetVM.kt | 42 +++++-- .../settings/locale/LocaleSettingsScreen.kt | 74 +++++++++++- .../settings/locale/LocaleSettingsScreenVM.kt | 5 + .../media/MediaIntegrationSettingsScreenVM.kt | 14 ++- .../searchactions/EditSearchActionSheetVM.kt | 14 ++- .../de/mm20/launcher2/search/ResultScore.kt | 22 ++-- .../launcher2/search/SavableSearchable.kt | 3 +- .../mm20/launcher2/search/StringNormalizer.kt | 5 + core/i18n/src/main/res/values-de/strings.xml | 1 + core/i18n/src/main/res/values/strings.xml | 3 + .../main/java/de/mm20/launcher2/ktx/String.kt | 4 +- .../preferences/LauncherSettingsData.kt | 5 + .../preferences/ui/LocaleSettings.kt | 9 ++ .../launcher2/applications/AppRepository.kt | 14 ++- .../applications/AppSerialization.kt | 1 + .../launcher2/applications/LauncherApp.kt | 23 ++-- .../de/mm20/launcher2/applications/Module.kt | 2 +- .../appshortcuts/AppShortcutConfigActivity.kt | 3 +- .../appshortcuts/AppShortcutRepository.kt | 27 ++--- .../de/mm20/launcher2/appshortcuts/Module.kt | 2 +- data/i18n/.gitignore | 1 + data/i18n/build.gradle.kts | 52 ++++++++ data/i18n/consumer-rules.pro | 0 data/i18n/proguard-rules.pro | 21 ++++ data/i18n/src/main/AndroidManifest.xml | 4 + .../launcher2/data/CompatStringNormalizer.kt | 17 +++ .../launcher2/data/IcuStringNormalizer.kt | 113 ++++++++++++++++++ .../java/de/mm20/launcher2/data/Module.kt | 13 ++ .../openstreetmaps/OsmLocationProvider.kt | 2 +- gradle/libs.versions.toml | 2 +- settings.gradle.kts | 1 + 35 files changed, 443 insertions(+), 72 deletions(-) create mode 100644 core/base/src/main/java/de/mm20/launcher2/search/StringNormalizer.kt create mode 100644 data/i18n/.gitignore create mode 100644 data/i18n/build.gradle.kts create mode 100644 data/i18n/consumer-rules.pro create mode 100644 data/i18n/proguard-rules.pro create mode 100644 data/i18n/src/main/AndroidManifest.xml create mode 100644 data/i18n/src/main/java/de/mm20/launcher2/data/CompatStringNormalizer.kt create mode 100644 data/i18n/src/main/java/de/mm20/launcher2/data/IcuStringNormalizer.kt create mode 100644 data/i18n/src/main/java/de/mm20/launcher2/data/Module.kt diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 1418b14b9..ffac37e6d 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -130,6 +130,7 @@ dependencies { implementation(project(":data:searchable")) implementation(project(":data:plugins")) implementation(project(":data:themes")) + implementation(project(":data:i18n")) implementation(project(":data:files")) implementation(project(":core:i18n")) implementation(project(":services:icons")) diff --git a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt index 4d5503bc2..2a7f86f67 100644 --- a/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt +++ b/app/app/src/main/java/de/mm20/launcher2/LauncherApplication.kt @@ -13,6 +13,7 @@ import de.mm20.launcher2.calculator.calculatorModule import de.mm20.launcher2.calendar.calendarModule import de.mm20.launcher2.contacts.contactsModule import de.mm20.launcher2.data.customattrs.customAttrsModule +import de.mm20.launcher2.data.i18nDataModule import de.mm20.launcher2.searchable.searchableModule import de.mm20.launcher2.files.filesModule import de.mm20.launcher2.icons.iconsModule @@ -96,6 +97,7 @@ class LauncherApplication : Application(), CoroutineScope, ImageLoaderFactory { backupModule, devicePoseModule, profilesModule, + i18nDataModule, ) ) } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt index 9f06f2110..9e594e892 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/search/SearchVM.kt @@ -424,13 +424,13 @@ class SearchVM : ViewModel(), KoinComponent { val bWeight = weights[b.key] ?: 0.0 val aScore = if (a.score.isUnspecified) { - ResultScore(query = query, primaryFields = listOf(a.labelOverride ?: a.label)).score + ResultScore.from(query = query, primaryFields = listOf(a.labelOverride ?: a.label)).score } else { a.score.score } val bScore = if (b.score.isUnspecified) { - ResultScore(query = query, primaryFields = listOf(b.labelOverride ?: b.label)).score + ResultScore.from(query = query, primaryFields = listOf(b.labelOverride ?: b.label)).score } else { b.score.score } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt index d2e9c4802..d835256e2 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/EditFavoritesSheetVM.kt @@ -14,7 +14,6 @@ import de.mm20.launcher2.badges.BadgeService import de.mm20.launcher2.data.customattrs.CustomAttributesRepository import de.mm20.launcher2.icons.IconService import de.mm20.launcher2.icons.LauncherIcon -import de.mm20.launcher2.ktx.normalize import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.search.SavableSearchable @@ -32,6 +31,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.text.Collator class EditFavoritesSheetVM : ViewModel(), KoinComponent { @@ -77,12 +77,17 @@ class EditFavoritesSheetVM : ViewModel(), KoinComponent { includeTypes = listOf("tag"), minPinnedLevel = PinnedLevel.AutomaticallySorted, ).first().filterIsInstance().toMutableList() + + val collator = Collator.getInstance().apply { strength = Collator.SECONDARY } + availableTags.value = customAttributesRepository .getAllTags() .first() .filter {t -> pinnedTags.none { it.tag == t } } - .sortedBy { it.normalize() } + .sortedWith { el1, el2 -> + collator.compare(el1, el2) + } .map { Tag(it) } this.pinnedTags.value = pinnedTags diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/WidgetPickerSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/WidgetPickerSheetVM.kt index 7ad542af2..3605d35aa 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/WidgetPickerSheetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/launcher/sheets/WidgetPickerSheetVM.kt @@ -10,7 +10,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory -import de.mm20.launcher2.ktx.normalize +import de.mm20.launcher2.search.ResultScore +import de.mm20.launcher2.search.StringNormalizer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -22,10 +23,12 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext import org.koin.core.component.KoinComponent import org.koin.core.component.get +import java.text.Collator class WidgetPickerSheetVM( private val widgetsService: WidgetsService, private val packageManager: PackageManager, + private val stringNormalizer: StringNormalizer, ) : ViewModel() { val searchQuery = MutableStateFlow("") @@ -41,9 +44,15 @@ class WidgetPickerSheetVM( .combine(searchQuery) { widgets, query -> if (query.isBlank()) return@combine widgets withContext(Dispatchers.IO) { - val normalizedQuery = query.normalize() + val normalizedQuery = stringNormalizer.normalize(query) widgets.filter { - it.label.normalize().contains(normalizedQuery) + ResultScore.from( + query = normalizedQuery, + primaryFields = listOf( + stringNormalizer.normalize(it.label), + it.type + ) + ).score >= 0.8f } } }.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100)) @@ -57,9 +66,10 @@ class WidgetPickerSheetVM( .combine(searchQuery) { widgets, query -> if (query.isBlank()) return@combine widgets withContext(Dispatchers.IO) { - val normalizedQuery = query.normalize() + val normalizedQuery = stringNormalizer.normalize(query) widgets.filter { - if (it.loadLabel(packageManager).normalize().contains(normalizedQuery)) { + val widgetNormalizedLabel = stringNormalizer.normalize(it.loadLabel(packageManager)) + if (widgetNormalizedLabel.contains(normalizedQuery)) { return@filter true } val pkg = it.provider.packageName @@ -68,8 +78,15 @@ class WidgetPickerSheetVM( } catch (e: PackageManager.NameNotFoundException) { return@filter false } - appInfo.loadLabel(packageManager).toString().normalize() - .contains(normalizedQuery) + val normalizedAppLabel = stringNormalizer.normalize(appInfo.loadLabel(packageManager).toString()) + + ResultScore.from( + query = normalizedQuery, + primaryFields = listOf( + widgetNormalizedLabel, + normalizedAppLabel, + ) + ).score >= 0.8f } } } @@ -80,9 +97,12 @@ class WidgetPickerSheetVM( } val appWidgetGroups = filteredAppWidgets.map { widgets -> + val collator = Collator.getInstance().apply { strength = Collator.SECONDARY } withContext(Dispatchers.Default) { widgets - .sortedBy { it.loadLabel(packageManager).normalize() } + .sortedWith { el1, el2 -> + collator.compare(el1.loadLabel(packageManager), el2.loadLabel(packageManager)) + } .groupBy { it.provider.packageName } @@ -95,7 +115,9 @@ class WidgetPickerSheetVM( } AppWidgetGroup(appInfo.loadLabel(packageManager).toString(), pkg, it.value) } - .sortedBy { it.appName.normalize() } + .sortedWith { el1, el2 -> + collator.compare(el1.appName, el2.appName) + } } }.shareIn(viewModelScope, SharingStarted.WhileSubscribed(100)) @@ -112,7 +134,7 @@ class WidgetPickerSheetVM( companion object : KoinComponent { val Factory = viewModelFactory { initializer { - WidgetPickerSheetVM(get(), get()) + WidgetPickerSheetVM(get(), get(), get()) } } } diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreen.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreen.kt index ed280120c..810303828 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreen.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreen.kt @@ -1,13 +1,19 @@ package de.mm20.launcher2.ui.settings.locale import android.content.Intent +import android.icu.text.Transliterator +import android.icu.util.ULocale +import android.util.Log import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.toLowerCase +import androidx.compose.ui.text.toUpperCase import androidx.core.app.GrammaticalInflectionManagerCompat import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -23,6 +29,7 @@ import de.mm20.launcher2.ui.component.preferences.Preference import de.mm20.launcher2.ui.component.preferences.PreferenceCategory import de.mm20.launcher2.ui.component.preferences.PreferenceScreen import kotlinx.serialization.Serializable +import java.util.Locale @Serializable data object LocaleSettingsRoute : NavKey @@ -30,18 +37,72 @@ data object LocaleSettingsRoute : NavKey @Composable fun LocaleSettingsScreen() { val context = LocalContext.current + val resources = LocalResources.current val viewModel: LocaleSettingsScreenVM = viewModel() val timeFormat by viewModel.timeFormat.collectAsStateWithLifecycle(null) val measurementSystem by viewModel.measurementSystem.collectAsStateWithLifecycle(null) + val transliterator by viewModel.transliterator.collectAsStateWithLifecycle(null) // The language that has been selected by the user, or null to use the system language val selectedLocale = remember { AppCompatDelegate.getApplicationLocales().get(0) } + val locales = LocalResources.current.configuration?.locales + // The current language, including the resolved system language - val currentLocale = LocalResources.current.configuration?.locales[0] + val currentLocale = locales?.get(0) + + val transliterators: List> = remember(locales) { + if (!isAtLeastApiLevel(29)) return@remember listOf() + + if (locales?.isEmpty == true) return@remember listOf("Disabled" to null) + + val scripts = mutableSetOf() + val languages = mutableSetOf() + + val transliterators = mutableMapOf( + null to resources.getString(R.string.preference_transliteration_disabled), + "" to resources.getString(R.string.preference_transliteration_auto), + ) + + val availableIds = Transliterator.getAvailableIDs().toList() + + for (i in 0.. 2) { + ListPreference( + icon = R.drawable.translate_24px, + title = stringResource(R.string.preference_transliteration), + items = transliterators, + value = transliterator, + onValueChanged = { + viewModel.setTransliterator(it) + }, + ) + } } } item { diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreenVM.kt index cfe4469fd..8adbcbdc8 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/locale/LocaleSettingsScreenVM.kt @@ -19,4 +19,9 @@ class LocaleSettingsScreenVM: ViewModel(), KoinComponent { fun setMeasurementSystem(measurementSystem: MeasurementSystem) { localeSettings.setMeasurementSystem(measurementSystem) } + + val transliterator = localeSettings.transliterator + fun setTransliterator(transliterator: String?) { + localeSettings.setTransliterator(transliterator) + } } \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt index 2d6181300..cc82a22ba 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/media/MediaIntegrationSettingsScreenVM.kt @@ -8,7 +8,6 @@ import androidx.lifecycle.viewModelScope import de.mm20.launcher2.applications.AppRepository import de.mm20.launcher2.icons.IconService import de.mm20.launcher2.icons.LauncherIcon -import de.mm20.launcher2.ktx.normalize import de.mm20.launcher2.music.MusicService import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager @@ -21,6 +20,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import java.text.Collator import kotlin.math.roundToInt class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent { @@ -68,7 +68,7 @@ class MediaIntegrationSettingsScreenVM : ViewModel(), KoinComponent { icon = iconService.getIcon(it, (32 * density).roundToInt()) .shareIn(viewModelScope, SharingStarted.WhileSubscribed(10000)) ) - }.sortedBy { it.label.normalize() } + }.sorted() loading.value = false } } @@ -104,4 +104,12 @@ data class AppListItem( val isMusicApp: Boolean, val isChecked: Boolean, val icon: Flow, -) \ No newline at end of file +): Comparable { + override fun compareTo(other: AppListItem): Int { + val label1 = label + val label2 = other.label + return Collator.getInstance().apply { strength = Collator.SECONDARY } + .compare(label1, label2) + } + +} \ No newline at end of file diff --git a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt index 50ebd478c..8e8096b3b 100644 --- a/app/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt +++ b/app/ui/src/main/java/de/mm20/launcher2/ui/settings/searchactions/EditSearchActionSheetVM.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.unit.dp import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import de.mm20.launcher2.ktx.romanize import de.mm20.launcher2.searchactions.SearchActionService import de.mm20.launcher2.searchactions.actions.SearchActionIcon import de.mm20.launcher2.searchactions.builders.AppSearchActionBuilder @@ -25,6 +24,7 @@ import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.io.File +import java.text.Collator import kotlin.math.roundToInt class EditSearchActionSheetVM : ViewModel(), KoinComponent { @@ -57,7 +57,7 @@ class EditSearchActionSheetVM : ViewModel(), KoinComponent { .loadLabel(context.packageManager).toString(), componentName = it ) - }.sortedBy { it.label.romanize().lowercase() } + }.sorted() emit(items) } @@ -611,4 +611,12 @@ enum class EditSearchActionPage { data class SearchableApp( val label: String, val componentName: ComponentName, -) \ No newline at end of file +): Comparable { + override fun compareTo(other: SearchableApp): Int { + val label1 = label + val label2 = other.label + return Collator.getInstance().apply { strength = Collator.SECONDARY } + .compare(label1, label2) + } + +} \ No newline at end of file diff --git a/core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt b/core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt index 1ab08d667..f998dfd94 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/ResultScore.kt @@ -1,7 +1,6 @@ package de.mm20.launcher2.search import com.aallam.similarity.JaroWinkler -import de.mm20.launcher2.ktx.normalize @JvmInline value class ResultScore private constructor(private val packed: Long) : Comparable { @@ -53,29 +52,26 @@ value class ResultScore private constructor(private val packed: Long) : Comparab } companion object { - operator fun invoke( + fun from( query: String, primaryFields: Iterable = emptyList(), secondaryFields: Iterable = emptyList(), ): ResultScore { - val normalizedQuery = query.normalize() val jaroWinkler = JaroWinkler() - val bestPrimaryScore = primaryFields.maxOfOrNull { - val normalizedTerm = it.normalize() - val sim = jaroWinkler.similarity(normalizedQuery, normalizedTerm).toFloat() + val bestPrimaryScore = primaryFields.maxOfOrNull { term -> + val sim = jaroWinkler.similarity(query, term).toFloat() ResultScore( - isPrefix = normalizedTerm.startsWith(normalizedQuery), - isSubstring = normalizedQuery in normalizedTerm, + isPrefix = term.startsWith(query), + isSubstring = query in term, isPrimary = true, similarity = sim ) } ?: Zero - val bestSecondaryScore = secondaryFields.maxOfOrNull { - val normalizedTerm = it.normalize() - val sim = jaroWinkler.similarity(normalizedQuery, normalizedTerm).toFloat() + val bestSecondaryScore = secondaryFields.maxOfOrNull { term -> + val sim = jaroWinkler.similarity(query, term).toFloat() ResultScore( - isPrefix = normalizedTerm.startsWith(normalizedQuery), - isSubstring = normalizedQuery in normalizedTerm, + isPrefix = term.startsWith(query), + isSubstring = query in term, isPrimary = false, similarity = sim ) diff --git a/core/base/src/main/java/de/mm20/launcher2/search/SavableSearchable.kt b/core/base/src/main/java/de/mm20/launcher2/search/SavableSearchable.kt index c6448c926..c8522cc82 100644 --- a/core/base/src/main/java/de/mm20/launcher2/search/SavableSearchable.kt +++ b/core/base/src/main/java/de/mm20/launcher2/search/SavableSearchable.kt @@ -5,7 +5,6 @@ import android.graphics.drawable.Drawable import android.os.Bundle import de.mm20.launcher2.icons.LauncherIcon import de.mm20.launcher2.icons.StaticLauncherIcon -import de.mm20.launcher2.ktx.romanize import java.text.Collator interface SavableSearchable : Searchable, Comparable { @@ -38,7 +37,7 @@ interface SavableSearchable : Searchable, Comparable { val label1 = labelOverride ?: label val label2 = other.labelOverride ?: other.label return Collator.getInstance().apply { strength = Collator.SECONDARY } - .compare(label1.romanize(), label2.romanize()) + .compare(label1, label2) } val domain: String diff --git a/core/base/src/main/java/de/mm20/launcher2/search/StringNormalizer.kt b/core/base/src/main/java/de/mm20/launcher2/search/StringNormalizer.kt new file mode 100644 index 000000000..5c0642dad --- /dev/null +++ b/core/base/src/main/java/de/mm20/launcher2/search/StringNormalizer.kt @@ -0,0 +1,5 @@ +package de.mm20.launcher2.search + +interface StringNormalizer { + fun normalize(input: String): String +} \ No newline at end of file diff --git a/core/i18n/src/main/res/values-de/strings.xml b/core/i18n/src/main/res/values-de/strings.xml index 234829570..bf53864e8 100644 --- a/core/i18n/src/main/res/values-de/strings.xml +++ b/core/i18n/src/main/res/values-de/strings.xml @@ -878,4 +878,5 @@ Metrisch Vereinigtes Königreich Vereinigte Staaten + Bevorzugte Transliteration diff --git a/core/i18n/src/main/res/values/strings.xml b/core/i18n/src/main/res/values/strings.xml index e1cbbed8a..4573d9707 100644 --- a/core/i18n/src/main/res/values/strings.xml +++ b/core/i18n/src/main/res/values/strings.xml @@ -1067,4 +1067,7 @@ Metric United Kingdom United States + Preferred transliteration + Automatic + Disabled diff --git a/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt b/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt index 016a128e2..c3f8b0e6f 100644 --- a/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt +++ b/core/ktx/src/main/java/de/mm20/launcher2/ktx/String.kt @@ -15,7 +15,7 @@ fun String.decodeUrl(charset: String): String? { * Characters must be normalized independently so that * A.contains(B) -> A.normalize().contains(B.normalize()) is true. */ -fun String.normalize(): String { +/*fun String.normalize(): String { return StringUtils.stripAccents(this.romanize().lowercase(Locale.getDefault())) .replace("æ", "ae") .replace("œ", "oe") @@ -29,7 +29,7 @@ fun String.normalize(): String { */ fun String.romanize(): String { return Pinyin.toPinyin(this, "") -} +}*/ fun String.stripStartOrNull(s: String): String? = if (startsWith(s)) removePrefix(s) else null diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt index c0369280f..f703a6cac 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/LauncherSettingsData.kt @@ -194,6 +194,11 @@ data class LauncherSettingsData internal constructor( @JsonNames("clockWidgetTimeFormat") val localeTimeFormat: TimeFormat = TimeFormat.System, val localeMeasurementSystem: MeasurementSystem = MeasurementSystem.System, + /** + * The ID of the transliterator to use. The empty string means to pick a transliterator + * automatically. null disables the transliterator. + */ + val localeTransliterator: String? = "", ) { diff --git a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/LocaleSettings.kt b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/LocaleSettings.kt index d9772ae3f..3ef6f58ce 100644 --- a/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/LocaleSettings.kt +++ b/core/preferences/src/main/java/de/mm20/launcher2/preferences/ui/LocaleSettings.kt @@ -26,4 +26,13 @@ class LocaleSettings internal constructor( it.copy(localeMeasurementSystem = measurementSystem) } } + + val transliterator + get() = launcherDataStore.data.map { it.localeTransliterator } + + fun setTransliterator(transliterator: String?) { + launcherDataStore.update { + it.copy(localeTransliterator = transliterator) + } + } } \ No newline at end of file diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt index 73c311366..2bf3688e6 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/AppRepository.kt @@ -10,12 +10,12 @@ import android.os.Handler import android.os.Looper import android.os.Process import android.os.UserHandle -import de.mm20.launcher2.ktx.normalize import de.mm20.launcher2.profiles.Profile import de.mm20.launcher2.profiles.ProfileManager import de.mm20.launcher2.search.Application import de.mm20.launcher2.search.ResultScore import de.mm20.launcher2.search.SearchableRepository +import de.mm20.launcher2.search.StringNormalizer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -30,8 +30,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import org.apache.commons.text.similarity.FuzzyScore -import java.util.Locale interface AppRepository : SearchableRepository { fun findOne( @@ -45,6 +43,7 @@ interface AppRepository : SearchableRepository { internal class AppRepositoryImpl( private val context: Context, private val profileManager: ProfileManager, + private val stringNormalizer: StringNormalizer, ) : AppRepository { private val scope = CoroutineScope(Dispatchers.Default + Job()) @@ -240,6 +239,7 @@ internal class AppRepositoryImpl( } override fun search(query: String, allowNetwork: Boolean): Flow> { + val normalizedQuery = stringNormalizer.normalize(query) return installedApps.map { apps -> withContext(Dispatchers.Default) { val appResults = mutableListOf() @@ -247,9 +247,11 @@ internal class AppRepositoryImpl( appResults.addAll(apps) } else { appResults.addAll(apps.mapNotNull { - val score = ResultScore( - query = query, - primaryFields = listOf(it.label), + val score = ResultScore.from( + query = normalizedQuery, + primaryFields = listOf( + stringNormalizer.normalize(it.label) + ), ) if (score.score < 0.8f) return@mapNotNull null it.copy( diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt index f468fc5a4..a5c3a59b8 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/AppSerialization.kt @@ -12,6 +12,7 @@ import de.mm20.launcher2.ktx.isAtLeastApiLevel import de.mm20.launcher2.search.SavableSearchable import de.mm20.launcher2.search.SearchableDeserializer import de.mm20.launcher2.search.SearchableSerializer +import de.mm20.launcher2.search.StringNormalizer import org.json.JSONObject internal class LockedPrivateProfileAppSerializer : SearchableSerializer { diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt index 7eb6760cb..4e2b97747 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/LauncherApp.kt @@ -1,6 +1,5 @@ package de.mm20.launcher2.applications -import android.app.admin.DevicePolicyManager import android.content.ActivityNotFoundException import android.content.ComponentName import android.content.Context @@ -33,7 +32,6 @@ import de.mm20.launcher2.search.SearchableSerializer import de.mm20.launcher2.search.StoreLink import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import kotlin.math.roundToInt internal data class LauncherApp( private val launcherActivityInfo: LauncherActivityInfo, @@ -50,9 +48,16 @@ internal data class LauncherApp( override val label: String = launcherActivityInfo.label.toString() - constructor(context: Context, launcherActivityInfo: LauncherActivityInfo, score: ResultScore = ResultScore.Unspecified) : this( + constructor( + context: Context, + launcherActivityInfo: LauncherActivityInfo, + score: ResultScore = ResultScore.Unspecified, + ) : this( launcherActivityInfo, - versionName = getPackageVersionName(context, launcherActivityInfo.applicationInfo.packageName), + versionName = getPackageVersionName( + context, + launcherActivityInfo.applicationInfo.packageName + ), isSuspended = launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SUSPENDED != 0, userSerialNumber = launcherActivityInfo.user.getSerialNumber(context), score = score, @@ -63,7 +68,8 @@ internal data class LauncherApp( private val isMainProfile = launcherActivityInfo.user == Process.myUserHandle() - private val isSystemApp: Boolean = launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 + private val isSystemApp: Boolean = + launcherActivityInfo.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM != 0 override val canUninstall: Boolean get() = !isSystemApp && isMainProfile @@ -89,7 +95,7 @@ internal data class LauncherApp( try { val icon = withContext(Dispatchers.IO) { - val density = size / (108/1.5) + val density = size / (108 / 1.5) launcherActivityInfo.getIcon(0) } ?: return null @@ -269,7 +275,10 @@ internal data class LauncherApp( fun isSuspended(context: Context, packageName: String): Boolean { return try { - context.packageManager.getApplicationInfo(packageName, 0).flags and ApplicationInfo.FLAG_SUSPENDED != 0 + context.packageManager.getApplicationInfo( + packageName, + 0 + ).flags and ApplicationInfo.FLAG_SUSPENDED != 0 } catch (e: PackageManager.NameNotFoundException) { false } diff --git a/data/applications/src/main/java/de/mm20/launcher2/applications/Module.kt b/data/applications/src/main/java/de/mm20/launcher2/applications/Module.kt index 440c6ec01..e3ea94df6 100644 --- a/data/applications/src/main/java/de/mm20/launcher2/applications/Module.kt +++ b/data/applications/src/main/java/de/mm20/launcher2/applications/Module.kt @@ -9,6 +9,6 @@ import org.koin.dsl.module val applicationsModule = module { factory>(named()) { get() } - single { AppRepositoryImpl(androidContext(), get()) } + single { AppRepositoryImpl(androidContext(), get(), get()) } factory(named(LauncherApp.Domain)) { LauncherAppDeserializer(androidContext()) } } \ No newline at end of file diff --git a/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutConfigActivity.kt b/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutConfigActivity.kt index 9788e1dd2..088e64ab9 100644 --- a/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutConfigActivity.kt +++ b/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutConfigActivity.kt @@ -6,7 +6,6 @@ import android.content.pm.LauncherActivityInfo import android.content.pm.LauncherApps import android.graphics.drawable.Drawable import androidx.core.content.getSystemService -import de.mm20.launcher2.ktx.romanize import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import java.text.Collator @@ -30,6 +29,6 @@ class AppShortcutConfigActivity( val label1 = label val label2 = other.label return Collator.getInstance().apply { strength = Collator.SECONDARY } - .compare(label1.romanize(), label2.romanize()) + .compare(label1, label2) } } \ No newline at end of file diff --git a/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt b/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt index f5da31377..98da0720a 100644 --- a/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt +++ b/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/AppShortcutRepository.kt @@ -9,7 +9,6 @@ import android.os.Looper import android.os.Process import android.os.UserHandle import androidx.core.content.getSystemService -import de.mm20.launcher2.ktx.normalize import de.mm20.launcher2.permissions.PermissionGroup import de.mm20.launcher2.permissions.PermissionsManager import de.mm20.launcher2.preferences.search.ShortcutSearchSettings @@ -17,6 +16,7 @@ import de.mm20.launcher2.profiles.ProfileManager import de.mm20.launcher2.search.AppShortcut import de.mm20.launcher2.search.ResultScore import de.mm20.launcher2.search.SearchableRepository +import de.mm20.launcher2.search.StringNormalizer import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -34,8 +34,6 @@ import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.withContext -import org.apache.commons.text.similarity.FuzzyScore -import java.util.Locale interface AppShortcutRepository : SearchableRepository { @@ -57,6 +55,7 @@ internal class AppShortcutRepositoryImpl( private val permissionsManager: PermissionsManager, private val settings: ShortcutSearchSettings, private val profileManager: ProfileManager, + private val stringNormalizer: StringNormalizer, ) : AppShortcutRepository { private val scope = CoroutineScope(Dispatchers.Default + Job()) @@ -120,6 +119,8 @@ internal class AppShortcutRepositoryImpl( return flowOf(persistentListOf()) } + val normalizedQuery = stringNormalizer.normalize(query) + return combine( listOf( settings.enabled, @@ -146,9 +147,14 @@ internal class AppShortcutRepositoryImpl( ) val shortcuts = launcherApps.getShortcuts(shortcutQuery, Process.myUserHandle()) ?.mapNotNull { - val score = ResultScore( - query = query, - primaryFields = listOfNotNull(it.longLabel?.toString(), it.shortLabel?.toString()) + val score = ResultScore.from( + query = normalizedQuery, + primaryFields = listOfNotNull( + it.longLabel?.toString() + ?.let { stringNormalizer.normalize(it) }, + it.shortLabel?.toString() + ?.let { stringNormalizer.normalize(it) }, + ) ) if (score.score < 0.8f) return@mapNotNull null LauncherShortcut( @@ -227,13 +233,4 @@ internal class AppShortcutRepositoryImpl( } return results.sorted() } - - - private fun matches(label: String, query: String): Boolean { - val normalizedLabel = label.normalize() - val normalizedQuery = query.normalize() - if (normalizedLabel.contains(normalizedQuery)) return true - val fuzzyScore = FuzzyScore(Locale.getDefault()) - return fuzzyScore.fuzzyScore(normalizedLabel, normalizedQuery) >= query.length * 1.5 - } } \ No newline at end of file diff --git a/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/Module.kt b/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/Module.kt index 49bd6dcf9..e69a06c42 100644 --- a/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/Module.kt +++ b/data/appshortcuts/src/main/java/de/mm20/launcher2/appshortcuts/Module.kt @@ -8,7 +8,7 @@ import org.koin.core.qualifier.named import org.koin.dsl.module val appShortcutsModule = module { - factory { AppShortcutRepositoryImpl(androidContext(), get(), get(), get()) } + factory { AppShortcutRepositoryImpl(androidContext(), get(), get(), get(), get()) } factory>(named()) { get() } factory(named(LauncherShortcut.Domain)) { LauncherShortcutDeserializer(androidContext()) } factory(named(LegacyShortcut.Domain)) { LegacyShortcutDeserializer(androidContext()) } diff --git a/data/i18n/.gitignore b/data/i18n/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/data/i18n/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/data/i18n/build.gradle.kts b/data/i18n/build.gradle.kts new file mode 100644 index 000000000..77b1ff922 --- /dev/null +++ b/data/i18n/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.plugin.serialization) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_1_8) + } + } + namespace = "de.mm20.launcher2.data.i18n" +} + +dependencies { + implementation(libs.bundles.kotlin) + + implementation(libs.koin.android) + implementation(libs.commons.text) + + implementation(project(":core:ktx")) + implementation(project(":core:base")) + implementation(project(":core:crashreporter")) + implementation(project(":core:preferences")) + +} \ No newline at end of file diff --git a/data/i18n/consumer-rules.pro b/data/i18n/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/data/i18n/proguard-rules.pro b/data/i18n/proguard-rules.pro new file mode 100644 index 000000000..481bb4348 --- /dev/null +++ b/data/i18n/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/data/i18n/src/main/AndroidManifest.xml b/data/i18n/src/main/AndroidManifest.xml new file mode 100644 index 000000000..a5918e68a --- /dev/null +++ b/data/i18n/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/data/i18n/src/main/java/de/mm20/launcher2/data/CompatStringNormalizer.kt b/data/i18n/src/main/java/de/mm20/launcher2/data/CompatStringNormalizer.kt new file mode 100644 index 000000000..bcad5fef4 --- /dev/null +++ b/data/i18n/src/main/java/de/mm20/launcher2/data/CompatStringNormalizer.kt @@ -0,0 +1,17 @@ +package de.mm20.launcher2.data + +import de.mm20.launcher2.search.StringNormalizer +import org.apache.commons.lang3.StringUtils +import java.util.Locale + +/** + * Pre Android 10 StringNormalizer. Only strips accents from latin characters + */ +internal class CompatStringNormalizer: StringNormalizer { + override fun normalize(input: String): String { + return StringUtils.stripAccents(input.lowercase(Locale.getDefault())) + .replace("æ", "ae") + .replace("œ", "oe") + .replace("ß", "ss") + } +} \ No newline at end of file diff --git a/data/i18n/src/main/java/de/mm20/launcher2/data/IcuStringNormalizer.kt b/data/i18n/src/main/java/de/mm20/launcher2/data/IcuStringNormalizer.kt new file mode 100644 index 000000000..6b15c4cec --- /dev/null +++ b/data/i18n/src/main/java/de/mm20/launcher2/data/IcuStringNormalizer.kt @@ -0,0 +1,113 @@ +package de.mm20.launcher2.data + +import android.content.Context +import android.icu.text.Transliterator +import android.icu.util.ULocale +import androidx.annotation.RequiresApi +import de.mm20.launcher2.crashreporter.CrashReporter +import de.mm20.launcher2.preferences.ui.LocaleSettings +import de.mm20.launcher2.search.StringNormalizer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import org.apache.commons.lang3.StringUtils +import java.util.Locale + +@RequiresApi(29) +internal class IcuStringNormalizer( + private val context: Context, + localeSettings: LocaleSettings, +) : StringNormalizer { + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + private val transliterator = localeSettings.transliterator + .map { + try { + getTransliterator(it) + } catch (e: IllegalArgumentException) { + CrashReporter.logException(e) + null + } + } + .stateIn(scope, SharingStarted.Eagerly, null) + + override fun normalize(input: String): String { + val transliterator = transliterator.value + + if (transliterator == null) { + return StringUtils.stripAccents(input.lowercase(Locale.getDefault())) + .replace("æ", "ae") + .replace("œ", "oe") + .replace("ß", "ss") + } + + return transliterator.transliterate(input).lowercase() + } + + private fun getTransliterator(preferenceValue: String?): Transliterator { + val id = preferenceValue ?: return Transliterator.getInstance(DisabledTransliteratorId) + + if (id.isNotBlank()) { + return Transliterator.getInstance("$id;$BaseTransliteratorId") + } + + val locales = context.resources.configuration.locales + + if (locales.isEmpty) { + Transliterator.getInstance(BaseTransliteratorId) + } + + val scripts = mutableSetOf() + val languages = mutableSetOf() + + val availableIds = Transliterator.getAvailableIDs().toList() + + for (i in 0.. { + if (isAtLeastApiLevel(29)) IcuStringNormalizer(androidContext(), get()) + else CompatStringNormalizer() + } +} \ No newline at end of file diff --git a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt index b458133af..193cfcf9f 100644 --- a/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt +++ b/data/locations/src/main/java/de/mm20/launcher2/locations/providers/openstreetmaps/OsmLocationProvider.kt @@ -214,7 +214,7 @@ internal class OsmLocationProvider( private fun delocalizeToQueryableTags(localizedQuery: String): List = poiCategories.flatMap { (string, tags) -> - val score = ResultScore( + val score = ResultScore.from( localizedQuery, primaryFields = listOf(string) ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b322dddd..b07b57d45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -119,7 +119,7 @@ coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coi leakcanary = { group = "com.squareup.leakcanary", name = "leakcanary", version = "2.10" } suncalc = { group = "org.shredzone.commons", name = "commons-suncalc", version = "3.11" } jsoup = { group = "org.jsoup", name = "jsoup", version = "1.21.2" } -commons-text = { group = "org.apache.commons", name = "commons-text", version = "1.14.0" } +commons-text = { group = "org.apache.commons", name = "commons-text", version = "1.15.0" } stringsimilarity = { group = "com.aallam.similarity", name = "string-similarity-kotlin", version = "0.1.0" } #noinspection NewerVersionAvailable 4.4.2 is the last GPL compatible version, don't update to 5.x diff --git a/settings.gradle.kts b/settings.gradle.kts index a4d80bc6f..31e858233 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -68,3 +68,4 @@ include(":services:plugins") include(":core:devicepose") include(":core:profiles") include(":libs:tinypinyin") +include(":data:i18n")