Add transliteration support for non-latin scripts

This commit is contained in:
MM20
2025-12-15 23:00:40 +01:00
parent c17373e9e0
commit 67167de2da
35 changed files with 443 additions and 72 deletions

View File

@@ -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"))

View File

@@ -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,
)
)
}

View File

@@ -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
}

View File

@@ -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<Tag>().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

View File

@@ -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())
}
}
}

View File

@@ -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<Pair<String, String?>> = remember(locales) {
if (!isAtLeastApiLevel(29)) return@remember listOf()
if (locales?.isEmpty == true) return@remember listOf("Disabled" to null)
val scripts = mutableSetOf<String>()
val languages = mutableSetOf<String>()
val transliterators = mutableMapOf<String?, String>(
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..<locales!!.size()) {
val locale = locales.get(i)
val ulocale = ULocale.addLikelySubtags(ULocale.forLocale(locale))
val lng = ulocale.language
val scr = ulocale.script
if (!languages.contains(lng)) {
val filter = "${lng}-${lng}_Latn"
val ids = availableIds.filter { it.startsWith(filter) }
for (id in ids) {
transliterators[id] = "${ulocale.displayLanguage.replaceFirstChar { ulocale.displayLanguage.first().uppercase() }} ($id)"
}
languages.add(lng)
}
if (!scripts.contains(ulocale.script)) {
val filter = "${scr}-Latn"
val ids = availableIds.filter { it.startsWith(filter) }
for (id in ids) {
transliterators[id] = "${ulocale.displayScript.replaceFirstChar { ulocale.displayScript.first().uppercase() }} ($id)"
}
scripts.add(ulocale.script)
}
}
transliterators.map { it.value to it.key }
}
PreferenceScreen(
@@ -94,6 +155,17 @@ fun LocaleSettingsScreen() {
)
)
}
if (isAtLeastApiLevel(29) && transliterators.size > 2) {
ListPreference(
icon = R.drawable.translate_24px,
title = stringResource(R.string.preference_transliteration),
items = transliterators,
value = transliterator,
onValueChanged = {
viewModel.setTransliterator(it)
},
)
}
}
}
item {

View File

@@ -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)
}
}

View File

@@ -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<LauncherIcon?>,
)
): Comparable<AppListItem> {
override fun compareTo(other: AppListItem): Int {
val label1 = label
val label2 = other.label
return Collator.getInstance().apply { strength = Collator.SECONDARY }
.compare(label1, label2)
}
}

View File

@@ -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,
)
): Comparable<SearchableApp> {
override fun compareTo(other: SearchableApp): Int {
val label1 = label
val label2 = other.label
return Collator.getInstance().apply { strength = Collator.SECONDARY }
.compare(label1, label2)
}
}

View File

@@ -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<ResultScore> {
@@ -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<String> = emptyList(),
secondaryFields: Iterable<String> = 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
)

View File

@@ -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<SavableSearchable> {
@@ -38,7 +37,7 @@ interface SavableSearchable : Searchable, Comparable<SavableSearchable> {
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

View File

@@ -0,0 +1,5 @@
package de.mm20.launcher2.search
interface StringNormalizer {
fun normalize(input: String): String
}

View File

@@ -878,4 +878,5 @@
<string name="preference_measurement_system_metric">Metrisch</string>
<string name="preference_measurement_system_uk">Vereinigtes Königreich</string>
<string name="preference_measurement_system_us">Vereinigte Staaten</string>
<string name="preference_transliteration">Bevorzugte Transliteration</string>
</resources>

View File

@@ -1067,4 +1067,7 @@
<string name="preference_measurement_system_metric">Metric</string>
<string name="preference_measurement_system_uk">United Kingdom</string>
<string name="preference_measurement_system_us">United States</string>
<string name="preference_transliteration">Preferred transliteration</string>
<string name="preference_transliteration_auto">Automatic</string>
<string name="preference_transliteration_disabled">Disabled</string>
</resources>

View File

@@ -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

View File

@@ -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? = "",
) {

View File

@@ -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)
}
}
}

View File

@@ -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<Application> {
fun findOne(
@@ -45,6 +43,7 @@ interface AppRepository : SearchableRepository<Application> {
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<ImmutableList<LauncherApp>> {
val normalizedQuery = stringNormalizer.normalize(query)
return installedApps.map { apps ->
withContext(Dispatchers.Default) {
val appResults = mutableListOf<LauncherApp>()
@@ -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(

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -9,6 +9,6 @@ import org.koin.dsl.module
val applicationsModule = module {
factory<SearchableRepository<Application>>(named<Application>()) { get<AppRepository>() }
single<AppRepository> { AppRepositoryImpl(androidContext(), get()) }
single<AppRepository> { AppRepositoryImpl(androidContext(), get(), get()) }
factory<SearchableDeserializer>(named(LauncherApp.Domain)) { LauncherAppDeserializer(androidContext()) }
}

View File

@@ -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)
}
}

View File

@@ -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<AppShortcut> {
@@ -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
}
}

View File

@@ -8,7 +8,7 @@ import org.koin.core.qualifier.named
import org.koin.dsl.module
val appShortcutsModule = module {
factory<AppShortcutRepository> { AppShortcutRepositoryImpl(androidContext(), get(), get(), get()) }
factory<AppShortcutRepository> { AppShortcutRepositoryImpl(androidContext(), get(), get(), get(), get()) }
factory<SearchableRepository<AppShortcut>>(named<AppShortcut>()) { get<AppShortcutRepository>() }
factory<SearchableDeserializer>(named(LauncherShortcut.Domain)) { LauncherShortcutDeserializer(androidContext()) }
factory<SearchableDeserializer>(named(LegacyShortcut.Domain)) { LegacyShortcutDeserializer(androidContext()) }

1
data/i18n/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

View File

@@ -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"))
}

View File

21
data/i18n/proguard-rules.pro vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -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")
}
}

View File

@@ -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<String>()
val languages = mutableSetOf<String>()
val availableIds = Transliterator.getAvailableIDs().toList()
for (i in 0..<locales.size()) {
val locale = locales.get(i)
val ulocale = ULocale.addLikelySubtags(ULocale.forLocale(locale))
val lng = ulocale.language
val scr = ulocale.script
if (!languages.contains(lng)) {
val filter = "${lng}-${lng}_Latn"
val id = availableIds.find { it.startsWith(filter) }
if (id != null) {
return Transliterator.getInstance("$id;$BaseTransliteratorId")
}
languages.add(lng)
}
if (!scripts.contains(ulocale.script)) {
val filter = "${scr}-Latn"
val id = availableIds.find { it.startsWith(filter) }
if (id != null) {
return Transliterator.getInstance("$id;$BaseTransliteratorId")
}
scripts.add(ulocale.script)
}
}
return Transliterator.getInstance(BaseTransliteratorId)
}
companion object {
/**
* Transliterator that is used when transliteration is disabled
*/
private const val DisabledTransliteratorId = "Latin-ASCII"
/**
* Transliterator that is used when no script or language is specified
*/
private const val BaseTransliteratorId = "Any-Latin;Latin-ASCII"
}
}

View File

@@ -0,0 +1,13 @@
package de.mm20.launcher2.data
import de.mm20.launcher2.ktx.isAtLeastApiLevel
import de.mm20.launcher2.search.StringNormalizer
import org.koin.android.ext.koin.androidContext
import org.koin.dsl.module
val i18nDataModule = module {
single<StringNormalizer> {
if (isAtLeastApiLevel(29)) IcuStringNormalizer(androidContext(), get())
else CompatStringNormalizer()
}
}

View File

@@ -214,7 +214,7 @@ internal class OsmLocationProvider(
private fun delocalizeToQueryableTags(localizedQuery: String): List<String> =
poiCategories.flatMap { (string, tags) ->
val score = ResultScore(
val score = ResultScore.from(
localizedQuery,
primaryFields = listOf(string)
)

View File

@@ -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

View File

@@ -68,3 +68,4 @@ include(":services:plugins")
include(":core:devicepose")
include(":core:profiles")
include(":libs:tinypinyin")
include(":data:i18n")