From 12b0f8f9df7fb8d9adf084fde0d727cf729949a8 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:20:40 +0530 Subject: [PATCH 001/118] fix(settings): fix and optimize gboard import Parse Gboard format header dynamically to fix missing words and swapped values. Optimize using bulkInsert to reduce database insertion IPC overhead. --- .../screens/PersonalDictionariesScreen.kt | 113 +++++++++++++----- 1 file changed, 81 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionariesScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionariesScreen.kt index 683f00573..7bf4ffa59 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionariesScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionariesScreen.kt @@ -54,6 +54,7 @@ import java.io.InputStreamReader import java.util.TreeSet import java.util.zip.ZipInputStream import android.provider.UserDictionary +import android.content.ContentValues import kotlinx.coroutines.withContext import java.util.zip.ZipEntry @@ -133,61 +134,109 @@ fun PersonalDictionariesScreen( private suspend fun importGboardDictionary(context: android.content.Context, uri: android.net.Uri): Int { var addedCount = 0 - context.contentResolver.openInputStream(uri)?.use { inputStream -> - // Check if it's a zip by reading magic bytes or extension (but here we just try zip first) - // Gboard export is typically a zip containing dictionary.txt - // But users might extract it - try { - // Try as ZIP - val zipStream = ZipInputStream(inputStream) - var entry = zipStream.nextEntry - while (entry != null) { - if (entry.name.endsWith(".txt") || entry.name == "dictionary.txt") { - // Prevent closing the parent stream - val reader = BufferedReader(InputStreamReader(zipStream)) - addedCount += parseAndInsert(context, reader) + val isZip = try { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + ZipInputStream(inputStream).use { zip -> zip.nextEntry != null } + } ?: false + } catch (_: Exception) { + false + } + + if (isZip) { + context.contentResolver.openInputStream(uri)?.use { inputStream -> + try { + ZipInputStream(inputStream).use { zipStream -> + var entry = zipStream.nextEntry + while (entry != null) { + if (entry.name.endsWith(".txt") || entry.name == "dictionary.txt") { + val reader = BufferedReader(InputStreamReader(zipStream)) + addedCount += parseAndInsert(context, reader) + } + zipStream.closeEntry() + entry = zipStream.nextEntry + } } - zipStream.closeEntry() - entry = zipStream.nextEntry - } - } catch (e: Exception) { - // Might be a plain text file - context.contentResolver.openInputStream(uri)?.use { plainStream -> - val reader = BufferedReader(InputStreamReader(plainStream)) - addedCount += parseAndInsert(context, reader) + } catch (e: Exception) { + Log.e("ImportDict", "Failed to parse ZIP dictionary", e) } } + } else { + context.contentResolver.openInputStream(uri)?.use { plainStream -> + val reader = BufferedReader(InputStreamReader(plainStream)) + addedCount += parseAndInsert(context, reader) + } } return addedCount } private fun parseAndInsert(context: android.content.Context, reader: BufferedReader): Int { - var count = 0 + val valuesList = mutableListOf() var line: String? + var wordIndex = 0 + var shortcutIndex = 1 + var localeIndex = 2 while (reader.readLine().also { line = it } != null) { val currentLine = line ?: continue - if (currentLine.startsWith("#")) continue + if (currentLine.startsWith("#")) { + val formatPrefix = "# Gboard Dictionary format:" + if (currentLine.startsWith(formatPrefix)) { + val columnsStr = currentLine.substring(formatPrefix.length) + val columns = if (columnsStr.contains("\t")) { + columnsStr.split("\t") + } else { + columnsStr.split(Regex("\\s+")) + }.map { it.trim() } + val wIdx = columns.indexOf("word") + val sIdx = columns.indexOf("shortcut") + val lIdx = columns.indexOf("language_tag").let { if (it == -1) columns.indexOf("locale") else it } + if (wIdx != -1) { + wordIndex = wIdx + shortcutIndex = sIdx + localeIndex = lIdx + } + } + continue + } val parts = currentLine.split("\t") if (parts.isNotEmpty()) { - val word = parts[0] + val word = if (wordIndex in parts.indices) parts[wordIndex] else "" if (word.isNotBlank()) { - val shortcut = if (parts.size >= 2) parts[1].ifBlank { null } else null - val localeStr = if (parts.size >= 3) parts[2].ifBlank { null } else null + val shortcut = if (shortcutIndex != -1 && shortcutIndex in parts.indices) parts[shortcutIndex].ifBlank { null } else null + val localeStr = if (localeIndex != -1 && localeIndex in parts.indices) parts[localeIndex].ifBlank { null } else null val locale = if (localeStr != null && localeStr != "all") { try { Locale.forLanguageTag(localeStr) } catch(_: Exception) { null } } else null - try { - UserDictionary.Words.addWord(context, word, 250, shortcut, locale) - count++ - } catch (e: Exception) { - Log.w("ImportDict", "Failed to add word $word", e) + val values = ContentValues(5).apply { + put(UserDictionary.Words.WORD, word) + put(UserDictionary.Words.FREQUENCY, 250) + put(UserDictionary.Words.LOCALE, locale?.toString()) + put(UserDictionary.Words.APP_ID, 0) + put(UserDictionary.Words.SHORTCUT, shortcut) } + valuesList.add(values) + } + } + } + + if (valuesList.isEmpty()) return 0 + + return try { + context.contentResolver.bulkInsert(UserDictionary.Words.CONTENT_URI, valuesList.toTypedArray()) + } catch (e: Exception) { + Log.e("ImportDict", "Bulk insert failed, falling back to one-by-one insert", e) + var successCount = 0 + for (values in valuesList) { + try { + context.contentResolver.insert(UserDictionary.Words.CONTENT_URI, values) + successCount++ + } catch (ex: Exception) { + Log.w("ImportDict", "Failed to add word ${values.getAsString(UserDictionary.Words.WORD)}", ex) } } + successCount } - return count } fun getSortedDictionaryLocales(): TreeSet { From 7b626f1c72d8ac8ae89ef8566bef756a22da7459 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:23:59 +0530 Subject: [PATCH 002/118] chore: bump version to 3.8.4 Set versionCode to 3840 and versionName to 3.8.4 in build.gradle.kts. Create Fastlane changelog metadata at 3840.txt. --- app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/3840.txt | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/3840.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cda6db365..1ec4a7470 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "com.leanbitlab.leantype" minSdk = 21 targetSdk = 35 - versionCode = 3830 - versionName = "3.8.3" + versionCode = 3840 + versionName = "3.8.4" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") diff --git a/fastlane/metadata/android/en-US/changelogs/3840.txt b/fastlane/metadata/android/en-US/changelogs/3840.txt new file mode 100644 index 000000000..575d5e861 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3840.txt @@ -0,0 +1,3 @@ +- Fix missing words and swapped values during Gboard dictionary import +- Improve Gboard import performance using bulk database insertion +- Pre-verify ZIP signatures and safely close streams to prevent corrupted imports From 935f93bce7d5cc7803c19b42666f94abadf19d5b Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:35:04 +0530 Subject: [PATCH 003/118] feat(settings): improve text expander ui/ux Add responsive search filtering for text expansion shortcuts. Add quick placeholder selection row with rich cursor-based insert support to Add/Edit Dialog. --- .../settings/screens/TextExpanderScreen.kt | 88 +++++++++++++++++-- 1 file changed, 81 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index d5232e1c2..c3da62fa3 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -26,6 +26,7 @@ import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.horizontalScroll import androidx.compose.material3.Button import androidx.compose.material3.CardDefaults import androidx.compose.material3.ElevatedCard @@ -53,6 +54,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.text.input.TextFieldValue import androidx.core.content.edit import helium314.keyboard.latin.R import helium314.keyboard.latin.utils.TextExpanderUtils @@ -84,7 +86,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { var showAddDialog by remember { mutableStateOf(false) } var editingShortcut by remember { mutableStateOf("") } - var editingTemplate by remember { mutableStateOf("") } + var editingTemplate by remember { mutableStateOf(TextFieldValue("")) } var originalShortcutToEdit by remember { mutableStateOf(null) } Box(modifier = Modifier.fillMaxSize()) { @@ -98,8 +100,35 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { fontWeight = FontWeight.Bold ) }, - filteredItems = { emptyList() }, - itemContent = { }, + filteredItems = { term -> + shortcutsMap.entries + .filter { (shortcut, template) -> + shortcut.contains(term, ignoreCase = true) || + template.contains(term, ignoreCase = true) + } + .map { Pair(it.key, it.value) } + }, + itemContent = { (shortcut, template) -> + Box(modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp)) { + ShortcutItem( + shortcut = shortcut, + template = template, + prefix = prefixText, + onEdit = { + editingShortcut = shortcut + editingTemplate = TextFieldValue(template) + originalShortcutToEdit = shortcut + showAddDialog = true + }, + onDelete = { + val updated = shortcutsMap.toMutableMap() + updated.remove(shortcut) + shortcutsMap = updated + TextExpanderUtils.saveShortcuts(context, updated) + } + ) + } + }, content = { Column( modifier = Modifier @@ -289,7 +318,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { prefix = prefixText, onEdit = { editingShortcut = shortcut - editingTemplate = template + editingTemplate = TextFieldValue(template) originalShortcutToEdit = shortcut showAddDialog = true }, @@ -313,7 +342,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { ExtendedFloatingActionButton( onClick = { editingShortcut = "" - editingTemplate = "" + editingTemplate = TextFieldValue("") originalShortcutToEdit = null showAddDialog = true }, @@ -339,12 +368,12 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { if (isEditMode && originalShortcutToEdit != editingShortcut) { updated.remove(originalShortcutToEdit) } - updated[editingShortcut.trim()] = editingTemplate + updated[editingShortcut.trim()] = editingTemplate.text shortcutsMap = updated TextExpanderUtils.saveShortcuts(context, updated) showAddDialog = false }, - checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.isNotEmpty() }, + checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() }, confirmButtonText = if (isEditMode) "Save" else "Add", neutralButtonText = if (isEditMode) "Delete" else null, onNeutral = { @@ -383,6 +412,51 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { label = { Text("Template Expansion") }, placeholder = { Text("Be right back! or My email is %clipboard%") } ) + + Text( + text = "Quick Placeholders (tap to insert at cursor):", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(top = 4.dp) + ) + + androidx.compose.foundation.layout.Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val tags = listOf( + "%date%", "%time%", "%time12%", + "%clipboard%", "%day%", "%day_short%" + ) + tags.forEach { tag -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)) + .clickable { + val text = editingTemplate.text + val selection = editingTemplate.selection + val start = selection.start + val end = selection.end + val newText = text.substring(0, start) + tag + text.substring(end) + val newSelectionRange = androidx.compose.ui.text.TextRange(start + tag.length) + editingTemplate = TextFieldValue(text = newText, selection = newSelectionRange) + } + .padding(horizontal = 8.dp, vertical = 6.dp) + ) { + Text( + text = tag, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + } } } ) From b1b95f9e808aa60afadfbe6f83d20b573fcb0228 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:38:00 +0530 Subject: [PATCH 004/118] feat(settings): polish text expander guide and list Redesign Quick Feature Guide card with styled step badges. Redesign custom shortcuts list items with premium keyword badges and chevrons. Redesign empty state with card illustration layout. --- .../settings/screens/TextExpanderScreen.kt | 214 ++++++++++++++---- 1 file changed, 166 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index c3da62fa3..8f01dc467 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.isImeVisible @@ -141,7 +142,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.15f) + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.12f) ), border = androidx.compose.foundation.BorderStroke( 1.dp, @@ -184,23 +185,76 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { modifier = Modifier .fillMaxWidth() .padding(start = 16.dp, end = 16.dp, bottom = 16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = "How it works:", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "1. Set a Prefix (e.g. '.' or ';') to avoid accidental triggers.\n" + - "2. Add a Shortcut keyword (e.g. 'brb') and its Template expansion.\n" + - "3. Type your Prefix + Shortcut on the keyboard (e.g. '.brb') and press Space or Punctuation to expand instantly!", - style = MaterialTheme.typography.bodyMedium, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2f, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text( + text = "How it works:", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + + // Step 1 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StepBadge(num = "1") + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Set a Shortcut Prefix", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Choose a prefix like '.' or ';' under prefix configuration to prevent accidental expansions.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Step 2 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StepBadge(num = "2") + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Add Custom Shortcuts", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Define triggers (e.g. 'brb') and their expanded templates (e.g. 'Be right back!').", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // Step 3 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + StepBadge(num = "3") + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Type Prefix + Shortcut", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "Type your prefix followed by the shortcut keyword (e.g., '.brb') and press Space or punctuation on the keyboard to expand instantly.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)) @@ -209,40 +263,40 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { Text( text = "How Template Placeholders Work:", style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Text( - text = "Placeholders are special tags you can write in your templates. When you type the shortcut, LeanType automatically replaces them with real-time values (like the current date, time, or your clipboard content) before inserting the text.\n\n" + + text = "Placeholders are special tags you can write in your templates. When you type the shortcut, the keyboard automatically replaces them with real-time values (like the current date, time, or your clipboard content) before inserting the text.\n\n" + "Example Template: 'Hi, let's meet on %day% at %time%! My clipboard says: %clipboard%'\n" + "Expands to: 'Hi, let's meet on Monday at 14:30! My clipboard says: [copied text]'", - style = MaterialTheme.typography.bodyMedium, - lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2f, + style = MaterialTheme.typography.bodySmall, + lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.2f, color = MaterialTheme.colorScheme.onSurfaceVariant ) } HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)) - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( text = "Supported Template Placeholders:", style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { PlaceholderChip(tag = "%date%", desc = "Date (YYYY-MM-DD)") PlaceholderChip(tag = "%time%", desc = "Time (24h, HH:MM)") PlaceholderChip(tag = "%time12%", desc = "Time (12h, hh:mm AM/PM)") } - Column(modifier = Modifier.weight(1.1f), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Column(modifier = Modifier.weight(1.1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { PlaceholderChip(tag = "%clipboard%", desc = "Clipboard content") - PlaceholderChip(tag = "%day%", desc = "Day (e.g. Monday)") + PlaceholderChip(tag = "%day%", desc = "Day name (e.g. Monday)") PlaceholderChip(tag = "%day_short%", desc = "Day short (e.g. Mon)") } } @@ -287,26 +341,47 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { if (shortcutsMap.isEmpty()) { androidx.compose.material3.Card( modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f) + ), + border = androidx.compose.foundation.BorderStroke( + 1.dp, + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.05f) ) ) { Column( - modifier = Modifier.padding(24.dp), + modifier = Modifier.padding(vertical = 32.dp, horizontal = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(12.dp) ) { + Box( + modifier = Modifier + .size(56.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(R.drawable.ic_edit), + contentDescription = "Edit", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(24.dp) + ) + } Text( text = "No shortcuts configured yet.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, textAlign = TextAlign.Center ) Text( - text = "Tap the '+' floating action button below to add your first text template!", - style = MaterialTheme.typography.bodySmall, + text = "Tap the 'Add Shortcut' floating button in the bottom corner to quickly create your first smart text expansion template.", + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center + textAlign = TextAlign.Center, + lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.25f ) } } @@ -478,7 +553,8 @@ private fun ShortcutItem( shape = RoundedCornerShape(16.dp), colors = CardDefaults.elevatedCardColors( containerColor = MaterialTheme.colorScheme.surface - ) + ), + elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp) ) { Row( modifier = Modifier @@ -488,32 +564,74 @@ private fun ShortcutItem( horizontalArrangement = Arrangement.SpaceBetween ) { Column(modifier = Modifier.weight(1f)) { - Text( - text = "$prefix$shortcut", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(4.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) { + Text( + text = "$prefix$shortcut", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) Text( text = template, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2 + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + fontFamily = if (template.contains("%")) androidx.compose.ui.text.font.FontFamily.Monospace else null ) } - IconButton(onClick = onDelete) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + IconButton(onClick = onDelete) { + Icon( + painter = painterResource(R.drawable.ic_bin), + contentDescription = "Delete shortcut", + tint = MaterialTheme.colorScheme.error.copy(alpha = 0.8f) + ) + } Icon( - painter = painterResource(R.drawable.ic_bin), - contentDescription = "Delete shortcut", - tint = MaterialTheme.colorScheme.error + painter = painterResource(R.drawable.ic_arrow_left), + contentDescription = "Edit shortcut", + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), + modifier = Modifier.rotate(180f) ) } } } } +@Composable +private fun StepBadge(num: String) { + Box( + modifier = Modifier + .size(24.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(MaterialTheme.colorScheme.primary) + .padding(top = 1.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = num, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimary, + textAlign = TextAlign.Center + ) + } +} + @Composable private fun PlaceholderChip(tag: String, desc: String) { Box( From fcf830ec55bbc12f63aa404c0ec3041fa6101f97 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:44:34 +0530 Subject: [PATCH 005/118] feat(settings): clean up text expander guide Remove redundant template placeholder explanation while retaining the list of supported placeholder tags. --- .../settings/screens/TextExpanderScreen.kt | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index 8f01dc467..5da664bcd 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -257,25 +257,6 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { } } - HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)) - - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = "How Template Placeholders Work:", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "Placeholders are special tags you can write in your templates. When you type the shortcut, the keyboard automatically replaces them with real-time values (like the current date, time, or your clipboard content) before inserting the text.\n\n" + - "Example Template: 'Hi, let's meet on %day% at %time%! My clipboard says: %clipboard%'\n" + - "Expands to: 'Hi, let's meet on Monday at 14:30! My clipboard says: [copied text]'", - style = MaterialTheme.typography.bodySmall, - lineHeight = MaterialTheme.typography.bodySmall.lineHeight * 1.2f, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - HorizontalDivider(color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { From 26ad741973382ef393939337ba545a2f2cdd1ca0 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:46:50 +0530 Subject: [PATCH 006/118] feat(settings): add more expander placeholders Add support and UI representations for %month%, %month_short%, %year%, and %week% placeholders. --- .../keyboard/latin/utils/TextExpanderUtils.kt | 24 +++++++++++++++++++ .../settings/screens/TextExpanderScreen.kt | 9 +++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index c87b054db..769957fc7 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -99,6 +99,30 @@ object TextExpanderUtils { result = result.replace("%day_short%", dayShortStr) } + // Resolve %month% + if (result.contains("%month%")) { + val monthStr = SimpleDateFormat("MMMM", Locale.getDefault()).format(Date()) + result = result.replace("%month%", monthStr) + } + + // Resolve %month_short% + if (result.contains("%month_short%")) { + val monthShortStr = SimpleDateFormat("MMM", Locale.getDefault()).format(Date()) + result = result.replace("%month_short%", monthShortStr) + } + + // Resolve %year% + if (result.contains("%year%")) { + val yearStr = SimpleDateFormat("yyyy", Locale.getDefault()).format(Date()) + result = result.replace("%year%", yearStr) + } + + // Resolve %week% + if (result.contains("%week%")) { + val weekStr = SimpleDateFormat("w", Locale.getDefault()).format(Date()) + result = result.replace("%week%", weekStr) + } + return result } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index 5da664bcd..a42f03ca5 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -274,11 +274,15 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { PlaceholderChip(tag = "%date%", desc = "Date (YYYY-MM-DD)") PlaceholderChip(tag = "%time%", desc = "Time (24h, HH:MM)") PlaceholderChip(tag = "%time12%", desc = "Time (12h, hh:mm AM/PM)") + PlaceholderChip(tag = "%year%", desc = "Year (YYYY)") + PlaceholderChip(tag = "%week%", desc = "Week of year (1-53)") } Column(modifier = Modifier.weight(1.1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { PlaceholderChip(tag = "%clipboard%", desc = "Clipboard content") PlaceholderChip(tag = "%day%", desc = "Day name (e.g. Monday)") PlaceholderChip(tag = "%day_short%", desc = "Day short (e.g. Mon)") + PlaceholderChip(tag = "%month%", desc = "Month (e.g. June)") + PlaceholderChip(tag = "%month_short%", desc = "Month short (e.g. Jun)") } } } @@ -484,8 +488,9 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { horizontalArrangement = Arrangement.spacedBy(8.dp) ) { val tags = listOf( - "%date%", "%time%", "%time12%", - "%clipboard%", "%day%", "%day_short%" + "%date%", "%time%", "%time12%", "%clipboard%", + "%day%", "%day_short%", "%month%", "%month_short%", + "%year%", "%week%" ) tags.forEach { tag -> Box( From 980ecf599b14162091338f090a8618ae9fbc9386 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:49:14 +0530 Subject: [PATCH 007/118] feat(settings): add system template placeholders Add and integrate support for %battery%, %device%, and %android% placeholders. --- .../keyboard/latin/utils/TextExpanderUtils.kt | 20 +++++++++++++++++++ .../settings/screens/TextExpanderScreen.kt | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 769957fc7..4e4dfd4d4 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -123,6 +123,26 @@ object TextExpanderUtils { result = result.replace("%week%", weekStr) } + // Resolve %battery% + if (result.contains("%battery%")) { + val bm = context.getSystemService(Context.BATTERY_SERVICE) as? android.os.BatteryManager + val level = bm?.getIntProperty(android.os.BatteryManager.BATTERY_PROPERTY_CAPACITY) ?: -1 + val batteryStr = if (level != -1) "$level%" else "" + result = result.replace("%battery%", batteryStr) + } + + // Resolve %device% + if (result.contains("%device%")) { + val deviceStr = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}" + result = result.replace("%device%", deviceStr) + } + + // Resolve %android% + if (result.contains("%android%")) { + val androidStr = android.os.Build.VERSION.RELEASE + result = result.replace("%android%", androidStr) + } + return result } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index a42f03ca5..3fd3e8670 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -276,6 +276,8 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { PlaceholderChip(tag = "%time12%", desc = "Time (12h, hh:mm AM/PM)") PlaceholderChip(tag = "%year%", desc = "Year (YYYY)") PlaceholderChip(tag = "%week%", desc = "Week of year (1-53)") + PlaceholderChip(tag = "%battery%", desc = "Battery level (e.g. 85%)") + PlaceholderChip(tag = "%device%", desc = "Phone model (e.g. POCO M2)") } Column(modifier = Modifier.weight(1.1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { PlaceholderChip(tag = "%clipboard%", desc = "Clipboard content") @@ -283,6 +285,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { PlaceholderChip(tag = "%day_short%", desc = "Day short (e.g. Mon)") PlaceholderChip(tag = "%month%", desc = "Month (e.g. June)") PlaceholderChip(tag = "%month_short%", desc = "Month short (e.g. Jun)") + PlaceholderChip(tag = "%android%", desc = "Android OS (e.g. 14)") } } } @@ -490,7 +493,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { val tags = listOf( "%date%", "%time%", "%time12%", "%clipboard%", "%day%", "%day_short%", "%month%", "%month_short%", - "%year%", "%week%" + "%year%", "%week%", "%battery%", "%device%", "%android%" ) tags.forEach { tag -> Box( From 443cea26eadfc8856d917cb76d3c99b5e008086b Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 12:50:50 +0530 Subject: [PATCH 008/118] feat(settings): add language placeholder Add and integrate support for %language% placeholder, which expands to the current active keyboard display language. --- .../helium314/keyboard/latin/utils/TextExpanderUtils.kt | 9 +++++++++ .../keyboard/settings/screens/TextExpanderScreen.kt | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 4e4dfd4d4..b691efb31 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -143,6 +143,15 @@ object TextExpanderUtils { result = result.replace("%android%", androidStr) } + // Resolve %language% + if (result.contains("%language%")) { + val imeManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager + val activeSubtype = imeManager?.currentInputMethodSubtype + val languageStr = activeSubtype?.getDisplayName(context, context.packageName, context.applicationInfo)?.toString() + ?: Locale.getDefault().getDisplayName(Locale.getDefault()) + result = result.replace("%language%", languageStr) + } + return result } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index 3fd3e8670..b4e3a0d6e 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -286,6 +286,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { PlaceholderChip(tag = "%month%", desc = "Month (e.g. June)") PlaceholderChip(tag = "%month_short%", desc = "Month short (e.g. Jun)") PlaceholderChip(tag = "%android%", desc = "Android OS (e.g. 14)") + PlaceholderChip(tag = "%language%", desc = "Keyboard language (e.g. English)") } } } @@ -493,7 +494,8 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { val tags = listOf( "%date%", "%time%", "%time12%", "%clipboard%", "%day%", "%day_short%", "%month%", "%month_short%", - "%year%", "%week%", "%battery%", "%device%", "%android%" + "%year%", "%week%", "%battery%", "%device%", "%android%", + "%language%" ) tags.forEach { tag -> Box( From 48b69787fd16487fbd2a25ef9b6a9a9a98dc608a Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:33:19 +0530 Subject: [PATCH 009/118] fix(perf): prevent OOM on background image decode - Add BitmapUtils.decodeSampledBitmap() with two-pass decode and inSampleSize - Use RGB_565 config for non-PNG images to halve memory usage - Use BitmapFactory.decodeStream (with InputStream) instead of decodeFile - Cap background bitmap at 2048px max dimension - Recycle temp bitmap after validation in setBackgroundImage Fixes: Settings.java:527, BackgroundImagePreference.kt:122 --- .../latin/DictionaryFacilitatorImpl.kt | 14 ++++- .../keyboard/latin/settings/Settings.java | 5 +- .../AndroidSpellCheckerService.java | 13 ++++- .../keyboard/latin/utils/BitmapUtils.kt | 58 +++++++++++++++++++ .../preferences/BackgroundImagePreference.kt | 14 +++-- 5 files changed, 92 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/helium314/keyboard/latin/utils/BitmapUtils.kt diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index c35ab8263..04143839f 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -75,9 +75,16 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private var changeFrom = "" private var changeTo = "" + private val SPELLING_DICTIONARY_TYPES = arrayOf( + Dictionary.TYPE_MAIN, + Dictionary.TYPE_CONTACTS, + Dictionary.TYPE_APPS, + Dictionary.TYPE_USER_HISTORY + ) + // Caches for spell checking word validity - private var mValidSpellingWordReadCache: LruCache? = null - private var mValidSpellingWordWriteCache: LruCache? = null + private var mValidSpellingWordReadCache: LruCache? = LruCache(500) + private var mValidSpellingWordWriteCache: LruCache? = LruCache(500) // Limit parallelism to prevent excessive CPU usage during dictionary operations private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(2)) @@ -613,7 +620,8 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // locale, and instead simply return true if word is in any of the available dictionaries override fun isValidSpellingWord(word: String): Boolean { mValidSpellingWordReadCache?.get(word)?.let { return it } - val result = dictionaryGroups.any { isValidWord(word, DictionaryFacilitator.ALL_DICTIONARY_TYPES, it) } + mValidSpellingWordWriteCache?.get(word)?.let { return it } + val result = dictionaryGroups.any { isValidWord(word, SPELLING_DICTIONARY_TYPES, it) } mValidSpellingWordReadCache?.put(word, result) return result } diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index c5d745be9..59cbaa361 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -523,8 +523,9 @@ public static Drawable readUserBackgroundImage(final Context context, final bool if (!image.isFile()) return null; try { - sCachedBackgroundImages[index] = new CenterCropDrawable( - BitmapFactory.decodeFile(image.getAbsolutePath())); + final android.graphics.Bitmap bm = helium314.keyboard.latin.utils.BitmapUtils.decodeSampledBitmap(image, 2048, true); + if (bm == null) return null; + sCachedBackgroundImages[index] = new CenterCropDrawable(bm); return sCachedBackgroundImages[index]; } catch (Exception e) { return null; diff --git a/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java b/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java index 3ecbc5731..67ce62401 100644 --- a/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java +++ b/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java @@ -34,6 +34,7 @@ import helium314.keyboard.latin.utils.ScriptUtils; import helium314.keyboard.latin.utils.SubtypeSettings; import helium314.keyboard.latin.utils.SubtypeUtilsAdditional; +import helium314.keyboard.latin.utils.SubtypeUtilsKt; import helium314.keyboard.latin.utils.SuggestionResults; import java.util.Locale; @@ -205,7 +206,17 @@ private Keyboard createKeyboardForLocale(final Locale locale) { editorInfo.inputType = InputType.TYPE_CLASS_TEXT; Settings.getInstance().loadSettings(this, locale, new InputAttributes(editorInfo, false, getPackageName()), ScriptUtils.SCRIPT_UNKNOWN); } - final String mainLayoutName = SubtypeSettings.INSTANCE.getMatchingMainLayoutNameForLocale(locale); + String mainLayoutName = null; + for (InputMethodSubtype enabledSubtype : SubtypeSettings.INSTANCE.getEnabledSubtypes(true)) { + if (enabledSubtype.getLocale().equals(locale.toString()) + || SubtypeUtilsKt.locale(enabledSubtype).getLanguage().equals(locale.getLanguage())) { + mainLayoutName = SubtypeUtilsKt.mainLayoutName(enabledSubtype); + break; + } + } + if (mainLayoutName == null) { + mainLayoutName = SubtypeSettings.INSTANCE.getMatchingMainLayoutNameForLocale(locale); + } final InputMethodSubtype subtype = SubtypeUtilsAdditional.INSTANCE.createDummyAdditionalSubtype(locale, mainLayoutName); final KeyboardLayoutSet keyboardLayoutSet = createKeyboardSetForSpellChecker(subtype); return keyboardLayoutSet.getKeyboard(KeyboardId.ELEMENT_ALPHABET); diff --git a/app/src/main/java/helium314/keyboard/latin/utils/BitmapUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/BitmapUtils.kt new file mode 100644 index 000000000..05c8b182a --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/utils/BitmapUtils.kt @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.utils + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import androidx.annotation.WorkerThread +import java.io.File +import java.io.FileInputStream + +object BitmapUtils { + private const val DEFAULT_BACKGROUND_MAX_DIM = 2048 + + /** + * Decodes a file into a Bitmap using a two-pass approach with sub-sampling to avoid OOM. + * Returns null if the file cannot be decoded. + * + * @param file the file to decode + * @param maxDim the maximum width/height of the resulting bitmap; larger images are sub-sampled down + * @param preferLowConfig if true, prefer RGB_565 (half the memory) when the image has no alpha + */ + @WorkerThread + @JvmStatic + fun decodeSampledBitmap(file: File, maxDim: Int = DEFAULT_BACKGROUND_MAX_DIM, preferLowConfig: Boolean = true): Bitmap? { + if (!file.isFile) return null + val bounds = BitmapFactory.Options() + bounds.inJustDecodeBounds = true + try { + FileInputStream(file).use { BitmapFactory.decodeStream(it, null, bounds) } + } catch (_: Exception) { + return null + } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + val opts = BitmapFactory.Options() + opts.inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, maxDim) + if (preferLowConfig && !bounds.outMimeType.equals("image/png", ignoreCase = true)) { + opts.inPreferredConfig = Bitmap.Config.RGB_565 + } + return try { + FileInputStream(file).use { BitmapFactory.decodeStream(it, null, opts) } + } catch (_: Exception) { + null + } catch (_: OutOfMemoryError) { + null + } + } + + private fun calculateInSampleSize(width: Int, height: Int, maxDim: Int): Int { + var sample = 1 + var w = width + var h = height + while (w / 2 >= maxDim && h / 2 >= maxDim) { + w /= 2 + h /= 2 + sample *= 2 + } + return sample + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt index 0f500cdf9..d124adc27 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackgroundImagePreference.kt @@ -4,12 +4,12 @@ package helium314.keyboard.settings.preferences import android.app.Activity import android.content.Context import android.content.Intent -import android.graphics.BitmapFactory import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -23,6 +23,7 @@ import helium314.keyboard.latin.R import helium314.keyboard.latin.common.FileUtils import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.BitmapUtils import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.getActivity import helium314.keyboard.latin.utils.prefs @@ -45,8 +46,9 @@ fun BackgroundImagePref(setting: Setting, isLandscape: Boolean) { if ((b?.value ?: 0) < 0) // necessary to reload dayNightPref Log.v("irrelevant", "stupid way to trigger recomposition on preference change") val dayNightPref = ctx.prefs().getBoolean(Settings.PREF_THEME_DAY_NIGHT, Defaults.PREF_THEME_DAY_NIGHT) - if (!dayNightPref) - isNight = false + LaunchedEffect(dayNightPref) { + if (!dayNightPref) isNight = false + } val scope = rememberCoroutineScope() val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult @@ -118,12 +120,12 @@ private fun setBackgroundImage(ctx: Context, uri: Uri, isNight: Boolean, isLands val imageFile = Settings.getCustomBackgroundFile(ctx, isNight, isLandscape) FileUtils.copyContentUriToNewFile(uri, ctx, imageFile) KeyboardSwitcher.getInstance().setThemeNeedsReload() - try { - BitmapFactory.decodeFile(imageFile.absolutePath) - } catch (_: Exception) { + val bm = BitmapUtils.decodeSampledBitmap(imageFile, maxDim = 2048, preferLowConfig = true) + if (bm == null) { imageFile.delete() return false } + bm.recycle() Settings.clearCachedBackgroundImages() return true } From f115d559e528b185fbfdde4d30e4a94e12f89635 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:37:54 +0530 Subject: [PATCH 010/118] fix(stability): replace force-unwrap !! in hot paths - Colors.kt: use 'let' smart cast and 'error' instead of NPE on missing keyBackground - FloatingKeyboardManager.kt: safe-call on overlayRoot, early return on null - SuggestionStripView.kt: early return true on missing drawable Prevents IME process crashes from null drawables/bitmaps in keyboard rendering paths. --- .../keyboard/latin/FloatingKeyboardManager.kt | 2 +- .../helium314/keyboard/latin/common/Colors.kt | 26 +++++++------------ .../latin/suggestions/SuggestionStripView.kt | 5 +++- 3 files changed, 15 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/FloatingKeyboardManager.kt b/app/src/main/java/helium314/keyboard/latin/FloatingKeyboardManager.kt index 5ea81b881..37b20730e 100644 --- a/app/src/main/java/helium314/keyboard/latin/FloatingKeyboardManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/FloatingKeyboardManager.kt @@ -129,7 +129,7 @@ class FloatingKeyboardManager(private val context: Context, private val latinIME } contentContainer.addView(headerBar) - overlayRoot!!.addView(contentContainer) + overlayRoot?.addView(contentContainer) ?: return // Calculate window position val savedX = prefs.getInt(PREF_X, -1) diff --git a/app/src/main/java/helium314/keyboard/latin/common/Colors.kt b/app/src/main/java/helium314/keyboard/latin/common/Colors.kt index f09235c11..7660f4b7b 100644 --- a/app/src/main/java/helium314/keyboard/latin/common/Colors.kt +++ b/app/src/main/java/helium314/keyboard/latin/common/Colors.kt @@ -75,7 +75,7 @@ interface Colors { attr.getDrawable(R.styleable.KeyboardView_keyBackground) } else -> null // keyBackground - }?.mutate() ?: attr.getDrawable(R.styleable.KeyboardView_keyBackground)?.mutate()!! // keyBackground always exists + }?.mutate() ?: attr.getDrawable(R.styleable.KeyboardView_keyBackground)?.mutate() ?: error("keyBackground always exists in KeyboardView style") setColor(drawable, color) return drawable @@ -348,15 +348,13 @@ class DynamicColors(context: Context, override val themeStyle: String, override setColor(view.background, POPUP_KEYS_BACKGROUND) else view.background.colorFilter = adjustedBackgroundFilter MAIN_BACKGROUND -> { - if (keyboardBackground != null) { + keyboardBackground?.let { bg -> if (!backgroundSetupDone && view.width > 0 && view.height > 0) { - keyboardBackground = keyboardBackground!!.toBitmap(view.width, view.height).toDrawable(view.context.resources) + keyboardBackground = bg.toBitmap(view.width, view.height).toDrawable(view.context.resources) backgroundSetupDone = true } view.background = keyboardBackground - } else { - view.background.colorFilter = backgroundFilter - } + } ?: run { view.background.colorFilter = backgroundFilter } } else -> view.background.colorFilter = backgroundFilter } @@ -533,15 +531,13 @@ class DefaultColors ( ONE_HANDED_MODE_BUTTON -> setColor(view.background, if (keyboardBackground == null) MAIN_BACKGROUND else STRIP_BACKGROUND) MORE_SUGGESTIONS_BACKGROUND -> view.background.colorFilter = backgroundFilter MAIN_BACKGROUND -> { - if (keyboardBackground != null) { + keyboardBackground?.let { bg -> if (!backgroundSetupDone && view.width > 0 && view.height > 0) { - keyboardBackground = keyboardBackground!!.toBitmap(view.width, view.height).toDrawable(view.context.resources) + keyboardBackground = bg.toBitmap(view.width, view.height).toDrawable(view.context.resources) backgroundSetupDone = true } view.background = keyboardBackground - } else { - view.background.colorFilter = backgroundFilter - } + } ?: run { view.background.colorFilter = backgroundFilter } } else -> view.background.colorFilter = backgroundFilter } @@ -584,15 +580,13 @@ class AllColors(private val colorMap: EnumMap, override val them when (color) { ONE_HANDED_MODE_BUTTON -> setColor(view.background, MAIN_BACKGROUND) // button has no separate background color MAIN_BACKGROUND -> { - if (keyboardBackground != null) { + keyboardBackground?.let { bg -> if (!backgroundSetupDone && view.width > 0 && view.height > 0) { - keyboardBackground = keyboardBackground!!.toBitmap(view.width, view.height).toDrawable(view.context.resources) + keyboardBackground = bg.toBitmap(view.width, view.height).toDrawable(view.context.resources) backgroundSetupDone = true } view.background = keyboardBackground - } else { - setColor(view.background, color) - } + } ?: run { setColor(view.background, color) } } else -> setColor(view.background, color) } diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt index a295633c9..f381ec697 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -637,7 +637,10 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) showIcon = false } if (showIcon) { - val icon = KeyboardIconsSet.instance.getNewDrawable(KeyboardIconsSet.NAME_BIN, context)!! + val icon = KeyboardIconsSet.instance.getNewDrawable(KeyboardIconsSet.NAME_BIN, context) + if (icon == null) { + return true + } Settings.getValues().mColors.setColor(icon, ColorType.REMOVE_SUGGESTION_ICON) val w = icon.intrinsicWidth val h = icon.intrinsicHeight From 3dbf4c7dbe21635c6214d995e383f27fdbf3b182 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:39:05 +0530 Subject: [PATCH 011/118] fix(stability): unregister SharedPreferences listener in spell-checker AndroidSpellCheckerService.onDestroy() now unregisters the OnSharedPreferenceChangeListener. Without this, the SharedPreferences implementation kept a strong reference to the service, leaking it through every spell-check session the system bound/unbound. --- .../latin/spellcheck/AndroidSpellCheckerService.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java b/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java index 67ce62401..458121f48 100644 --- a/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java +++ b/app/src/main/java/helium314/keyboard/latin/spellcheck/AndroidSpellCheckerService.java @@ -90,6 +90,12 @@ public void onCreate() { mSettingsValuesForSuggestion = new SettingsValuesForSuggestion(blockOffensive, false); } + @Override + public void onDestroy() { + KtxKt.prefs(this).unregisterOnSharedPreferenceChangeListener(this); + super.onDestroy(); + } + public float getRecommendedThreshold() { return mRecommendedThreshold; } From f9c7e239c191c897ed9ce85e54bcc9812c1fe244 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:40:33 +0530 Subject: [PATCH 012/118] fix(stability): don't call Looper.prepare() on background thread BackupRestorePreference.kt was calling Looper.prepare() from the ScheduledThreadPool executor, leaking a Looper per restore and posting UI work onto an unreliable thread. Use Handler(Looper.getMainLooper()) to dispatch the FeedbackManager.message call to the UI thread. --- .../settings/preferences/BackupRestorePreference.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt index 617a3c053..00c51dae6 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt @@ -3,6 +3,7 @@ package helium314.keyboard.settings.preferences import android.content.Intent import android.content.SharedPreferences +import android.os.Handler import android.os.Looper import android.widget.Toast import androidx.activity.compose.ManagedActivityResultLauncher @@ -228,8 +229,9 @@ private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLau } Database.copyFromDb(restoredDb, ctx) - Looper.prepare() - FeedbackManager.message(ctx, R.string.backup_restored) + Handler(Looper.getMainLooper()).post { + FeedbackManager.message(ctx, R.string.backup_restored) + } } catch (t: Throwable) { onError("r" + t.message) Log.w("AdvancedScreen", "error during restore", t) From 6d81417a3152614f557e91faadb24d380b5e3731 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:42:08 +0530 Subject: [PATCH 013/118] fix(stability): make score-limit cache update atomic in Suggest The previous implementation had a non-atomic read-then-write of mLastScoreLimitUpdateTime and mCachedScoreLimitForAutocorrect across threads (suggestion lookup can happen on background threads via SuggestionSpan / TextClassifier). Two threads could both miss the interval check and recompute, with the second write overwriting the first. Wrap the cache update in synchronized(this) to make the check and update atomic. --- .../java/helium314/keyboard/latin/Suggest.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/Suggest.kt b/app/src/main/java/helium314/keyboard/latin/Suggest.kt index eb9029b03..1cd59b4ae 100644 --- a/app/src/main/java/helium314/keyboard/latin/Suggest.kt +++ b/app/src/main/java/helium314/keyboard/latin/Suggest.kt @@ -42,6 +42,9 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { } } // Cached scoreLimit to avoid repeated Settings lookups in hot path + // The read-then-write of (mLastScoreLimitUpdateTime, mCachedScoreLimitForAutocorrect) + // is guarded by `synchronized(this)` in shouldBeAutoCorrected() to make the update atomic + // when called from multiple threads (e.g. main IME thread + SuggestionSpan / TextClassifier). @Volatile private var mCachedScoreLimitForAutocorrect = 0 @Volatile private var mLastScoreLimitUpdateTime = 0L @@ -49,7 +52,9 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { fun clearNextWordSuggestionsCache() { nextWordSuggestionsCache.evictAll() // Also reset scoreLimit cache to force refresh on next use - mLastScoreLimitUpdateTime = 0 + synchronized(this) { + mLastScoreLimitUpdateTime = 0 + } } /** @@ -172,13 +177,17 @@ class Suggest(private val mDictionaryFacilitator: DictionaryFacilitator) { val consideredWord = typedWordString.dropLast(trailingSingleQuotesCount) val firstAndTypedEmptyInfos by lazy { getEmptyWordSuggestions() } - // Use cached scoreLimit to avoid repeated Settings lookups in hot path - val currentTime = System.currentTimeMillis() - if (currentTime - mLastScoreLimitUpdateTime > SCORE_LIMIT_CACHE_UPDATE_INTERVAL_MS) { - mCachedScoreLimitForAutocorrect = Settings.getValues().mScoreLimitForAutocorrect - mLastScoreLimitUpdateTime = currentTime + // Use cached scoreLimit to avoid repeated Settings lookups in hot path. + // The read-then-write is guarded by `synchronized(this)` to make the cache + // update atomic across threads. + val scoreLimit: Int = synchronized(this) { + val currentTime = System.currentTimeMillis() + if (currentTime - mLastScoreLimitUpdateTime > SCORE_LIMIT_CACHE_UPDATE_INTERVAL_MS) { + mCachedScoreLimitForAutocorrect = Settings.getValues().mScoreLimitForAutocorrect + mLastScoreLimitUpdateTime = currentTime + } + mCachedScoreLimitForAutocorrect } - val scoreLimit = mCachedScoreLimitForAutocorrect // We allow auto-correction if whitelisting is not required or the word is whitelisted, // or if the word had more than one char and was not suggested. val allowsToBeAutoCorrected: Boolean From 8b73400e52676f3a77a1b25c77353b5b1f818037 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:43:39 +0530 Subject: [PATCH 014/118] fix(stability): use named lock for dictionary blacklist Three blacklist operations in DictionaryGroup used `.apply { scope.launch { synchronized(this) { ... } } }`, which re-bound `this` to the HashSet / CoroutineScope inside the synchronized block. Two threads could enter the critical section concurrently because they were locking on different objects. Add an explicit `blacklistLock: Any` and synchronize on it. --- .../keyboard/latin/DictionaryFacilitatorImpl.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 04143839f..e2fb65f7e 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -768,6 +768,12 @@ private class DictionaryGroup( ) { private val subDicts: ConcurrentHashMap = ConcurrentHashMap(subDicts) + // Monitor for the blacklist set + file I/O. The previous code used + // `synchronized(this)` inside an `apply { }` and `scope.launch { }` block, which + // re-bound `this` to the inner receiver (the HashSet / CoroutineScope). Two + // concurrent blacklist operations could then run without mutual exclusion. + private val blacklistLock = Any() + /** Removes a word from all dictionaries in this group. If the word is in a read-only dictionary, it is blacklisted. */ fun removeWord(word: String) { // remove from user history @@ -855,7 +861,7 @@ private class DictionaryGroup( private val blacklist = hashSetOf().apply { if (blacklistFile?.isFile != true) return@apply scope.launch { - synchronized(this) { + synchronized(blacklistLock) { try { addAll(blacklistFile.readLines()) } catch (e: IOException) { @@ -870,7 +876,7 @@ private class DictionaryGroup( fun addToBlacklist(word: String) { if (!blacklist.add(word) || blacklistFile == null) return scope.launch { - synchronized(this) { + synchronized(blacklistLock) { try { if (blacklistFile.isDirectory) blacklistFile.delete() blacklistFile.appendText("$word\n") @@ -884,7 +890,7 @@ private class DictionaryGroup( fun removeFromBlacklist(word: String) { if (!blacklist.remove(word) || blacklistFile == null) return scope.launch { - synchronized(this) { + synchronized(blacklistLock) { try { val newLines = blacklistFile.readLines().filterNot { it == word } blacklistFile.writeText(newLines.joinToString("\n")) From c3b1003260038efe342454008c270410e1996fb2 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:47:54 +0530 Subject: [PATCH 015/118] perf(perf): add key= to Lazy* list items for stable identity - SearchScreen: key groups by titleRes, items by toString() - ListPickerDialog, MultiListPickerDialog: key items by toString() - LayoutPickerDialog: key by layout name - ToolbarKeysCustomizer: key by enum name - ColorThemePickerDialog: key by color name Without keys, LazyColumn uses positional keys, causing every visible item to be recomposed (and its remember slots discarded) on every search keystroke or list mutation. --- app/src/main/java/helium314/keyboard/settings/SearchScreen.kt | 4 ++-- .../keyboard/settings/dialogs/ColorThemePickerDialog.kt | 2 +- .../helium314/keyboard/settings/dialogs/LayoutPickerDialog.kt | 2 +- .../helium314/keyboard/settings/dialogs/ListPickerDialog.kt | 2 +- .../keyboard/settings/dialogs/MultiListPickerDialog.kt | 2 +- .../keyboard/settings/dialogs/ToolbarKeysCustomizer.kt | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt index d68ef97e3..43c30700b 100644 --- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -104,7 +104,7 @@ fun SearchSettingsScreen( .fillMaxSize(), contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 8.dp) ) { - items(groups) { (titleRes, keys) -> + items(groups, key = { (titleRes, _) -> titleRes ?: 0 }) { (titleRes, keys) -> androidx.compose.material3.Card( modifier = Modifier .fillMaxWidth() @@ -247,7 +247,7 @@ fun SearchScreen( contentWindowInsets = WindowInsets(0) ) { innerPadding -> LazyColumn(contentPadding = innerPadding) { - items(items) { + items(items, key = { it?.toString() ?: "null" }) { itemContent(it) } } diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ColorThemePickerDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ColorThemePickerDialog.kt index 4dd13933f..090727b03 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/ColorThemePickerDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ColorThemePickerDialog.kt @@ -116,7 +116,7 @@ fun ColorThemePickerDialog( LocalTextStyle provides MaterialTheme.typography.bodyLarge ) { LazyColumn(state = state) { - items(colors) { item -> + items(colors, key = { it }) { item -> if (item == "") { AddColorRow(onDismissRequest, userColors, targetScreen, setting.key) } else { diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutPickerDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutPickerDialog.kt index ace5aafb8..5ae0e9a69 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutPickerDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutPickerDialog.kt @@ -95,7 +95,7 @@ fun LayoutPickerDialog( LocalTextStyle provides MaterialTheme.typography.bodyLarge ) { LazyColumn(state = state) { - items(layouts) { item -> + items(layouts, key = { it }) { item -> if (item == "") { AddLayoutRow({ newLayoutDialog = it to "" }, layoutType, customLayouts) } else { diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt index b35584a2b..584a2c607 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ListPickerDialog.kt @@ -60,7 +60,7 @@ fun ListPickerDialog( LocalTextStyle provides MaterialTheme.typography.bodyLarge ) { LazyColumn(state = state) { - items(items) { item -> + items(items, key = { it.toString() }) { item -> Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/MultiListPickerDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/MultiListPickerDialog.kt index 554c0fda4..fe055e483 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/MultiListPickerDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/MultiListPickerDialog.kt @@ -52,7 +52,7 @@ fun MultiListPickerDialog( LocalTextStyle provides MaterialTheme.typography.bodyLarge ) { LazyColumn(state = state) { - items(items) { item -> + items(items, key = { it.toString() }) { item -> Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt index 51ef4ddcf..70b124828 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ToolbarKeysCustomizer.kt @@ -60,7 +60,7 @@ fun ToolbarKeysCustomizer( LazyColumn( verticalArrangement = Arrangement.spacedBy(4.dp) ) { - items(ToolbarKey.entries) { + items(ToolbarKey.entries, key = { it.name }) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { showKeyCustomizer = it }.fillParentMaxWidth() From a6fa290ad47f726364cc77175a2b67c7323d9a5d Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:49:58 +0530 Subject: [PATCH 016/118] perf(perf): remember() expensive computations in Composables - SearchScreen: cache filteredItems(searchText.text) so it doesn't re-run the search filter on every parent recomposition (only when the search text actually changes) - MainSettingsScreen: cache SubtypeSettings.getEnabledSubtypes() and its joinToString() output so the description string is not rebuilt on every recomposition Both lists are otherwise recomputed on every pref change, every parent state change, and every scroll-induced recomposition. --- .../main/java/helium314/keyboard/settings/SearchScreen.kt | 2 +- .../keyboard/settings/screens/MainSettingsScreen.kt | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt index 43c30700b..595c65d33 100644 --- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -242,7 +242,7 @@ fun SearchScreen( content() } } else { - val items = filteredItems(searchText.text) + val items = remember(searchText.text) { filteredItems(searchText.text) } Scaffold( contentWindowInsets = WindowInsets(0) ) { innerPadding -> diff --git a/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt index c7cfae164..917f114d0 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/MainSettingsScreen.kt @@ -66,7 +66,10 @@ fun MainSettingsScreen( title = stringResource(R.string.ime_settings), settings = emptyList(), ) { - val enabledSubtypes = SubtypeSettings.getEnabledSubtypes(true) + val enabledSubtypes = remember { SubtypeSettings.getEnabledSubtypes(true) } + val enabledSubtypesDescription = remember(enabledSubtypes) { + enabledSubtypes.joinToString(", ") { it.displayName() } + } val ctx = LocalContext.current val prefs = ctx.prefs() val neverShowAgain = prefs.getBoolean( @@ -143,7 +146,7 @@ fun MainSettingsScreen( ) { NextScreenIcon() } Preference( name = stringResource(R.string.language_and_layouts_title), - description = enabledSubtypes.joinToString(", ") { it.displayName() }, + description = enabledSubtypesDescription, onClick = onClickLanguage, icon = R.drawable.ic_settings_languages ) { NextScreenIcon() } From ead818c064bb949ed5f6a9efb1590e61d6de9fa2 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:51:16 +0530 Subject: [PATCH 017/118] perf(perf): avoid Paint allocation per recomposition in ColorPickerDialog Wrap the Paint in remember { } and assign to the controller inside a LaunchedEffect so the Paint is created once and not allocated on every recomposition. Also avoids re-assigning the controller's wheelPaint on every recomposition. --- .../settings/dialogs/ColorPickerDialog.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt index fc28b9a4f..69f82ed91 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/ColorPickerDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,13 +52,17 @@ fun ColorPickerDialog( onConfirmed: (Int) -> Unit, ) { val controller = rememberColorPickerController() - val wheelPaint = Paint().apply { - alpha = 0.5f - style = PaintingStyle.Stroke - strokeWidth = 5f - color = Color.White + val wheelPaint = remember { + Paint().apply { + alpha = 0.5f + style = PaintingStyle.Stroke + strokeWidth = 5f + color = Color.White + } + } + LaunchedEffect(controller) { + controller.wheelPaint = wheelPaint } - controller.wheelPaint = wheelPaint val barHeight = 35.dp val initialString = initialColor.toUInt().toString(16) var textValue by remember { mutableStateOf(TextFieldValue(initialString, TextRange(initialString.length))) } From b7b17a7d33d66c1293dd1feac192aeadc0fb91b1 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:54:58 +0530 Subject: [PATCH 018/118] perf(perf): stream logcat to file instead of buffering in memory AboutScreen's 'Save log' was reading the entire logcat buffer into a single String via readText(), then writing it out. For a long-running device this can be several MB and compete with the IME process for memory, risking OOM on low-RAM devices. Use useLines { } to iterate line by line and write each one directly to the output stream. The internal log is now also streamed with a for loop and explicit toString() instead of a joinToString() that builds the entire list as a single String. --- .../keyboard/settings/screens/AboutScreen.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt index 55bda2a7b..bf9ccb66f 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AboutScreen.kt @@ -184,10 +184,23 @@ fun createAboutSettings(context: Context) = listOf( val uri = result.data?.data ?: return@rememberLauncherForActivityResult scope.launch(Dispatchers.IO) { ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use { os -> - os.writer().use { writer -> - val logcat = ProcessBuilder("logcat", "-d", "-b", "all", "*:W").start().inputStream.use { it.reader().readText() } - val internal = Log.getLog().joinToString("\n") - writer.write(logcat + "\n\n" + internal) + os.bufferedWriter().use { writer -> + // Stream the logcat line by line to avoid allocating a multi-MB + // String in memory (the IME process can OOM on long-running devices). + ProcessBuilder("logcat", "-d", "-b", "all", "*:W").start().inputStream.use { stream -> + stream.bufferedReader().useLines { lines: Sequence -> + for (line: String in lines) { + writer.write(line) + writer.newLine() + } + } + } + writer.newLine() + writer.newLine() + for (line in Log.getLog()) { + writer.write(line.toString()) + writer.newLine() + } } } } From 1c9d550e19e6e0b0f34893969dbd9857cf54e2fd Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:57:04 +0530 Subject: [PATCH 019/118] perf(perf): make ReorderSwitchPreference data class stable The private KeyAndState class had var fields that were mutated in the Switch.onCheckedChange callback, defeating Compose stability and forcing LazyColumn items to be rebuilt on every recomposition. - Make KeyAndState an immutable data class annotated with @Immutable - Hold the checked state in rememberSaveable(item.name) so the value survives recomposition but is per-item - Remove the in-place mutation of item.state in the Switch callback - rememberSaveable the items list so it's not re-parsed on every recomposition when the dialog is open --- .../preferences/ReorderSwitchPreference.kt | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt index 488e4e2c6..d840ce625 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt @@ -22,6 +22,7 @@ import helium314.keyboard.latin.utils.prefs import helium314.keyboard.settings.Setting import helium314.keyboard.settings.dialogs.ReorderDialog import helium314.keyboard.settings.screens.GetIcon +import androidx.compose.runtime.Immutable import androidx.core.content.edit @Composable @@ -35,10 +36,12 @@ fun ReorderSwitchPreference(setting: Setting, default: String, filter: (String) if (showDialog) { val ctx = LocalContext.current val prefs = ctx.prefs() - val items = prefs.getString(setting.key, default)!!.split(Separators.ENTRY).map { - val both = it.split(Separators.KV) - KeyAndState(both.first(), both.last().toBoolean()) - }.filter { filter(it.name) } + val items = rememberSaveable(setting.key) { + prefs.getString(setting.key, default)!!.split(Separators.ENTRY).map { + val both = it.split(Separators.KV) + KeyAndState(both.first(), both.last().toBoolean()) + }.filter { filter(it.name) } + } ReorderDialog( onConfirmed = { reorderedItems -> val value = reorderedItems.joinToString(Separators.ENTRY) { it.name + Separators.KV + it.state } @@ -51,7 +54,7 @@ fun ReorderSwitchPreference(setting: Setting, default: String, filter: (String) items = items, title = { Text(setting.title) }, displayItem = { item -> - var checked by rememberSaveable { mutableStateOf(item.state) } + var checked by rememberSaveable(item.name) { mutableStateOf(item.state) } Row(verticalAlignment = Alignment.CenterVertically) { KeyboardIconsSet.instance.GetIcon(item.name) val text = item.name.lowercase().getStringResourceOrName("", ctx) @@ -60,7 +63,7 @@ fun ReorderSwitchPreference(setting: Setting, default: String, filter: (String) Text(actualText, Modifier.weight(1f)) Switch( checked = checked, - onCheckedChange = { item.state = it; checked = it } + onCheckedChange = { checked = it } ) } }, @@ -69,4 +72,5 @@ fun ReorderSwitchPreference(setting: Setting, default: String, filter: (String) } } -private class KeyAndState(var name: String, var state: Boolean) +@Immutable +private data class KeyAndState(val name: String, val state: Boolean) From 8438ac0bb9a1b3db50ec40cd3eba090a1aa17928 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 13:59:56 +0530 Subject: [PATCH 020/118] fix(perf): remove top-level MutableStateFlow in AIIntegrationScreen The previous top-level 'providerState' MutableStateFlow lived for the process lifetime and was mutated during composition. The state can be derived from the service on every composition (the service reads from SharedPreferences, which is cheap). - Replace top-level MutableStateFlow with a simple val read - Remove the no-op updateProviderState() function - Remove its call site in AdvancedScreen.kt The AIIntegrationScreen will pick up provider changes on the next composition (e.g. when the user navigates to it after changing the provider on the AdvancedScreen). --- .../settings/screens/AIIntegrationScreen.kt | 27 +++++++------------ .../settings/screens/AdvancedScreen.kt | 5 ++-- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index db7323978..c3b35d8b4 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -7,17 +7,15 @@ package helium314.keyboard.settings.screens import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.R import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.SettingsWithoutKey -import kotlinx.coroutines.flow.MutableStateFlow - -// Shared state for provider selection -private val providerState = MutableStateFlow(null) @Composable fun AIIntegrationScreen( @@ -28,7 +26,7 @@ fun AIIntegrationScreen( onClickBack() return } - + if (BuildConfig.FLAVOR == "standard") { StandardAIIntegrationScreen(onClickBack) } else { @@ -42,12 +40,12 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { // Use remember to avoid re-creating the service on every recomposition val service = remember(ctx) { helium314.keyboard.latin.utils.ProofreadService(ctx) } - // Initialize provider state if needed - if (providerState.value == null) { - providerState.value = service.getProvider().name - } - - val currentProvider by providerState.collectAsState() + // Provider is read from the service on every recomposition. The service + // reads from SharedPreferences so this is cheap. We don't need a + // top-level MutableStateFlow to keep the AIIntegrationScreen in sync + // with provider changes made on the AdvancedScreen: when the user + // returns to this screen, the search settings list is rebuilt. + val provider = service.getProvider().name val items = buildList { // Always show provider selection @@ -56,7 +54,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { add(SettingsWithoutKey.CUSTOM_AI_KEYS) // Show settings based on selected provider - when (currentProvider) { + when (provider) { "GROQ" -> { add(SettingsWithoutKey.GROQ_TOKEN) add(SettingsWithoutKey.GROQ_MODEL) @@ -99,8 +97,3 @@ private fun OfflineAIIntegrationScreen(onClickBack: () -> Unit) { settings = items ) } - -// Update provider state when changed -fun updateProviderState(provider: String) { - providerState.value = provider -} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 075426f0d..ceeef14d3 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -324,8 +324,9 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( onChanged = { newProvider -> service.setProvider(helium314.keyboard.latin.utils.ProofreadService.AIProvider.valueOf(newProvider)) selectedProvider = newProvider - // Trigger AI Integration screen recomposition - helium314.keyboard.settings.screens.updateProviderState(newProvider) + // Provider change is reflected on the AI Integration screen the next + // time the user navigates there; the screen reads provider on + // each composition. } ) }, From dedd3cb519bc9754a5d8c08cb996610242e14fea Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 14:03:08 +0530 Subject: [PATCH 021/118] fix(perf): scope errorJob to the LayoutEditDialog composable The top-level 'private var errorJob: Job?' was shared between any two simultaneous instances of LayoutEditDialog, so opening a second dialog would cancel the first dialog's pending error feedback job. On configuration change the coroutine scope could be cancelled while the top-level job reference was leaked. Move the job into a per-composable remember { mutableStateOf(null) } and cancel/assign through errorJob.value. --- .../keyboard/settings/dialogs/LayoutEditDialog.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt index 42794cfb9..c598e4d98 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/LayoutEditDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -55,6 +56,11 @@ fun LayoutEditDialog( ) { val ctx = LocalContext.current val scope = rememberCoroutineScope() + // Per-composable cancellation slot. Previously this was a top-level + // var, so opening a second dialog would cancel the first dialog's + // feedback job, and on configuration change the scope could be + // cancelled while the top-level job reference leaked. + val errorJob = remember { mutableStateOf(null) } val startIsCustom = LayoutUtilsCustom.isCustomLayout(initialLayoutName) var displayNameValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue( @@ -70,7 +76,7 @@ fun LayoutEditDialog( TextInputDialog( onDismissRequest = { - errorJob?.cancel() + errorJob.value?.cancel() onDismissRequest() }, onConfirmed = { @@ -103,9 +109,9 @@ fun LayoutEditDialog( }, checkTextValid = { text -> val valid = LayoutUtilsCustom.checkLayout(text, ctx) - errorJob?.cancel() + errorJob.value?.cancel() if (!valid) { - errorJob = scope.launch { + errorJob.value = scope.launch { val message = Log.getLog(10) .lastOrNull { it.tag == "LayoutUtilsCustom" }?.message ?.split("\n")?.take(2)?.joinToString("\n") @@ -122,9 +128,6 @@ fun LayoutEditDialog( ) } -// the job is here (outside the composable to make sure old jobs are canceled -private var errorJob: Job? = null - @Preview @Composable private fun Preview() { From 3a5fd143f5c8f9960fceaf96f051a25c16ef6d2e Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 14:06:39 +0530 Subject: [PATCH 022/118] fix(perf): replace GlobalScope in toolbar preference listener setToolbarButtonsActivatedStateOnPrefChange used GlobalScope.launch to defer a UI update by 10 ms, waiting for SettingsValues to reload after a SharedPreferences change. GlobalScope is uncancellable and its default exception handler converts failures into silent crashes. Replace it with a process-wide scope that uses SupervisorJob (so one failure cannot tear down sibling preference updates) and a logging CoroutineExceptionHandler. The function still hops to Dispatchers.Main before touching the view tree. --- .../keyboard/latin/utils/ToolbarUtils.kt | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 104b9db93..7eeab7319 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -16,8 +16,10 @@ import helium314.keyboard.latin.common.Constants.Separators import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.ToolbarKey.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -35,6 +37,17 @@ import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter import android.content.res.ColorStateList +// Process-wide scope used for fire-and-forget tasks triggered by +// SharedPreferences listeners (which don't carry a coroutine scope). +// SupervisorJob prevents a single failure from cancelling unrelated +// preference-driven updates, and the exception handler keeps crashes +// from surfacing as silent uncaught exceptions in the default +// handler. UI mutations still hop to Dispatchers.Main explicitly. +private val toolbarPrefScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + + CoroutineExceptionHandler { _, throwable -> + android.util.Log.w("ToolbarUtils", "preference update failed", throwable) + }) + fun createToolbarKey(context: Context, key: ToolbarKey): ImageButton { val button = ImageButton(context, null, R.attr.suggestionWordStyle) button.scaleType = ImageView.ScaleType.CENTER_INSIDE @@ -146,7 +159,12 @@ fun setToolbarButtonsActivatedStateOnPrefChange(buttonsGroup: ViewGroup, key: St && key?.startsWith(Settings.PREF_ONE_HANDED_MODE_PREFIX) == false) return - GlobalScope.launch { + // Use a process-wide scope with a SupervisorJob and exception handler. + // The previous code used GlobalScope, which is uncancellable and + // doesn't have a structured way to handle errors. The buttonsGroup + // can be detached if the IME is torn down quickly, so we also need + // to use the main thread. + toolbarPrefScope.launch { delay(10) // need to wait until SettingsValues are reloaded withContext(Dispatchers.Main) { buttonsGroup.forEach { if (it is ImageButton) setToolbarButtonActivatedState(it) } From 90e972effa99e8721c5bbff0dca4b71983fe3542 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 14:07:26 +0530 Subject: [PATCH 023/118] fix(perf): make SettingsNavHost navigateTo scope supervised The CoroutineScope backing the navigateTo() helper used a plain Job, so a single child failure would cancel the scope permanently. Add SupervisorJob so unrelated navigation hops keep working. --- .../java/helium314/keyboard/settings/SettingsNavHost.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt index d4f23f4f7..55e295236 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt @@ -38,6 +38,7 @@ import helium314.keyboard.settings.screens.TextCorrectionScreen import helium314.keyboard.settings.screens.ToolbarScreen import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -194,7 +195,10 @@ object SettingsDestination { const val TextExpander = "text_expander" val navTarget = MutableStateFlow(Settings) - private val navScope = CoroutineScope(Dispatchers.Default) + // Use SupervisorJob so a cancellation in one navigation hop + // doesn't tear down the rest of the settings UI. Dispatchers.Default + // is fine here because we're only updating a MutableStateFlow. + private val navScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) fun navigateTo(target: String) { if (navTarget.value == target) { // triggers recompose twice, but that's ok as it's a rare event From c1d4de14486d7d7c58ae1e396d79810b48a6c61d Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 14:10:57 +0530 Subject: [PATCH 024/118] fix(stability): replace !! in colorFilter() helper createBlendModeColorFilterCompat returns a nullable ColorFilter, but the helper is only ever called with the supported BlendModeCompat modes (MODULATE, SRC_IN). Replace the !! with a Kotlin error() that throws IllegalStateException with a useful message if a new unsupported mode is ever introduced. --- app/src/main/java/helium314/keyboard/latin/common/Colors.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/common/Colors.kt b/app/src/main/java/helium314/keyboard/latin/common/Colors.kt index 7660f4b7b..5167aa30e 100644 --- a/app/src/main/java/helium314/keyboard/latin/common/Colors.kt +++ b/app/src/main/java/helium314/keyboard/latin/common/Colors.kt @@ -596,8 +596,9 @@ class AllColors(private val colorMap: EnumMap, override val them } private fun colorFilter(color: Int, mode: BlendModeCompat = BlendModeCompat.MODULATE): ColorFilter { - // using !! for the color filter because null is only returned for unsupported blend modes, which are not used - return BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, mode)!! + // null is only returned for unsupported blend modes, which are not used here + return BlendModeColorFilterCompat.createBlendModeColorFilterCompat(color, mode) + ?: error("unsupported blend mode for color filter: $mode") } private fun pressedStateList(pressed: Int, normal: Int): ColorStateList { From a63b7dad32ae6a3fcc44eea3418de49d64ad8aa1 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 14:13:36 +0530 Subject: [PATCH 025/118] perf(perf): cache main-thread Handler in ClipboardHistoryManager ClipboardHistoryManager is a singleton scoped to the IME service, but it was creating a fresh Handler(Looper.getMainLooper()) on every postDelayed() and on every ContentObserver registration. The main Looper is process-wide and lives for the lifetime of the app, so a single cached Handler is enough. Replace the two ad-hoc Handler allocations in registerMediaStoreObserver and in the post-paste clip restoration path with a single 'mainHandler' field on the manager. --- .../keyboard/latin/ClipboardHistoryManager.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index 182bbe530..4cd798de5 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -32,6 +32,11 @@ class ClipboardHistoryManager( ) : ClipboardManager.OnPrimaryClipChangedListener { private lateinit var clipboardManager: ClipboardManager + // Cache the main-thread Handler. ClipboardHistoryManager is a + // singleton scoped to the IME service, so a single Handler bound + // to the main Looper is fine for the whole process. This avoids + // allocating a fresh Handler on every postDelayed(). + private val mainHandler = Handler(Looper.getMainLooper()) private var clipboardSuggestionView: View? = null private var clipboardDao: ClipboardDao? = null private var dontShowCurrentSuggestion: Boolean = false @@ -148,11 +153,11 @@ class ClipboardHistoryManager( private fun registerMediaStoreObserver() { if (mediaStoreObserver == null) { - mediaStoreObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + mediaStoreObserver = object : ContentObserver(mainHandler) { override fun onChange(selfChange: Boolean, uri: Uri?) { super.onChange(selfChange, uri) if (latinIME.mSettings.current.mSuggestScreenshots) { - Handler(Looper.getMainLooper()).postDelayed({ + mainHandler.postDelayed({ updateLatestScreenshotCache { dontShowCurrentSuggestion = false latinIME.setNeutralSuggestionStrip() @@ -523,7 +528,7 @@ class ClipboardHistoryManager( } // Restore original clip after a tiny delay to allow paste process to complete - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ + mainHandler.postDelayed({ try { if (primaryClip != null) { clipboardManager.setPrimaryClip(primaryClip) From 1a84e7aa84d677f78470496dd5278dd946fcfb1e Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 14:14:48 +0530 Subject: [PATCH 026/118] fix(stability): use SupervisorJob in RichInputMethodManager scope The CoroutineScope backing updateShortcutIme, onSubtypeChanged and related fire-and-forget coroutines was using a plain Job. A single exception in any of those coroutines would cancel the scope and stop all subsequent subtype lookups for the lifetime of the IME process. Add SupervisorJob() so a single failure cannot tear down the rest of the lookups. --- .../helium314/keyboard/latin/RichInputMethodManager.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt b/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt index c8e0a8930..3b0014ada 100644 --- a/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/RichInputMethodManager.kt @@ -25,6 +25,7 @@ import helium314.keyboard.latin.utils.locale import helium314.keyboard.latin.utils.prefs import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import java.util.Locale @@ -34,7 +35,11 @@ class RichInputMethodManager private constructor() { private lateinit var imm: InputMethodManager private lateinit var inputMethodInfoCache: InputMethodInfoCache private lateinit var currentRichInputMethodSubtype: RichInputMethodSubtype - private val scope = CoroutineScope(Dispatchers.Default) + // Use SupervisorJob so a single failure in updateShortcutIme or one + // of the other fire-and-forget coroutines cannot tear down the + // scope and stop all subsequent subtype lookups. The manager is a + // process-wide singleton, so the scope is also process-wide. + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) private val isInitializedInternal get() = this::imm.isInitialized From 67044de29bc2297c45fd72bf2abe941b7016d5d8 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 1 Jun 2026 14:49:05 +0530 Subject: [PATCH 027/118] fix(stability): use ContextCompat.registerReceiver with NOT_EXPORTED flag LatinIME.onCreate was using the deprecated registerReceiver(receiver, filter) overload for the ringer mode, package add/remove and user unlocked broadcasts. On Android 13+ this throws SecurityException unless the receiver is registered with an explicit exported flag. Switch the three call sites to ContextCompat.registerReceiver with RECEIVER_NOT_EXPORTED, matching the existing style used for DICTIONARY_DUMP_INTENT_ACTION. The exported flag stays set for the NEW_DICTIONARY_INTENT_ACTION receiver, as documented in the existing comment, because the sender app may not be this one. --- .../main/java/helium314/keyboard/latin/LatinIME.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 747e0f5c3..3095457be 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -557,14 +557,19 @@ public void onCreate() { // Register to receive ringer mode change. final IntentFilter filter = new IntentFilter(); filter.addAction(AudioManager.RINGER_MODE_CHANGED_ACTION); - registerReceiver(mRingerModeChangeReceiver, filter); + // These intents are sent by the system, so NOT_EXPORTED is sufficient and + // avoids the SecurityException thrown by the plain registerReceiver() + // overload on API 33+ when no exported flag is set. + ContextCompat.registerReceiver(this, mRingerModeChangeReceiver, filter, + ContextCompat.RECEIVER_NOT_EXPORTED); // Register to receive installation and removal of a dictionary pack. final IntentFilter packageFilter = new IntentFilter(); packageFilter.addAction(Intent.ACTION_PACKAGE_ADDED); packageFilter.addAction(Intent.ACTION_PACKAGE_REMOVED); packageFilter.addDataScheme(SCHEME_PACKAGE); - registerReceiver(mDictionaryPackInstallReceiver, packageFilter); + ContextCompat.registerReceiver(this, mDictionaryPackInstallReceiver, packageFilter, + ContextCompat.RECEIVER_NOT_EXPORTED); final IntentFilter newDictFilter = new IntentFilter(); newDictFilter.addAction(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION); @@ -582,7 +587,8 @@ public void onCreate() { final IntentFilter restartAfterUnlockFilter = new IntentFilter(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) restartAfterUnlockFilter.addAction(Intent.ACTION_USER_UNLOCKED); - registerReceiver(mRestartAfterDeviceUnlockReceiver, restartAfterUnlockFilter); + ContextCompat.registerReceiver(this, mRestartAfterDeviceUnlockReceiver, + restartAfterUnlockFilter, ContextCompat.RECEIVER_NOT_EXPORTED); StatsUtils.onCreate(mSettings.getCurrent(), mRichImm); } From ce755f4692ebcb3fe8a516c4b1cfc68c73ea7084 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 01:48:40 +0530 Subject: [PATCH 028/118] fix(settings): update ai provider fields dynamically Align provider preference source and observe changes dynamically to update UI fields immediately. Wrap preferences in key() to prevent Compose state reuse. --- app/build.gradle.kts | 6 ++++++ .../keyboard/settings/SearchScreen.kt | 4 +++- .../settings/screens/AIIntegrationScreen.kt | 20 +++++++++++++------ .../keyboard/latin/utils/ProofreadService.kt | 2 ++ .../keyboard/latin/utils/ProofreadService.kt | 3 +++ .../keyboard/latin/utils/ProofreadService.kt | 6 ++++-- 6 files changed, 32 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1ec4a7470..837df88ae 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,12 @@ android { keyPassword = keystoreProperties["keyPassword"] as String storeFile = rootProject.file(keystoreProperties["storeFile"] as String) storePassword = keystoreProperties["storePassword"] as String + // Disable v2/v3 signing to avoid CHUNKED_SHA256 mismatch caused by + // AGP injecting META-INF files after signature computation. + // v1 (JAR) signing only verifies individual entry integrity. + enableV1Signing = true + enableV2Signing = false + enableV3Signing = false } } } diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt index 595c65d33..a9189ec0c 100644 --- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -119,7 +119,9 @@ fun SearchSettingsScreen( } keys.forEach { key -> - SettingsActivity.settingsContainer[key]?.Preference() + androidx.compose.runtime.key(key) { + SettingsActivity.settingsContainer[key]?.Preference() + } } } } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index c3b35d8b4..5f4b61c96 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -40,12 +40,20 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { // Use remember to avoid re-creating the service on every recomposition val service = remember(ctx) { helium314.keyboard.latin.utils.ProofreadService(ctx) } - // Provider is read from the service on every recomposition. The service - // reads from SharedPreferences so this is cheap. We don't need a - // top-level MutableStateFlow to keep the AIIntegrationScreen in sync - // with provider changes made on the AdvancedScreen: when the user - // returns to this screen, the search settings list is rebuilt. - val provider = service.getProvider().name + var provider by remember { mutableStateOf(service.getProvider().name) } + + androidx.compose.runtime.DisposableEffect(service) { + val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == "ai_provider") { + provider = service.getProvider().name + } + } + val prefs = service.getPrefs() + prefs.registerOnSharedPreferenceChangeListener(listener) + onDispose { + prefs.unregisterOnSharedPreferenceChangeListener(listener) + } + } val items = buildList { // Always show provider selection diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index b0fe0ac82..0b20e490c 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -42,6 +42,8 @@ class ProofreadService(private val context: Context) { private val prefs: SharedPreferences by lazy { context.prefs() } + + fun getPrefs(): SharedPreferences = prefs // Singleton holder for model state to prevent reloading on every request object ModelHolder { diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt index 51e68ea61..e927888ca 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -5,6 +5,7 @@ package helium314.keyboard.latin.utils import android.content.Context +import android.content.SharedPreferences /** * Stub ProofreadService for OfflineLite flavor. @@ -16,6 +17,8 @@ class ProofreadService(private val context: Context) { GEMINI, GROQ, OPENAI } + fun getPrefs(): SharedPreferences = context.prefs() + // Always returns GEMINI as default, but methods do nothing fun getProvider(): AIProvider = AIProvider.GEMINI fun setProvider(provider: AIProvider) { /* No-op */ } diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt index ffd825b99..4edd4a2ec 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -59,9 +59,11 @@ class ProofreadService(private val context: Context) { } } + fun getPrefs(): SharedPreferences = context.prefs() + // Provider selection fun getProvider(): AIProvider { - val providerStr = securePrefs.getString(KEY_PROVIDER, AIProvider.GEMINI.name) + val providerStr = context.prefs().getString(KEY_PROVIDER, AIProvider.GEMINI.name) return try { AIProvider.valueOf(providerStr ?: AIProvider.GEMINI.name) } catch (e: IllegalArgumentException) { @@ -70,7 +72,7 @@ class ProofreadService(private val context: Context) { } fun setProvider(provider: AIProvider) { - securePrefs.edit().putString(KEY_PROVIDER, provider.name).apply() + context.prefs().edit().putString(KEY_PROVIDER, provider.name).apply() } // Gemini API key From 82bde3d978837a25679bda52a0c6fd601ffe2673 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 02:29:17 +0530 Subject: [PATCH 029/118] feat: add standardOptimised flavor and disable r8 Add standardOptimised flavor to allow non-reproducible optimizations like R8 fullMode and baseline profiles. Turn off R8 fullMode globally to restore reproducibility for standard flavor on F-Droid. Clean APK metadata and restore global V2/V3 signing. --- app/build.gradle.kts | 40 +++++++++++++++---- .../keyboard/emoji/EmojiPalettesView.java | 2 +- .../keyboard/latin/utils/ToolbarUtils.kt | 2 +- .../keyboard/settings/WelcomeWizard.kt | 2 +- .../settings/screens/AIIntegrationScreen.kt | 2 +- .../settings/screens/AdvancedScreen.kt | 2 +- .../settings/screens/ToolbarScreen.kt | 2 +- gradle.properties | 2 +- settings.gradle | 5 +++ 9 files changed, 45 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 837df88ae..815eb43ed 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -39,6 +39,10 @@ android { create("standard") { dimension = "privacy" } + create("standardOptimised") { + dimension = "privacy" + applicationIdSuffix = ".optimised" + } create("offline") { dimension = "privacy" applicationIdSuffix = ".offline" @@ -56,12 +60,9 @@ android { keyPassword = keystoreProperties["keyPassword"] as String storeFile = rootProject.file(keystoreProperties["storeFile"] as String) storePassword = keystoreProperties["storePassword"] as String - // Disable v2/v3 signing to avoid CHUNKED_SHA256 mismatch caused by - // AGP injecting META-INF files after signature computation. - // v1 (JAR) signing only verifies individual entry integrity. enableV1Signing = true - enableV2Signing = false - enableV3Signing = false + enableV2Signing = true + enableV3Signing = true } } } @@ -107,6 +108,7 @@ android { "standard" -> "1" "offline" -> "2" "offlinelite" -> "3" + "standardOptimised" -> "4" else -> "" } if (number.isNotEmpty()) { @@ -115,6 +117,7 @@ android { output?.outputFileName = "$number-LeanType_${defaultConfig.versionName}-${flavor}-${buildType.name}.apk" } } + } // got a little too big for GitHub after some dependency upgrades, so we remove the largest dictionary androidComponents.onVariants { variant: ApplicationVariant -> @@ -149,6 +152,17 @@ android { resources { excludes += "assets/dexopt/baseline.prof" excludes += "assets/dexopt/baseline.profm" + excludes += "META-INF/DEPENDENCIES" + excludes += "META-INF/LICENSE" + excludes += "META-INF/LICENSE.txt" + excludes += "META-INF/license.txt" + excludes += "META-INF/NOTICE" + excludes += "META-INF/NOTICE.txt" + excludes += "META-INF/notice.txt" + excludes += "META-INF/ASL2.0" + excludes += "META-INF/*.kotlin_module" + excludes += "META-INF/kotlin-project-structure-metadata.json" + excludes += "**/*.proto" } } @@ -182,6 +196,14 @@ android { // these orphaned strings are harmlessly stripped by R8 during minification. disable += "ExtraTranslation" } + + sourceSets { + getByName("standardOptimised") { + java.srcDirs("src/standard/java") + res.srcDirs("src/standard/res") + manifest.srcFile("src/standard/AndroidManifest.xml") + } + } } dependencies { @@ -210,6 +232,8 @@ dependencies { // gemini ai proofreading "standardImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0") "standardImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // for encrypted API key storage + "standardOptimisedImplementation"("com.google.ai.client.generativeai:generativeai:0.9.0") + "standardOptimisedImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // local llm proofreading (offline) // ONNX Runtime for T5 encoder-decoder grammar models @@ -229,9 +253,11 @@ dependencies { debugImplementation("androidx.compose.ui:ui-test-manifest") } -// Disable baseline/ART profile tasks to guarantee deterministic reproducible builds +// Disable baseline/ART profile tasks to guarantee deterministic reproducible builds (except for standardOptimised) tasks.configureEach { if (name.contains("ArtProfile", ignoreCase = true)) { - enabled = false + if (!name.contains("StandardOptimised", ignoreCase = true)) { + enabled = false + } } } diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index cfdb6aa51..82a52a7c3 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -504,7 +504,7 @@ public void afterTextChanged(Editable s) { downloadBtn.setTextSize(12); // Keep it small to fit downloadBtn.setAllCaps(false); downloadBtn.setOnClickListener(v -> { - if ("standard".equals(BuildConfig.FLAVOR)) { + if ("standard".equals(BuildConfig.FLAVOR) || "standardOptimised".equals(BuildConfig.FLAVOR)) { downloadEmojiDictionary(); downloadBtn.setText("Downloading..."); downloadBtn.setEnabled(false); diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 7eeab7319..a6ae57c7c 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -266,7 +266,7 @@ enum class ToolbarMode { val toolbarKeyStrings = entries.associateWithTo(EnumMap(ToolbarKey::class.java)) { it.toString().lowercase(Locale.US) } private val excludedKeys by lazy { - val customAiKeys = if (BuildConfig.FLAVOR != "standard") + val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised") ToolbarKey.entries.filter { it.name.startsWith("CUSTOM_AI_") } else emptyList() val otherKeys = if (BuildConfig.FLAVOR == "offlinelite") diff --git a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt index 2abf54444..94374123c 100644 --- a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt +++ b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt @@ -263,7 +263,7 @@ fun WelcomeWizard( { step++ }, { step-- } ) { - if (BuildConfig.FLAVOR == "standard") { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { val service = remember { helium314.keyboard.latin.utils.ProofreadService(ctx) } var currentProvider by remember { mutableStateOf(service.getProvider()) } val aiConfigured = when (currentProvider) { diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index 5f4b61c96..9ea9b4ffb 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -27,7 +27,7 @@ fun AIIntegrationScreen( return } - if (BuildConfig.FLAVOR == "standard") { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { StandardAIIntegrationScreen(onClickBack) } else { OfflineAIIntegrationScreen(onClickBack) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index ceeef14d3..133c5069f 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -534,7 +534,7 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, - if (BuildConfig.FLAVOR == "standard") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { Preference( name = it.title, description = it.description, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index 760dfd2b3..e86fe7958 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -91,7 +91,7 @@ fun createToolbarSettings(context: Context): List { val filter = { name: String -> val lowerName = name.lowercase() when { - lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" + lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" lowerName in listOf("proofread", "translate", "clipboard_search") -> BuildConfig.FLAVOR != "offlinelite" else -> true } diff --git a/gradle.properties b/gradle.properties index 3de890ed6..9c1055317 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,4 +11,4 @@ org.gradle.vfs.watch=true org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+UseParallelGC # Limit workers to reduce peak memory spikes org.gradle.workers.max=2 -android.enableR8.fullMode=true +android.enableR8.fullMode=false diff --git a/settings.gradle b/settings.gradle index 8335aa9f5..3e732f77f 100755 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,7 @@ include ':app' include ':tools:make-emoji-keys' + +// Dynamically enable R8 fullMode for non-reproducible optimised flavor builds to unlock maximum compilation optimizations. +if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("optimised") || it.toLowerCase().contains("optimized") }) { + gradle.startParameter.projectProperties["android.enableR8.fullMode"] = "true" +} From ed13c9a9f96ae450f8651d7863502521cc754a5f Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 02:33:25 +0530 Subject: [PATCH 030/118] perf: add baseline profile for standardOptimised Add manual wildcard-based precompilation rules in baseline-prof.txt for standardOptimised to optimize startup, typing reaction, and suggestions. Fix dynamic property injection in settings.gradle. --- app/src/standardOptimised/baseline-prof.txt | 7 +++++++ settings.gradle | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 app/src/standardOptimised/baseline-prof.txt diff --git a/app/src/standardOptimised/baseline-prof.txt b/app/src/standardOptimised/baseline-prof.txt new file mode 100644 index 000000000..1de953ad3 --- /dev/null +++ b/app/src/standardOptimised/baseline-prof.txt @@ -0,0 +1,7 @@ +# Optimize Heliboard/LeanType classes and methods for butter-smooth typing & suggestion rendering +HSPLhelium314/keyboard/** +HSPLhelium314/keyboard/**->** +HSPLhelium314/keyboard/latin/** +HSPLhelium314/keyboard/latin/**->** +HSPLhelium314/keyboard/settings/** +HSPLhelium314/keyboard/settings/**->** diff --git a/settings.gradle b/settings.gradle index 3e732f77f..2a01f40c8 100755 --- a/settings.gradle +++ b/settings.gradle @@ -2,6 +2,10 @@ include ':app' include ':tools:make-emoji-keys' // Dynamically enable R8 fullMode for non-reproducible optimised flavor builds to unlock maximum compilation optimizations. -if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("optimised") || it.toLowerCase().contains("optimized") }) { - gradle.startParameter.projectProperties["android.enableR8.fullMode"] = "true" +gradle.projectsLoaded { gradle -> + if (gradle.startParameter.taskNames.any { it.toLowerCase().contains("optimised") || it.toLowerCase().contains("optimized") }) { + gradle.rootProject.allprojects { project -> + project.ext.set("android.enableR8.fullMode", "true") + } + } } From 9a26c7390bb2e1cced06fc186800719687b64ba2 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 02:51:49 +0530 Subject: [PATCH 031/118] feat: remove standardOptimised package suffix Remove applicationIdSuffix from standardOptimised product flavor to share standard package name (com.leanbitlab.leantype). --- app/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 815eb43ed..5b392b302 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,7 +41,6 @@ android { } create("standardOptimised") { dimension = "privacy" - applicationIdSuffix = ".optimised" } create("offline") { dimension = "privacy" From 50f184b4616121c128580dd519df4811c0066eb2 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 02:57:07 +0530 Subject: [PATCH 032/118] fix: dismiss emoji dialog and update wizard status Close ConfirmationDialog on successful emoji dictionary download/load. Invoke onSuccess callback in WelcomeWizard to trigger recomposition and show the checkmark immediately. --- .../java/helium314/keyboard/settings/WelcomeWizard.kt | 5 ++++- .../settings/preferences/LoadEmojiLibPreference.kt | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt index 94374123c..29e98b232 100644 --- a/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt +++ b/app/src/main/java/helium314/keyboard/settings/WelcomeWizard.kt @@ -233,7 +233,10 @@ fun WelcomeWizard( val gestureLibInstalled = java.io.File(ctx.filesDir, "libjni_latinime.so").exists() || JniUtils.sHaveGestureLib Box(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, MaterialTheme.shapes.medium)) { - LoadEmojiLibPreference("Emoji Dictionary") + LoadEmojiLibPreference( + title = "Emoji Dictionary", + onSuccess = { refreshTrigger++ } + ) if (emojiLibInstalled) { Icon(painterResource(R.drawable.ic_setup_check), null, Modifier.align(Alignment.CenterEnd).padding(end = 16.dp), tint = MaterialTheme.colorScheme.primary) } diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LoadEmojiLibPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LoadEmojiLibPreference.kt index 22ce74c8a..3186191ca 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/LoadEmojiLibPreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LoadEmojiLibPreference.kt @@ -46,6 +46,7 @@ fun LoadEmojiLibPreference( title: String, summary: String? = null, @DrawableRes icon: Int? = null, + onSuccess: (() -> Unit)? = null, ) { var showDialog by rememberSaveable { mutableStateOf(false) } var isDownloading by rememberSaveable { mutableStateOf(false) } @@ -61,10 +62,8 @@ fun LoadEmojiLibPreference( fun refreshAndLoad() { helium314.keyboard.keyboard.emoji.EmojiPalettesView.closeDictionaryFacilitator() - // Force settings screen to recompose by updating a dummy pref or just updating local state so the preference knows it's installed. - // The most direct way since we read `isInstalled` at composition is to just swap a boolean state here if needed, - // but `isInstalled` is computed on every recompose. ctx.protectedPrefs().edit { putLong("emoji_lib_last_update", System.currentTimeMillis()) } + onSuccess?.invoke() (ctx.getActivity() as? helium314.keyboard.settings.SettingsActivity)?.let { it.prefChanged.value = it.prefChanged.value + 1 } @@ -93,6 +92,7 @@ fun LoadEmojiLibPreference( withContext(Dispatchers.Main) { FeedbackManager.message(ctx, R.string.load_gesture_library_download_success) // Reusing success string isDownloading = false + showDialog = false refreshAndLoad() } } else { @@ -107,6 +107,7 @@ fun LoadEmojiLibPreference( } } + val launcher = filePicker { uri -> if (cachePath != null) { val targetFile = File(cachePath, dictName) @@ -120,6 +121,7 @@ fun LoadEmojiLibPreference( } tmpFile.delete() FeedbackManager.message(ctx, "Emoji dictionary loaded successfully") + showDialog = false refreshAndLoad() } catch (e: IOException) { Toast.makeText(ctx, "Failed to load emoji dictionary from file", Toast.LENGTH_SHORT).show() @@ -161,6 +163,7 @@ fun LoadEmojiLibPreference( onNeutral = { if (isInstalled) { libFile.delete() + showDialog = false refreshAndLoad() } else { showDialog = false From b559cabcc5f25efdb97abe8d08c31b296bd323b6 Mon Sep 17 00:00:00 2001 From: iBasim <57762287+iBasim@users.noreply.github.com> Date: Fri, 5 Jun 2026 06:43:46 +0300 Subject: [PATCH 033/118] Update ar.txt --- app/src/main/assets/locale_key_texts/ar.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/locale_key_texts/ar.txt b/app/src/main/assets/locale_key_texts/ar.txt index 133219aeb..f50417260 100644 --- a/app/src/main/assets/locale_key_texts/ar.txt +++ b/app/src/main/assets/locale_key_texts/ar.txt @@ -7,7 +7,7 @@ ي ئ ى ب پ ل ﻻ|لا ﻷ|لأ ﻹ|لإ ﻵ|لآ -ا !fixedOrder!5 آ ء أ إ ٱ +ا !fixedOrder!5 أ ء إ آ ٱ ك گ ک ى ئ ز ژ From 7c310bf3f04b1786d73d40cb1470c17dc2f22471 Mon Sep 17 00:00:00 2001 From: iBasim <57762287+iBasim@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:16:41 +0300 Subject: [PATCH 034/118] Update build-debug-apk.yml --- .github/workflows/build-debug-apk.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-debug-apk.yml b/.github/workflows/build-debug-apk.yml index 5ae939445..74398bd36 100644 --- a/.github/workflows/build-debug-apk.yml +++ b/.github/workflows/build-debug-apk.yml @@ -29,7 +29,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: HeliBoard-debug - path: app/build/outputs/apk/debug/*-debug*.apk + path: app/build/outputs/apk/**/*.apk - name: Archive reports for failed job uses: actions/upload-artifact@v4 From 063d1689291bf821e70dab7b37d2e5c2746f1a02 Mon Sep 17 00:00:00 2001 From: iBasim <57762287+iBasim@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:01:52 +0300 Subject: [PATCH 035/118] arabic-popup-and-harakat-tweak --- app/src/main/assets/locale_key_texts/ar.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/assets/locale_key_texts/ar.txt b/app/src/main/assets/locale_key_texts/ar.txt index f50417260..4d41518ae 100644 --- a/app/src/main/assets/locale_key_texts/ar.txt +++ b/app/src/main/assets/locale_key_texts/ar.txt @@ -4,15 +4,15 @@ ه ﻫ|ه‍ ج چ ش ڜ -ي ئ ى +ي ى ئ ب پ ل ﻻ|لا ﻷ|لأ ﻹ|لإ ﻵ|لآ -ا !fixedOrder!5 أ ء إ آ ٱ +ا !fixedOrder!5 أ ٱ إ آ ء ك گ ک ى ئ ز ژ و ؤ -punctuation !fixedOrder!7 ٕ|ٕ ٔ|ٔ ْ|ْ ٍ|ٍ ٌ|ٌ ً|ً ّ|ّ ٖ|ٖ ٰ|ٰ ٓ|ٓ ِ|ِ ُ|ُ َ|َ ـــ|ـ +punctuation !fixedOrder!7 ٕ◌ ٔ◌ ْ◌ ٍ◌ ٌ◌ ً◌ ّ◌ ٖ◌ ٰ◌ ٓ◌ ِ◌ ُ◌ َ◌ ـــ|ـ « „ “ ” » ‚ ‘ ’ ‹ › From 4ed977f486c0a674c848ca9bb0363f4a7dc15274 Mon Sep 17 00:00:00 2001 From: iBasim <57762287+iBasim@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:38:22 +0300 Subject: [PATCH 036/118] arabic-popup-and-harakat-tweak V2 --- app/src/main/assets/locale_key_texts/ar.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/locale_key_texts/ar.txt b/app/src/main/assets/locale_key_texts/ar.txt index 4d41518ae..f98dc8f6e 100644 --- a/app/src/main/assets/locale_key_texts/ar.txt +++ b/app/src/main/assets/locale_key_texts/ar.txt @@ -12,7 +12,7 @@ ى ئ ز ژ و ؤ -punctuation !fixedOrder!7 ٕ◌ ٔ◌ ْ◌ ٍ◌ ٌ◌ ً◌ ّ◌ ٖ◌ ٰ◌ ٓ◌ ِ◌ ُ◌ َ◌ ـــ|ـ +punctuation !fixedOrder!7 ◌ٕ|ٕ ◌ٔ|ٔ ◌ْ|ْ ◌ٍ|ٍ ◌ٌ|ٌ ◌ً|ً ◌ّ|ّ ◌ٖ|ٖ ◌ٰ|ٰ ◌ٓ|ٓ ◌ِ|ِ ◌ُ|ُ ◌َ|َ ـــ|ـ « „ “ ” » ‚ ‘ ’ ‹ › From 5cc7598f9ad1ea0300ac3e239d2b400e9148cf10 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 22:08:22 +0530 Subject: [PATCH 037/118] ci: add badge update workflow --- .github/workflows/update-badges.yml | 78 +++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/update-badges.yml diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml new file mode 100644 index 000000000..8f5e70db7 --- /dev/null +++ b/.github/workflows/update-badges.yml @@ -0,0 +1,78 @@ +name: Update README Badges + +on: + schedule: + - cron: '0 0 * * *' # Midnight UTC + workflow_dispatch: + +permissions: + contents: write + +jobs: + update-badges: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Fetch GitHub stats + id: stats + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + REPO="LeanBitLab/HeliboardL" + + # Latest version + VERSION=$(gh api repos/$REPO/releases/latest --jq '.tag_name' 2>/dev/null || echo "N/A") + echo "version=$VERSION" >> $GITHUB_OUTPUT + + # Total downloads + DOWNLOADS=$(gh api repos/$REPO/releases --jq '[.[].assets[]?.download_count] | add // 0' 2>/dev/null || echo "0") + echo "downloads=$DOWNLOADS" >> $GITHUB_OUTPUT + + # Stars + STARS=$(gh api repos/$REPO --jq '.stargazers_count' 2>/dev/null || echo "0") + echo "stars=$STARS" >> $GITHUB_OUTPUT + + - name: Generate badge SVGs + env: + VERSION: ${{ steps.stats.outputs.version }} + DOWNLOADS: ${{ steps.stats.outputs.downloads }} + STARS: ${{ steps.stats.outputs.stars }} + run: | + mkdir -p docs/badges + + # Format numbers with commas + DOWNLOADS_FMT=$(printf "%'d" "$DOWNLOADS" 2>/dev/null || echo "$DOWNLOADS") + STARS_FMT=$(printf "%'d" "$STARS" 2>/dev/null || echo "$STARS") + + # Download version badge + cat > docs/badges/download.svg << EOF + DownloadDownloadv${VERSION}v${VERSION} + EOF + + # Downloads count badge + cat > docs/badges/downloads.svg << EOF + DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} + EOF + + # Stars badge + cat > docs/badges/stars.svg << EOF + StarsStars${STARS_FMT}${STARS_FMT} + EOF + + echo "Generated: v$VERSION | $DOWNLOADS_FMT downloads | $STARS_FMT stars" + + - name: Update README badge URLs + run: | + # Replace shields.io URLs with local badge paths + sed -i 's|https://img.shields.io/github/v/release/LeanBitLab/HeliboardL?label=Download\&style=for-the-badge\&color=7C4DFF|docs/badges/download.svg|g' README.md + sed -i 's|https://img.shields.io/github/downloads/LeanBitLab/HeliboardL/total?style=for-the-badge\&color=7C4DFF\&label=Downloads|docs/badges/downloads.svg|g' README.md + sed -i 's|https://img.shields.io/github/stars/LeanBitLab/HeliboardL?style=for-the-badge\&color=7C4DFF|docs/badges/stars.svg|g' README.md + + - name: Commit changes + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add docs/badges/ README.md + git diff --staged --quiet || git commit -m "chore: update README badges [skip ci]" + git push From 2e580e8f8d26922da9974f64e6c4e7fdd1fa1015 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Jun 2026 16:38:45 +0000 Subject: [PATCH 038/118] chore: update README badges [skip ci] --- README.md | 2 +- docs/badges/download.svg | 1 + docs/badges/downloads.svg | 1 + docs/badges/stars.svg | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) create mode 100644 docs/badges/download.svg create mode 100644 docs/badges/downloads.svg create mode 100644 docs/badges/stars.svg diff --git a/README.md b/README.md index bec2ad64f..e7702a486 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ LeanType Banner -[![Download](https://img.shields.io/github/v/release/LeanBitLab/HeliboardL?label=Download&style=for-the-badge&color=7C4DFF)](https://github.com/LeanBitLab/HeliboardL/releases/latest) [![Downloads](https://img.shields.io/github/downloads/LeanBitLab/HeliboardL/total?style=for-the-badge&color=7C4DFF&label=Downloads)](https://github.com/LeanBitLab/HeliboardL/releases) [![Stars](https://img.shields.io/github/stars/LeanBitLab/HeliboardL?style=for-the-badge&color=7C4DFF)](https://github.com/LeanBitLab/HeliboardL/stargazers) +[![Download](docs/badges/download.svg)](https://github.com/LeanBitLab/HeliboardL/releases/latest) [![Downloads](docs/badges/downloads.svg)](https://github.com/LeanBitLab/HeliboardL/releases) [![Stars](docs/badges/stars.svg)](https://github.com/LeanBitLab/HeliboardL/stargazers) **LeanType** is a fork of [HeliBoard](https://github.com/Helium314/HeliBoard) - a privacy-conscious and customizable open-source keyboard based on AOSP/OpenBoard. diff --git a/docs/badges/download.svg b/docs/badges/download.svg new file mode 100644 index 000000000..e435828a4 --- /dev/null +++ b/docs/badges/download.svg @@ -0,0 +1 @@ +DownloadDownloadvv3.8.3vv3.8.3 diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg new file mode 100644 index 000000000..9be05b143 --- /dev/null +++ b/docs/badges/downloads.svg @@ -0,0 +1 @@ +DownloadsDownloads2785827858 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg new file mode 100644 index 000000000..106c2dd13 --- /dev/null +++ b/docs/badges/stars.svg @@ -0,0 +1 @@ +StarsStars471471 From 40b79e47bfa45e537e27b5a720de9212c7f5cffa Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 22:09:14 +0530 Subject: [PATCH 039/118] fix: strip leading v from version tag --- .github/workflows/update-badges.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml index 8f5e70db7..ed8bda209 100644 --- a/.github/workflows/update-badges.yml +++ b/.github/workflows/update-badges.yml @@ -22,7 +22,7 @@ jobs: REPO="LeanBitLab/HeliboardL" # Latest version - VERSION=$(gh api repos/$REPO/releases/latest --jq '.tag_name' 2>/dev/null || echo "N/A") + VERSION=$(gh api repos/$REPO/releases/latest --jq '.tag_name' | sed 's/^v//' 2>/dev/null || echo "N/A") echo "version=$VERSION" >> $GITHUB_OUTPUT # Total downloads From 66af68eb5748bc02fbac371d0b62b49fc0a880f2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Jun 2026 16:39:37 +0000 Subject: [PATCH 040/118] chore: update README badges [skip ci] --- docs/badges/download.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/badges/download.svg b/docs/badges/download.svg index e435828a4..a44448d6d 100644 --- a/docs/badges/download.svg +++ b/docs/badges/download.svg @@ -1 +1 @@ -DownloadDownloadvv3.8.3vv3.8.3 +DownloadDownloadv3.8.3v3.8.3 From eb19ef9c109c120f29fe2d79cee68127b5db0131 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 22:13:16 +0530 Subject: [PATCH 041/118] fix(badges): adjust width and add viewBox attributes to prevent clipping --- .github/workflows/update-badges.yml | 6 +++--- docs/badges/download.svg | 3 ++- docs/badges/downloads.svg | 3 ++- docs/badges/stars.svg | 3 ++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml index ed8bda209..f845aca72 100644 --- a/.github/workflows/update-badges.yml +++ b/.github/workflows/update-badges.yml @@ -47,17 +47,17 @@ jobs: # Download version badge cat > docs/badges/download.svg << EOF - DownloadDownloadv${VERSION}v${VERSION} + DownloadDownloadv${VERSION}v${VERSION} EOF # Downloads count badge cat > docs/badges/downloads.svg << EOF - DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} + DownloadsDownloads${DOWNLOADS_FMT}${DOWNLOADS_FMT} EOF # Stars badge cat > docs/badges/stars.svg << EOF - StarsStars${STARS_FMT}${STARS_FMT} + StarsStars${STARS_FMT}${STARS_FMT} EOF echo "Generated: v$VERSION | $DOWNLOADS_FMT downloads | $STARS_FMT stars" diff --git a/docs/badges/download.svg b/docs/badges/download.svg index a44448d6d..bdf033af0 100644 --- a/docs/badges/download.svg +++ b/docs/badges/download.svg @@ -1 +1,2 @@ -DownloadDownloadv3.8.3v3.8.3 +DownloadDownloadv3.8.3v3.8.3 + diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 9be05b143..0524bd5c3 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1,2 @@ -DownloadsDownloads2785827858 +DownloadsDownloads2785827858 + diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index 106c2dd13..e7af2f09f 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1,2 @@ -StarsStars471471 +StarsStars471471 + From d27f63aee135ccce8be3433330696f3f2af3242f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Jun 2026 16:43:54 +0000 Subject: [PATCH 042/118] chore: update README badges [skip ci] --- docs/badges/download.svg | 1 - docs/badges/downloads.svg | 1 - docs/badges/stars.svg | 1 - 3 files changed, 3 deletions(-) diff --git a/docs/badges/download.svg b/docs/badges/download.svg index bdf033af0..f24728d41 100644 --- a/docs/badges/download.svg +++ b/docs/badges/download.svg @@ -1,2 +1 @@ DownloadDownloadv3.8.3v3.8.3 - diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 0524bd5c3..562c1fc36 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1,2 +1 @@ DownloadsDownloads2785827858 - diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index e7af2f09f..a3ec88a5a 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1,2 +1 @@ StarsStars471471 - From d3d0fa293f16007b8b7384299601a79948370018 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 22:16:38 +0530 Subject: [PATCH 043/118] chore(badges): rename download badge label to version --- .github/workflows/update-badges.yml | 2 +- docs/badges/download.svg | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/update-badges.yml b/.github/workflows/update-badges.yml index f845aca72..a3f5715c1 100644 --- a/.github/workflows/update-badges.yml +++ b/.github/workflows/update-badges.yml @@ -47,7 +47,7 @@ jobs: # Download version badge cat > docs/badges/download.svg << EOF - DownloadDownloadv${VERSION}v${VERSION} + VersionVersionv${VERSION}v${VERSION} EOF # Downloads count badge diff --git a/docs/badges/download.svg b/docs/badges/download.svg index f24728d41..a1e1f05e5 100644 --- a/docs/badges/download.svg +++ b/docs/badges/download.svg @@ -1 +1,2 @@ -DownloadDownloadv3.8.3v3.8.3 +VersionVersionv3.8.3v3.8.3 + From 85a5c6e477473dbf859237205713c96877db54cd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 5 Jun 2026 16:46:56 +0000 Subject: [PATCH 044/118] chore: update README badges [skip ci] --- docs/badges/download.svg | 1 - docs/badges/downloads.svg | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/badges/download.svg b/docs/badges/download.svg index a1e1f05e5..41c90f3bd 100644 --- a/docs/badges/download.svg +++ b/docs/badges/download.svg @@ -1,2 +1 @@ VersionVersionv3.8.3v3.8.3 - diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 562c1fc36..be336bed9 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2785827858 +DownloadsDownloads2785927859 From 2f0f9565ead5f724269fc2751f633ca0ee20e505 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 22:24:57 +0530 Subject: [PATCH 045/118] fix: prevent duplicate screenshots in clipboard --- .../helium314/keyboard/latin/ClipboardHistoryManager.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index 4cd798de5..cf612dec6 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -258,7 +258,13 @@ class ClipboardHistoryManager( val cacheDir = java.io.File(latinIME.cacheDir, "clipboard_images") if (!cacheDir.exists()) cacheDir.mkdirs() - val file = java.io.File(cacheDir, "img_${System.currentTimeMillis()}.jpg") + val md = java.security.MessageDigest.getInstance("MD5") + val digest = md.digest(uri.toString().toByteArray()) + val hash = digest.joinToString("") { "%02x".format(it) } + val file = java.io.File(cacheDir, "img_${hash}.jpg") + if (file.exists() && file.length() > 0) { + return file.absolutePath + } resolver.openInputStream(uri)?.use { input -> val options = android.graphics.BitmapFactory.Options().apply { From 147379615d4cdca3f36759f1c607db79d8d17196 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 5 Jun 2026 22:31:42 +0530 Subject: [PATCH 046/118] feat: add toggle for screenshot compression --- .../keyboard/latin/ClipboardHistoryManager.kt | 13 ++++++++++++- .../helium314/keyboard/latin/settings/Defaults.kt | 1 + .../helium314/keyboard/latin/settings/Settings.java | 1 + .../keyboard/latin/settings/SettingsValues.java | 3 +++ .../settings/screens/TextCorrectionScreen.kt | 7 +++++++ app/src/main/res/values/strings.xml | 4 ++++ 6 files changed, 28 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt index cf612dec6..2d6e7a8d8 100644 --- a/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/ClipboardHistoryManager.kt @@ -261,11 +261,22 @@ class ClipboardHistoryManager( val md = java.security.MessageDigest.getInstance("MD5") val digest = md.digest(uri.toString().toByteArray()) val hash = digest.joinToString("") { "%02x".format(it) } - val file = java.io.File(cacheDir, "img_${hash}.jpg") + val suffix = if (latinIME.mSettings.current.mCompressScreenshots) "_compressed" else "" + val file = java.io.File(cacheDir, "img_${hash}${suffix}.jpg") if (file.exists() && file.length() > 0) { return file.absolutePath } + if (!latinIME.mSettings.current.mCompressScreenshots) { + resolver.openInputStream(uri)?.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + return file.absolutePath + } + return null + } + resolver.openInputStream(uri)?.use { input -> val options = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 2ff2d8c9e..5bbb017d9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -111,6 +111,7 @@ object Defaults { const val PREF_SUGGEST_PUNCTUATION = false const val PREF_SUGGEST_CLIPBOARD_CONTENT = true const val PREF_SUGGEST_SCREENSHOTS = false + const val PREF_COMPRESS_SCREENSHOTS = true const val PREF_GESTURE_INPUT = true const val PREF_VIBRATION_DURATION_SETTINGS = -1 const val PREF_VIBRATION_AMPLITUDE_SETTINGS = -1 diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 59cbaa361..f5e932291 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -166,6 +166,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history"; public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots"; + public static final String PREF_COMPRESS_SCREENSHOTS = "compress_screenshots"; public static final String PREF_CLIPBOARD_HISTORY_RETENTION_TIME = "clipboard_history_retention_time"; public static final String PREF_CLIPBOARD_HISTORY_PINNED_FIRST = "clipboard_history_pinned_first"; public static final String PREF_CLIPBOARD_FOLD_PINNED = "clipboard_fold_pinned"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index c90acec28..1bc0629d4 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -167,6 +167,7 @@ public class SettingsValues { private final boolean mOverrideShowingSuggestions; public final boolean mSuggestClipboardContent; public final boolean mSuggestScreenshots; + public final boolean mCompressScreenshots; public final SettingsValuesForSuggestion mSettingsValuesForSuggestion; public final boolean mIncognitoModeEnabled; public final boolean mLongPressSymbolsForNumpad; @@ -268,6 +269,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_SUGGEST_CLIPBOARD_CONTENT); mSuggestScreenshots = prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS); + mCompressScreenshots = prefs.getBoolean(Settings.PREF_COMPRESS_SCREENSHOTS, + Defaults.PREF_COMPRESS_SCREENSHOTS); mDoubleSpacePeriodTimeout = 1100; // ms mHasHardwareKeyboard = Settings.readHasHardwareKeyboard(res.getConfiguration()); final boolean isLandscape = mDisplayOrientation == Configuration.ORIENTATION_LANDSCAPE; diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt index b6bb50e67..32d629df4 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt @@ -89,6 +89,8 @@ fun TextCorrectionScreen( Settings.PREF_SUGGEST_PUNCTUATION, Settings.PREF_SUGGEST_CLIPBOARD_CONTENT, Settings.PREF_SUGGEST_SCREENSHOTS, + if (prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS)) + Settings.PREF_COMPRESS_SCREENSHOTS else null, Settings.PREF_USE_CONTACTS, Settings.PREF_USE_APPS ) @@ -243,6 +245,11 @@ fun createCorrectionSettings(context: Context) = listOf( } ) }, + Setting(context, Settings.PREF_COMPRESS_SCREENSHOTS, + R.string.compress_screenshots, R.string.compress_screenshots_summary + ) { + SwitchPreference(it, Defaults.PREF_COMPRESS_SCREENSHOTS) + }, Setting(context, Settings.PREF_USE_CONTACTS, R.string.use_contacts_dict, R.string.use_contacts_dict_summary ) { setting -> diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f5b51565a..cb0c379d1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,6 +180,10 @@ Suggest recent screenshots Show recently taken screenshots as a suggestion + + Compress screenshot suggestions + + Reduce quality and size of screenshot suggestions to save space Enable gesture typing From fbb8de748612ed408ec5466677ba13e8cdb5a2ab Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 6 Jun 2026 00:18:03 +0530 Subject: [PATCH 047/118] feat: improve text expander, gestures, and emoji scale fit --- .../keyboard/KeyboardActionListenerImpl.kt | 5 ++ .../keyboard/keyboard/KeyboardView.java | 4 +- .../keyboard/keyboard/TouchpadView.java | 10 +++ .../keyboard/emoji/DynamicGridKeyboard.java | 16 ++++ .../internal/keyboard_parser/EmojiParser.kt | 5 +- .../keyboard/latin/RichInputConnection.java | 27 +++++++ .../keyboard/latin/inputlogic/InputLogic.java | 68 +++++++++++++++- .../keyboard/latin/utils/TextExpanderUtils.kt | 77 +++++++++++++------ .../settings/screens/TextExpanderScreen.kt | 15 ++-- docs/FEATURES.md | 23 ++++++ .../android/en-US/changelogs/3840.txt | 9 ++- 11 files changed, 217 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 5102216c8..9d43053a9 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -561,6 +561,11 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onSingleTap() { onCodeInput(Constants.CODE_ENTER, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } + override fun onDoubleTap() { + if (connection.hasSelection()) { + onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } override fun onScroll(direction: Int) { onCodeInput(direction, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardView.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardView.java index 18053d561..695df5996 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardView.java @@ -446,7 +446,7 @@ protected void onDrawKeyTopVisuals(@NonNull final Key key, @NonNull final Canvas labelX = centerX; paint.setTextAlign(Align.CENTER); } - if (key.needsAutoXScale()) { + if (key.needsAutoXScale() || (StringUtilsKt.isEmoji(label) && Settings.getValues().mEmojiKeyFit)) { final int width; if (key.needsToKeepBackgroundAspectRatio(mDefaultKeyLabelFlags)) { // make sure the text stays inside bounds of background drawable @@ -457,7 +457,7 @@ protected void onDrawKeyTopVisuals(@NonNull final Key key, @NonNull final Canvas width = keyWidth; final float ratio = Math.min(1.0f, (width * MAX_LABEL_RATIO) / TypefaceUtils.getStringWidth(label, paint)); - if (key.needsAutoScale()) { + if (key.needsAutoScale() || (StringUtilsKt.isEmoji(label) && Settings.getValues().mEmojiKeyFit)) { final float autoSize = paint.getTextSize() * ratio; paint.setTextSize(autoSize); } else { diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index c97b7a0d0..ef5a2a54d 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -30,6 +30,7 @@ public class TouchpadView extends LinearLayout { public interface TouchpadListener { void onCursorMove(int keyCode, boolean isSelecting); void onSingleTap(); + void onDoubleTap(); void onScroll(int direction); } @@ -89,6 +90,15 @@ public void onLongPress(MotionEvent e) { mSelectionMode = true; applySurfaceColor(); } + + @Override + public boolean onDoubleTap(MotionEvent e) { + if (mListener != null) { + mListener.onDoubleTap(); + return true; + } + return false; + } }); setupTouchSurface(); diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/DynamicGridKeyboard.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/DynamicGridKeyboard.java index 586ee470a..e977c882e 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/DynamicGridKeyboard.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/DynamicGridKeyboard.java @@ -354,6 +354,22 @@ public void updateCoordinates(final int x0, final int y0, final int x1, final in getHitBox().set(x0, y0, x1, y1); } + @Override + public int getHorizontalGap() { + if (Settings.getValues().mEmojiKeyFit) { + return (int) (super.getHorizontalGap() * Settings.getValues().mFontSizeMultiplierEmoji); + } + return super.getHorizontalGap(); + } + + @Override + public int getVerticalGap() { + if (Settings.getValues().mEmojiKeyFit) { + return (int) (super.getVerticalGap() * Settings.getValues().mFontSizeMultiplierEmoji); + } + return super.getVerticalGap(); + } + @Override public int getWidth() { return getHitBox().width() - getHorizontalGap(); diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/EmojiParser.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/EmojiParser.kt index df5a2523f..a7b1cfcc3 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/EmojiParser.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/EmojiParser.kt @@ -75,10 +75,7 @@ class EmojiParser(private val params: KeyboardParams, private val context: Conte val emojiKeyboardHeight = defaultKeyboardHeight * 0.75f + params.mVerticalGap - defaultBottomPadding - context.resources.getDimensionPixelSize(R.dimen.config_emoji_category_page_id_height) var keyHeight = emojiKeyboardHeight * params.mDefaultRowHeight * Settings.getValues().mKeyboardHeightScale // still apply height scale to key - if (Settings.getValues().mEmojiKeyFit) { - keyWidth *= Settings.getValues().mFontSizeMultiplierEmoji - keyHeight *= Settings.getValues().mFontSizeMultiplierEmoji - } + lines.forEach { line -> diff --git a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.java b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.java index 64b64be8e..d10809ac9 100644 --- a/app/src/main/java/helium314/keyboard/latin/RichInputConnection.java +++ b/app/src/main/java/helium314/keyboard/latin/RichInputConnection.java @@ -699,6 +699,33 @@ public void deleteTextBeforeCursor(final int beforeLength) { checkConsistencyForDebug(); } + public void deleteSurroundingText(final int beforeLength, final int afterLength) { + if (DEBUG_BATCH_NESTING) + checkBatchEdit(); + if (DebugFlags.DEBUG_ENABLED) + Log.d(TAG, "deleting " + beforeLength + " before and " + afterLength + " after cursor"); + final int remainingChars = mComposingText.length() - beforeLength; + if (remainingChars >= 0) { + mComposingText.setLength(remainingChars); + } else { + mComposingText.setLength(0); + final int len = Math.max(mCommittedTextBeforeComposingText.length() + remainingChars, 0); + mCommittedTextBeforeComposingText.setLength(len); + } + if (mExpectedSelStart > beforeLength) { + mExpectedSelStart -= beforeLength; + mExpectedSelEnd -= beforeLength; + } else { + mExpectedSelEnd -= mExpectedSelStart; + mExpectedSelStart = 0; + } + if (isConnected()) { + mIC.deleteSurroundingText(beforeLength, afterLength); + } + if (DEBUG_PREVIOUS_TEXT) + checkConsistencyForDebug(); + } + public void performEditorAction(final int actionId) { mIC = mParent.getCurrentInputConnection(); if (isConnected()) { diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index d2a4d14d3..9f8644ad2 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -118,6 +118,12 @@ public final class InputLogic { // Note: This does not have a composing span, so it must be handled separately. private String mWordBeingCorrectedByCursor = null; + // Keep track of the last text expansion for backspace undo + private String mLastExpandedText = null; + private String mLastShortcutText = null; + private int mLastExpandedCursorPosition = -1; + private int mLastExpandedCursorOffset = -1; + private boolean mJustRevertedACommit = false; /** @@ -418,6 +424,15 @@ public boolean onUpdateSelection(final int oldSelStart, final int oldSelEnd, fin // state-related special processing to kick in. mSpaceState = SpaceState.NONE; + if (oldSelStart != newSelStart || oldSelEnd != newSelEnd) { + if (newSelStart != mLastExpandedCursorPosition) { + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + } + } + final boolean selectionChangedOrSafeToReset = oldSelStart != newSelStart || oldSelEnd != newSelEnd // selection // changed || !mWordComposer.isComposingWord(); // safe to reset @@ -522,6 +537,12 @@ public InputTransaction onCodeInput(final SettingsValues settingsValues, || inputTransaction.getTimestamp() > mLastKeyTime + Constants.LONG_PRESS_MILLISECONDS) { mDeleteCount = 0; } + if (processedEvent.getKeyCode() != KeyCode.DELETE) { + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + } mLastKeyTime = inputTransaction.getTimestamp(); mConnection.beginBatchEdit(); if (!mWordComposer.isComposingWord()) { @@ -1608,6 +1629,28 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu mSpaceState = SpaceState.NONE; mDeleteCount++; + if (mLastExpandedText != null && !event.isKeyRepeat()) { + final int expectedCursor = mConnection.getExpectedSelectionEnd(); + if (expectedCursor == mLastExpandedCursorPosition) { + final int beforeLen = mLastExpandedCursorOffset; + final int afterLen = mLastExpandedText.length() - beforeLen; + final CharSequence textBefore = mConnection.getTextBeforeCursor(beforeLen, 0); + final CharSequence textAfter = mConnection.getTextAfterCursor(afterLen, 0); + final String expectedBefore = mLastExpandedText.substring(0, beforeLen); + final String expectedAfter = mLastExpandedText.substring(beforeLen); + if (textBefore != null && textBefore.toString().equals(expectedBefore) + && textAfter != null && textAfter.toString().equals(expectedAfter)) { + mConnection.deleteSurroundingText(beforeLen, afterLen); + mConnection.commitText(mLastShortcutText, 1); + mLastExpandedText = null; + mLastShortcutText = null; + mLastExpandedCursorPosition = -1; + mLastExpandedCursorOffset = -1; + return; + } + } + } + // In many cases after backspace, we need to update the shift state. Normally we // need // to do this right away to avoid the shift state being out of date in case the @@ -2960,7 +3003,7 @@ private void commitChosenWord(final SettingsValues settingsValues, final String if (expanded != null) { mConnection.commitText(getTextWithSuggestionSpan(mLatinIME, chosenWord, mSuggestedWords, getDictionaryFacilitatorLocale()), 1); mConnection.deleteTextBeforeCursor(chosenWord.length()); - mConnection.commitText(expanded, 1); + commitExpandedText(chosenWord, expanded); return; } } else { @@ -2973,7 +3016,7 @@ private void commitChosenWord(final SettingsValues settingsValues, final String if (expanded != null) { mConnection.commitText(getTextWithSuggestionSpan(mLatinIME, chosenWord, mSuggestedWords, getDictionaryFacilitatorLocale()), 1); mConnection.deleteTextBeforeCursor(prefix.length() + chosenWord.length()); - mConnection.commitText(expanded, 1); + commitExpandedText(targetSuffix, expanded); return; } } @@ -3533,4 +3576,25 @@ public void onError(String errorMessage) { } }); } + + private void commitExpandedText(final String shortcut, final String expanded) { + final int cursorOffset = expanded.indexOf("%cursor%"); + final String finalExpandedText = cursorOffset != -1 ? expanded.replace("%cursor%", "") : expanded; + + mConnection.commitText(finalExpandedText, 1); + + mLastExpandedText = finalExpandedText; + mLastShortcutText = shortcut; + mLastExpandedCursorOffset = cursorOffset != -1 ? cursorOffset : finalExpandedText.length(); + + if (cursorOffset != -1) { + final int moveBackAmount = finalExpandedText.length() - cursorOffset; + if (moveBackAmount > 0) { + final int newCursorPos = mConnection.getExpectedSelectionEnd() - moveBackAmount; + mConnection.setSelection(newCursorPos, newCursorPos); + } + } + + mLastExpandedCursorPosition = mConnection.getExpectedSelectionEnd(); + } } diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index b691efb31..5c91ebe86 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -93,24 +93,12 @@ object TextExpanderUtils { result = result.replace("%time12%", time12Str) } - // Resolve %day_short% - if (result.contains("%day_short%")) { - val dayShortStr = SimpleDateFormat("EEE", Locale.getDefault()).format(Date()) - result = result.replace("%day_short%", dayShortStr) - } - // Resolve %month% if (result.contains("%month%")) { val monthStr = SimpleDateFormat("MMMM", Locale.getDefault()).format(Date()) result = result.replace("%month%", monthStr) } - // Resolve %month_short% - if (result.contains("%month_short%")) { - val monthShortStr = SimpleDateFormat("MMM", Locale.getDefault()).format(Date()) - result = result.replace("%month_short%", monthShortStr) - } - // Resolve %year% if (result.contains("%year%")) { val yearStr = SimpleDateFormat("yyyy", Locale.getDefault()).format(Date()) @@ -131,18 +119,6 @@ object TextExpanderUtils { result = result.replace("%battery%", batteryStr) } - // Resolve %device% - if (result.contains("%device%")) { - val deviceStr = "${android.os.Build.MANUFACTURER} ${android.os.Build.MODEL}" - result = result.replace("%device%", deviceStr) - } - - // Resolve %android% - if (result.contains("%android%")) { - val androidStr = android.os.Build.VERSION.RELEASE - result = result.replace("%android%", androidStr) - } - // Resolve %language% if (result.contains("%language%")) { val imeManager = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? android.view.inputmethod.InputMethodManager @@ -152,6 +128,59 @@ object TextExpanderUtils { result = result.replace("%language%", languageStr) } + // Resolve %greeting% + if (result.contains("%greeting%")) { + val hour = java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY) + val greeting = when (hour) { + in 5..11 -> "Good morning" + in 12..16 -> "Good afternoon" + in 17..21 -> "Good evening" + else -> "Good night" + } + result = result.replace("%greeting%", greeting) + } + + // Resolve %tomorrow% + if (result.contains("%tomorrow%")) { + val cal = java.util.Calendar.getInstance().apply { add(java.util.Calendar.DAY_OF_YEAR, 1) } + val tomorrowStr = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(cal.time) + result = result.replace("%tomorrow%", tomorrowStr) + } + + // Resolve %bullets% with optional count + if (result.contains("%bullets")) { + val bulletsRegex = Regex("%bullets(?:_(\\d+))?%") + result = bulletsRegex.replace(result) { match -> + val count = match.groups[1]?.value?.toIntOrNull() ?: 3 + if (count <= 0) "" + else { + val sb = java.lang.StringBuilder() + sb.append("• %cursor%") + for (i in 2..count) { + sb.append("\n• ") + } + sb.toString() + } + } + } + + // Resolve %list% with optional count + if (result.contains("%list")) { + val listRegex = Regex("%list(?:_(\\d+))?%") + result = listRegex.replace(result) { match -> + val count = match.groups[1]?.value?.toIntOrNull() ?: 3 + if (count <= 0) "" + else { + val sb = java.lang.StringBuilder() + sb.append("1. %cursor%") + for (i in 2..count) { + sb.append("\n$i. ") + } + sb.toString() + } + } + } + return result } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index b4e3a0d6e..e8a1a8014 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -277,16 +277,17 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { PlaceholderChip(tag = "%year%", desc = "Year (YYYY)") PlaceholderChip(tag = "%week%", desc = "Week of year (1-53)") PlaceholderChip(tag = "%battery%", desc = "Battery level (e.g. 85%)") - PlaceholderChip(tag = "%device%", desc = "Phone model (e.g. POCO M2)") + PlaceholderChip(tag = "%greeting%", desc = "Time-gated greeting") + PlaceholderChip(tag = "%tomorrow%", desc = "Tomorrow's date (YYYY-MM-DD)") } Column(modifier = Modifier.weight(1.1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { PlaceholderChip(tag = "%clipboard%", desc = "Clipboard content") PlaceholderChip(tag = "%day%", desc = "Day name (e.g. Monday)") - PlaceholderChip(tag = "%day_short%", desc = "Day short (e.g. Mon)") PlaceholderChip(tag = "%month%", desc = "Month (e.g. June)") - PlaceholderChip(tag = "%month_short%", desc = "Month short (e.g. Jun)") - PlaceholderChip(tag = "%android%", desc = "Android OS (e.g. 14)") PlaceholderChip(tag = "%language%", desc = "Keyboard language (e.g. English)") + PlaceholderChip(tag = "%cursor%", desc = "Cursor position after expansion") + PlaceholderChip(tag = "%bullets%", desc = "Bullet list (supports e.g. %bullets_5%)") + PlaceholderChip(tag = "%list%", desc = "Numbered list (supports e.g. %list_5%)") } } } @@ -493,9 +494,9 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { ) { val tags = listOf( "%date%", "%time%", "%time12%", "%clipboard%", - "%day%", "%day_short%", "%month%", "%month_short%", - "%year%", "%week%", "%battery%", "%device%", "%android%", - "%language%" + "%day%", "%month%", "%year%", "%week%", + "%battery%", "%language%", "%cursor%", "%greeting%", + "%tomorrow%", "%bullets%", "%list%" ) tags.forEach { tag -> Box( diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 06619cd11..d89d7f881 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -14,6 +14,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | 🧠 **[Custom AI Keys](#4-custom-ai-keys--keywords)** | Configure custom prompts and personas. | | 🛡️ **[Offline Proofreading](#5-offline-proofreading-privacy-focused)** | Privacy-first, on-device AI. | | 📝 **[Text Expander](#6-text-expander)** | Custom text shortcut expansion. | +| 🖱️ **[Touchpad Mode](#7-touchpad-mode)** | Full-screen touchpad gestures and controls. | ## Summary of New Features @@ -301,6 +302,11 @@ Text Expander allows you to define custom shortcuts (abbreviations) that automat * `%date%` - Inserts the current local date. * `%time%` - Inserts the current local time. * `%clipboard%` - Appends the most recently copied text from your clipboard. + * `%cursor%` - Positions the typing cursor here after expansion. + * `%greeting%` - Inserts "Good morning", "Good afternoon", or "Good evening" depending on the hour. + * `%tomorrow%` - Inserts tomorrow's date (YYYY-MM-DD). + * `%bullets%` - Inserts a bullet list template (supports count suffix e.g. `%bullets_5%`). + * `%list%` - Inserts a numbered list template (supports count suffix e.g. `%list_5%`). * **Custom Placeholders**: Create dynamic input fields (e.g., `%name%`) that prompt you to type a value during the expansion flow. ### Configuration @@ -308,3 +314,20 @@ Text Expander allows you to define custom shortcuts (abbreviations) that automat 2. Tap the **+** (Add) button to create a new expansion rule. 3. Specify the **Shortcut** trigger and the **Expansion** template. 4. Include dynamic template variables in the template block. + +--- + +## 7. Touchpad Mode + +Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to control the cursor and edit text using fluid gestures. + +### How to Enable +* **Swipe gesture**: Swipe up on the **Spacebar** to temporarily toggle Touchpad Mode. +* **Toolbar shortcut**: Tap the **Touchpad** icon in the toolbar for a persistent touchpad overlay. + +### Touchpad Gestures +* **Single-finger drag**: Moves the cursor in 2D space (simulating arrow keys left/right/up/down) to navigate text. +* **Two-finger drag**: Performs fast vertical scrolling (simulating arrow keys up/down). +* **Two-finger tap**: Simulates a mouse click/Enter. +* **Long press (hold finger)**: Activates text selection mode. Dragging while holding will select text. Releasing the finger exits selection mode. +* **Double tap by single finger**: Deletes the selected text or words (if a text selection exists). diff --git a/fastlane/metadata/android/en-US/changelogs/3840.txt b/fastlane/metadata/android/en-US/changelogs/3840.txt index 575d5e861..fcb3ac47b 100644 --- a/fastlane/metadata/android/en-US/changelogs/3840.txt +++ b/fastlane/metadata/android/en-US/changelogs/3840.txt @@ -1,3 +1,6 @@ -- Fix missing words and swapped values during Gboard dictionary import -- Improve Gboard import performance using bulk database insertion -- Pre-verify ZIP signatures and safely close streams to prevent corrupted imports +- Fix missing words & optimize Gboard dictionary import performance +- Pre-verify ZIP signatures & close streams to prevent corrupted imports +- Add double tap touchpad gesture to delete selected words +- Prevent duplicate clipboard screenshots & add compression toggle +- Settings and editor performance, stability & memory optimizations +- Text Expander: Backspace-to-revert, %cursor%, %greeting%, %tomorrow%, and bullets/list placeholders (with optional custom count e.g. %list_5%) From d4a688489c8da808a8302d8440af1ba3f9be617e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 6 Jun 2026 03:42:12 +0000 Subject: [PATCH 048/118] chore: update README badges [skip ci] --- docs/badges/download.svg | 2 +- docs/badges/downloads.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/download.svg b/docs/badges/download.svg index 41c90f3bd..82fc398ad 100644 --- a/docs/badges/download.svg +++ b/docs/badges/download.svg @@ -1 +1 @@ -VersionVersionv3.8.3v3.8.3 +VersionVersionv3.8.4v3.8.4 diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index be336bed9..59a40ea91 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2785927859 +DownloadsDownloads2817928179 From 5d417693a56c04a8c0b41ddd7b4c450dcd4e4197 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 6 Jun 2026 11:06:06 +0530 Subject: [PATCH 049/118] fix: persist toolbar customizer key toggles --- .../settings/preferences/ReorderSwitchPreference.kt | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt index d840ce625..868b8050a 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/ReorderSwitchPreference.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -36,7 +37,7 @@ fun ReorderSwitchPreference(setting: Setting, default: String, filter: (String) if (showDialog) { val ctx = LocalContext.current val prefs = ctx.prefs() - val items = rememberSaveable(setting.key) { + val items = remember(setting.key) { prefs.getString(setting.key, default)!!.split(Separators.ENTRY).map { val both = it.split(Separators.KV) KeyAndState(both.first(), both.last().toBoolean()) @@ -63,7 +64,10 @@ fun ReorderSwitchPreference(setting: Setting, default: String, filter: (String) Text(actualText, Modifier.weight(1f)) Switch( checked = checked, - onCheckedChange = { checked = it } + onCheckedChange = { + item.state = it + checked = it + } ) } }, @@ -72,5 +76,4 @@ fun ReorderSwitchPreference(setting: Setting, default: String, filter: (String) } } -@Immutable -private data class KeyAndState(val name: String, val state: Boolean) +private class KeyAndState(val name: String, var state: Boolean) From f87e6b19eafd29759e6f280cf697a3152b6c49a5 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 6 Jun 2026 11:08:15 +0530 Subject: [PATCH 050/118] build: bump version to v3.8.5 --- app/build.gradle.kts | 4 ++-- docs/badges/download.svg | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5b392b302..c4549c4ca 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "com.leanbitlab.leantype" minSdk = 21 targetSdk = 35 - versionCode = 3840 - versionName = "3.8.4" + versionCode = 3850 + versionName = "3.8.5" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") diff --git a/docs/badges/download.svg b/docs/badges/download.svg index 82fc398ad..ee156f654 100644 --- a/docs/badges/download.svg +++ b/docs/badges/download.svg @@ -1 +1 @@ -VersionVersionv3.8.4v3.8.4 +VersionVersionv3.8.5v3.8.5 From d742cb949ecf3fae0b69e70c09150e31b81ecabf Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 6 Jun 2026 11:24:25 +0530 Subject: [PATCH 051/118] feat: toggle dictionaries individually --- .../latin/DictionaryFacilitatorImpl.kt | 15 +++++ .../latin/dictionary/DictionaryFactory.kt | 15 ++++- .../settings/dialogs/DictionaryDialog.kt | 65 +++++++++++++++++-- 3 files changed, 85 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index e2fb65f7e..8372761f2 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -7,6 +7,7 @@ package helium314.keyboard.latin import android.Manifest import android.content.Context +import android.content.SharedPreferences import android.provider.UserDictionary import android.util.LruCache import helium314.keyboard.keyboard.Keyboard @@ -60,6 +61,8 @@ import java.util.concurrent.TimeUnit */ @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class DictionaryFacilitatorImpl : DictionaryFacilitator { + private var mPrefs: SharedPreferences? = null + private var mEnabledDictionariesState: Map = emptyMap() private var dictionaryGroups = listOf(DictionaryGroup()) @Volatile @@ -133,6 +136,14 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } override fun usesSameSettings(locales: List, contacts: Boolean, apps: Boolean, personalization: Boolean): Boolean { + val prefs = mPrefs + if (prefs != null) { + val currentPrefs = prefs.all.filterKeys { it.startsWith("pref_dict_enabled_") } + .mapValues { it.value as? Boolean ?: true } + if (currentPrefs != mEnabledDictionariesState) { + return false + } + } val dictGroup = dictionaryGroups[0] // settings are the same for all groups return contacts == dictGroup.hasDict(Dictionary.TYPE_CONTACTS) && apps == dictGroup.hasDict(Dictionary.TYPE_APPS) @@ -154,6 +165,10 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { listener: DictionaryInitializationListener? ) { Log.i(TAG, "resetDictionaries, force reloading main dictionary: $forceReloadMainDictionary") + val prefs = context.prefs() + mPrefs = prefs + mEnabledDictionariesState = prefs.all.filterKeys { it.startsWith("pref_dict_enabled_") } + .mapValues { it.value as? Boolean ?: true } // Initialize session word boost with context if not yet done if (sessionWordBoost == null) { diff --git a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt index dcd3ba9bc..38e8c778d 100644 --- a/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt +++ b/app/src/main/java/helium314/keyboard/latin/dictionary/DictionaryFactory.kt @@ -8,6 +8,7 @@ package helium314.keyboard.latin.dictionary import android.content.Context import helium314.keyboard.latin.common.LocaleUtils import helium314.keyboard.latin.utils.DictionaryInfoUtils +import helium314.keyboard.latin.utils.prefs import helium314.keyboard.latin.utils.Log import java.io.File import java.util.LinkedList @@ -29,13 +30,13 @@ object DictionaryFactory { val (extracted, nonExtracted) = getAvailableDictsForLocale(locale, context, useEmojiDict) extracted.sortedBy { !it.name.endsWith(DictionaryInfoUtils.USER_DICTIONARY_SUFFIX) }.forEach { // we sort to have user dicts first, so they have priority over internal dicts of the same type - checkAndAddDictionaryToListIfNewType(it, dictList, locale) + checkAndAddDictionaryToListIfNewType(it, dictList, locale, context) } nonExtracted.forEach { filename -> val type = filename.substringBefore("_") if (dictList.any { it.mDictType == type }) return@forEach val extractedFile = DictionaryInfoUtils.extractAssetsDictionary(filename, locale, context) ?: return@forEach - checkAndAddDictionaryToListIfNewType(extractedFile, dictList, locale) + checkAndAddDictionaryToListIfNewType(extractedFile, dictList, locale, context) } return DictionaryCollection(Dictionary.TYPE_MAIN, locale, dictList, FloatArray(dictList.size) { 1f }) } @@ -64,7 +65,15 @@ object DictionaryFactory { * if [file] cannot be loaded it is deleted * if the dictionary type already exists in [dicts], the [file] is skipped */ - private fun checkAndAddDictionaryToListIfNewType(file: File, dicts: MutableList, locale: Locale) { + private fun checkAndAddDictionaryToListIfNewType(file: File, dicts: MutableList, locale: Locale, context: Context) { + val header = DictionaryInfoUtils.getDictionaryFileHeaderOrNull(file) + if (header != null) { + val prefs = context.prefs() + if (!prefs.getBoolean("pref_dict_enabled_${header.mIdString}", true)) { + Log.i("DictionaryFactory", "skipping disabled dictionary ${header.mIdString}") + return + } + } val dictionary = getDictionary(file, locale) ?: return if (dicts.any { it.mDictType == dictionary.mDictType }) { dictionary.close() diff --git a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt index 0ff2057e8..1aa928a38 100644 --- a/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt +++ b/app/src/main/java/helium314/keyboard/settings/dialogs/DictionaryDialog.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -29,8 +30,10 @@ import androidx.compose.ui.unit.em import helium314.keyboard.compat.locale import helium314.keyboard.latin.dictionary.Dictionary import helium314.keyboard.latin.R +import helium314.keyboard.latin.common.LocaleUtils import helium314.keyboard.latin.common.LocaleUtils.localizedDisplayName import helium314.keyboard.latin.utils.DictionaryInfoUtils +import helium314.keyboard.latin.utils.prefs import helium314.keyboard.latin.utils.createDictionaryTextAnnotated import helium314.keyboard.settings.DeleteButton import helium314.keyboard.settings.ExpandButton @@ -62,16 +65,51 @@ fun DictionaryDialog( content = { Column { if (hasInternal) { + val internalDicts = DictionaryInfoUtils.getAssetsDictionaryList(ctx) + val best = internalDicts?.let { + LocaleUtils.getBestMatch(locale, it.toList()) { dict -> + DictionaryInfoUtils.extractLocaleFromAssetsDictionaryFile(dict) + } + } + val internalId = best?.let { "main:" + it.substringAfter("_").substringBefore(".") } + val color = if (mainDict == null) MaterialTheme.typography.titleSmall.color else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) // for disabled look val bottomPadding = if (mainDict == null) 12.dp else 0.dp - Text(stringResource(R.string.internal_dictionary_summary), - modifier = Modifier - .fillMaxWidth() - .padding(bottom = bottomPadding), - color = color, - style = MaterialTheme.typography.titleSmall - ) + + if (internalId != null) { + val prefs = ctx.prefs() + val prefKey = "pref_dict_enabled_$internalId" + var enabled by remember { mutableStateOf(prefs.getBoolean(prefKey, true)) } + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = bottomPadding) + ) { + Switch( + checked = enabled && (mainDict == null), + enabled = mainDict == null, + onCheckedChange = { isChecked -> + enabled = isChecked + prefs.edit().putBoolean(prefKey, isChecked).apply() + }, + modifier = Modifier.padding(end = 8.dp) + ) + Text(stringResource(R.string.internal_dictionary_summary), + color = color, + style = MaterialTheme.typography.titleSmall + ) + } + } else { + Text(stringResource(R.string.internal_dictionary_summary), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = bottomPadding), + color = color, + style = MaterialTheme.typography.titleSmall + ) + } } if (mainDict != null) DictionaryDetails(mainDict) @@ -113,11 +151,24 @@ private fun DictionaryDetails(dict: File) { var showDetails by remember { mutableStateOf(false) } val title = if (type != DictionaryInfoUtils.DEFAULT_MAIN_DICT) type else stringResource(R.string.main_dictionary) + val ctx = LocalContext.current + val prefs = ctx.prefs() + val prefKey = "pref_dict_enabled_${header.mIdString}" + var enabled by remember { mutableStateOf(prefs.getBoolean(prefKey, true)) } + Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { + Switch( + checked = enabled, + onCheckedChange = { isChecked -> + enabled = isChecked + prefs.edit().putBoolean(prefKey, isChecked).apply() + }, + modifier = Modifier.padding(end = 8.dp) + ) Text(title, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f)) DeleteButton { showDeleteDialog = true } ExpandButton { showDetails = !showDetails } From c6264c5eca3a5dd023d7b06782ce48b5a40dbb60 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 6 Jun 2026 11:42:08 +0530 Subject: [PATCH 052/118] chore: add changelog for v3.8.5 --- fastlane/metadata/android/en-US/changelogs/3850.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 fastlane/metadata/android/en-US/changelogs/3850.txt diff --git a/fastlane/metadata/android/en-US/changelogs/3850.txt b/fastlane/metadata/android/en-US/changelogs/3850.txt new file mode 100644 index 000000000..d8b4a9765 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3850.txt @@ -0,0 +1,2 @@ +- Fix toolbar key customization toggles not persisting +- Allow enabling or disabling individual dictionaries (built-in and custom) in settings From 1f782ea45354a3b69e8b686c30a6c24040baf3a0 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 6 Jun 2026 11:48:17 +0530 Subject: [PATCH 053/118] fix: split emoji search keyboard layout --- .../helium314/keyboard/keyboard/emoji/EmojiPalettesView.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index 82a52a7c3..8eb8995c2 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -711,6 +711,7 @@ public void resetMetaState() { // Load Alpha Keyboard KeyboardLayoutSet.Builder builder = new KeyboardLayoutSet.Builder(ctx, null); builder.setSubtype(RichInputMethodManager.getInstance().getCurrentSubtype()); + builder.setSplitLayoutEnabled(Settings.getValues().mIsSplitKeyboardEnabled); // Fix: Use SecondaryKeyboardHeight which provides the exact height of the Emoji // palettes area From 5a96d9955b6e6e8aacdc79192a1f2c62a0dc6482 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 6 Jun 2026 11:48:56 +0530 Subject: [PATCH 054/118] chore: update changelog for emoji search fix --- fastlane/metadata/android/en-US/changelogs/3850.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/fastlane/metadata/android/en-US/changelogs/3850.txt b/fastlane/metadata/android/en-US/changelogs/3850.txt index d8b4a9765..da5ba1039 100644 --- a/fastlane/metadata/android/en-US/changelogs/3850.txt +++ b/fastlane/metadata/android/en-US/changelogs/3850.txt @@ -1,2 +1,3 @@ - Fix toolbar key customization toggles not persisting - Allow enabling or disabling individual dictionaries (built-in and custom) in settings +- Fix emoji search keyboard layout not split in landscape mode when split keyboard is enabled From b6f31b349daf6d4671e19b8d47bae535cd3025ad Mon Sep 17 00:00:00 2001 From: Rohan Lodhi <42321434+RohanLodhi@users.noreply.github.com> Date: Sat, 6 Jun 2026 16:11:35 +0530 Subject: [PATCH 055/118] added auto detect feature --- app/src/main/AndroidManifest.xml | 2 + .../helium314/keyboard/latin/LatinIME.java | 25 ++- .../keyboard/latin/OtpSuggestionManager.kt | 167 ++++++++++++++++++ .../keyboard/latin/settings/Defaults.kt | 1 + .../keyboard/latin/settings/Settings.java | 1 + .../latin/settings/SettingsValues.java | 3 + .../settings/screens/TextCorrectionScreen.kt | 19 ++ app/src/main/res/layout/otp_suggestion.xml | 36 ++++ app/src/main/res/values/strings.xml | 6 + 9 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt create mode 100644 app/src/main/res/layout/otp_suggestion.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f7bf9bfd..341501dad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only + + diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 3095457be..b0121f98d 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -182,6 +182,7 @@ public void onReceive(Context context, Intent intent) { private GestureConsumer mGestureConsumer = GestureConsumer.NULL_GESTURE_CONSUMER; private final ClipboardHistoryManager mClipboardHistoryManager = new ClipboardHistoryManager(this); + private final OtpSuggestionManager mOtpSuggestionManager = new OtpSuggestionManager(this); private FloatingKeyboardManager mFloatingKeyboardManager; @@ -711,6 +712,7 @@ public void onDestroy() { mFloatingKeyboardManager.destroy(); } mClipboardHistoryManager.onDestroy(); + mOtpSuggestionManager.stop(); mDictionaryFacilitator.closeDictionaries(); mSettings.onDestroy(); unregisterReceiver(mRingerModeChangeReceiver); @@ -1093,6 +1095,10 @@ void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restart if (TRACE) Debug.startMethodTracing("/data/trace/latinime"); + + // Listen for incoming SMS OTPs only while the keyboard is shown, and only if the + // user has opted in and granted the permission (handled inside the manager). + mOtpSuggestionManager.start(); } @Override @@ -1130,6 +1136,7 @@ void onFinishInputInternal() { void onFinishInputViewInternal(final boolean finishingInput) { super.onFinishInputView(finishingInput); Log.i(TAG, "onFinishInputView"); + mOtpSuggestionManager.stop(); cleanupInternalStateForFinishInput(); } @@ -1718,6 +1725,20 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { * in suggestion strip. * returns whether a clipboard suggestion has been set. */ + /** + * Checks if a recent SMS OTP suggestion is available. If so, it is set in the suggestion strip. + * Returns whether an OTP suggestion has been set. + */ + public boolean tryShowOtpSuggestion() { + if (!hasSuggestionStripView()) return false; + final View otpView = mOtpSuggestionManager.getOtpSuggestionView(mSuggestionStripView); + if (otpView != null) { + mSuggestionStripView.setExternalSuggestionView(otpView, true); + return true; + } + return false; + } + public boolean tryShowClipboardSuggestion() { final View clipboardView = mClipboardHistoryManager.getClipboardSuggestionView(getCurrentInputEditorInfo(), mSuggestionStripView); @@ -1743,8 +1764,8 @@ public boolean tryShowClipboardSuggestion() { @Override public void setNeutralSuggestionStrip() { final SettingsValues currentSettings = mSettings.getCurrent(); - if (tryShowClipboardSuggestion()) { - // clipboard suggestion has been set + if (tryShowOtpSuggestion() || tryShowClipboardSuggestion()) { + // an external (OTP or clipboard) suggestion has been set if (hasSuggestionStripView() && currentSettings.mAutoHideToolbar) mSuggestionStripView.setToolbarVisibility(false); return; diff --git a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt new file mode 100644 index 000000000..b800462d8 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: GPL-3.0-only + +package helium314.keyboard.latin + +import android.Manifest +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Handler +import android.os.Looper +import android.provider.Telephony +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.core.view.isGone +import helium314.keyboard.event.HapticEvent +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.common.ColorType +import helium314.keyboard.latin.databinding.OtpSuggestionBinding +import helium314.keyboard.latin.permissions.PermissionsUtil +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.utils.ToolbarKey + +/** + * Optional, opt-in helper that surfaces one-time passcodes (OTPs) from incoming SMS as a + * suggestion-strip chip the user can tap to insert (similar to the clipboard/screenshot + * suggestions, see [ClipboardHistoryManager.getClipboardSuggestionView]). + * + * Privacy: this never reads the existing SMS inbox. A [BroadcastReceiver] is registered only + * while the keyboard input view is shown and only when the feature is enabled and the + * RECEIVE_SMS permission has been granted, so the keyboard only ever sees messages that arrive + * while the user is actively typing. + */ +class OtpSuggestionManager(private val latinIME: LatinIME) { + + private val mainHandler = Handler(Looper.getMainLooper()) + private var otpSuggestionView: View? = null + private var dontShowCurrentSuggestion = false + + private var latestOtp: String? = null + private var latestOtpTimestamp = 0L + + private var isRegistered = false + private val smsReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (intent?.action != Telephony.Sms.Intents.SMS_RECEIVED_ACTION) return + val body = try { + Telephony.Sms.Intents.getMessagesFromIntent(intent) + ?.joinToString(separator = "") { it.messageBody ?: it.displayMessageBody ?: "" } + ?: return + } catch (e: Exception) { + Log.w(TAG, "Failed to read incoming SMS", e) + return + } + val otp = extractOtp(body) ?: return + latestOtp = otp + latestOtpTimestamp = System.currentTimeMillis() + dontShowCurrentSuggestion = false + // Refresh the strip on the main thread so the chip appears immediately, + // mirroring the screenshot-observer path in ClipboardHistoryManager. + mainHandler.post { + if (latinIME.isInputViewShown) latinIME.setNeutralSuggestionStrip() + } + } + } + + /** Register the SMS receiver if the feature is enabled and the permission is granted. Idempotent. */ + fun start() { + if (isRegistered) return + if (!latinIME.mSettings.current.mAutoReadOtp) return + if (!PermissionsUtil.checkAllPermissionsGranted(latinIME, Manifest.permission.RECEIVE_SMS)) return + try { + ContextCompat.registerReceiver( + latinIME, + smsReceiver, + IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION), + ContextCompat.RECEIVER_NOT_EXPORTED // SMS_RECEIVED is a protected system broadcast + ) + isRegistered = true + } catch (e: Exception) { + Log.w(TAG, "Could not register SMS receiver", e) + } + } + + /** Unregister the receiver. Idempotent. Called when the input view is hidden or the IME is destroyed. */ + fun stop() { + if (!isRegistered) return + try { + latinIME.unregisterReceiver(smsReceiver) + } catch (e: Exception) { + Log.w(TAG, "Could not unregister SMS receiver", e) + } + isRegistered = false + } + + /** + * Build the OTP suggestion chip if a recent code is available, else null. + * Called from [LatinIME.tryShowOtpSuggestion]. + */ + fun getOtpSuggestionView(parent: ViewGroup?): View? { + otpSuggestionView = null + if (parent == null) return null + if (!latinIME.mSettings.current.mAutoReadOtp) return null + if (dontShowCurrentSuggestion) return null + val otp = latestOtp ?: return null + if (System.currentTimeMillis() - latestOtpTimestamp > RECENT_OTP_MILLIS) return null + + val binding = OtpSuggestionBinding.inflate(LayoutInflater.from(latinIME), parent, false) + val textView = binding.otpSuggestionText + latinIME.mSettings.getCustomTypeface()?.let { textView.typeface = it } + textView.text = otp + val icon = latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.NUMPAD.name.lowercase()) + textView.setCompoundDrawablesRelativeWithIntrinsicBounds(icon, null, null, null) + textView.setOnClickListener { + dontShowCurrentSuggestion = true + latinIME.onTextInput(otp) + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) + binding.root.isGone = true + } + val closeButton = binding.otpSuggestionClose + closeButton.setImageDrawable(latinIME.mKeyboardSwitcher.keyboard?.mIconsSet?.getIconDrawable(ToolbarKey.CLOSE_HISTORY.name.lowercase())) + closeButton.setOnClickListener { removeOtpSuggestion() } + + val colors = latinIME.mSettings.current.mColors + textView.setTextColor(colors.get(ColorType.KEY_TEXT)) + icon?.let { colors.setColor(it, ColorType.KEY_ICON) } + colors.setColor(closeButton, ColorType.REMOVE_SUGGESTION_ICON) + colors.setBackground(binding.root, ColorType.CLIPBOARD_SUGGESTION_BACKGROUND) + + otpSuggestionView = binding.root + return otpSuggestionView + } + + private fun removeOtpSuggestion() { + dontShowCurrentSuggestion = true + val view = otpSuggestionView ?: return + if (view.parent != null && !view.isGone) { + latinIME.setNeutralSuggestionStrip() + latinIME.mHandler.postResumeSuggestions(false) + } + view.isGone = true + } + + /** + * Extract an OTP from an SMS body. Keyword-gated to limit false positives: a 4-8 digit group is + * only treated as a code when the message mentions a code-like keyword, or when it is the single + * such group in the message. + */ + private fun extractOtp(body: String): String? { + if (body.isBlank()) return null + val groups = codeRegex.findAll(body).map { it.value }.toList() + if (groups.isEmpty()) return null + return if (otpKeywordRegex.containsMatchIn(body) || groups.size == 1) groups.first() else null + } + + companion object { + private const val TAG = "OtpSuggestionManager" + private const val RECENT_OTP_MILLIS = 60 * 1000L // OTP chip is offered for 60s after arrival + private val codeRegex = Regex("\\b\\d{4,8}\\b") + private val otpKeywordRegex = Regex( + "otp|code|passcode|password|pin|verification|verify|one[- ]?time|2fa|auth", + RegexOption.IGNORE_CASE + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 5bbb017d9..fafe652d9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -112,6 +112,7 @@ object Defaults { const val PREF_SUGGEST_CLIPBOARD_CONTENT = true const val PREF_SUGGEST_SCREENSHOTS = false const val PREF_COMPRESS_SCREENSHOTS = true + const val PREF_AUTO_READ_OTP = false const val PREF_GESTURE_INPUT = true const val PREF_VIBRATION_DURATION_SETTINGS = -1 const val PREF_VIBRATION_AMPLITUDE_SETTINGS = -1 diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index f5e932291..058f1d824 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -167,6 +167,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history"; public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots"; public static final String PREF_COMPRESS_SCREENSHOTS = "compress_screenshots"; + public static final String PREF_AUTO_READ_OTP = "auto_read_otp"; public static final String PREF_CLIPBOARD_HISTORY_RETENTION_TIME = "clipboard_history_retention_time"; public static final String PREF_CLIPBOARD_HISTORY_PINNED_FIRST = "clipboard_history_pinned_first"; public static final String PREF_CLIPBOARD_FOLD_PINNED = "clipboard_fold_pinned"; diff --git a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java index 1bc0629d4..7f398c4cb 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/SettingsValues.java @@ -167,6 +167,7 @@ public class SettingsValues { private final boolean mOverrideShowingSuggestions; public final boolean mSuggestClipboardContent; public final boolean mSuggestScreenshots; + public final boolean mAutoReadOtp; public final boolean mCompressScreenshots; public final SettingsValuesForSuggestion mSettingsValuesForSuggestion; public final boolean mIncognitoModeEnabled; @@ -269,6 +270,8 @@ public SettingsValues(final Context context, final SharedPreferences prefs, fina Defaults.PREF_SUGGEST_CLIPBOARD_CONTENT); mSuggestScreenshots = prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS); + mAutoReadOtp = prefs.getBoolean(Settings.PREF_AUTO_READ_OTP, + Defaults.PREF_AUTO_READ_OTP); mCompressScreenshots = prefs.getBoolean(Settings.PREF_COMPRESS_SCREENSHOTS, Defaults.PREF_COMPRESS_SCREENSHOTS); mDoubleSpacePeriodTimeout = 1100; // ms diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt index 32d629df4..4be567699 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt @@ -250,6 +250,25 @@ fun createCorrectionSettings(context: Context) = listOf( ) { SwitchPreference(it, Defaults.PREF_COMPRESS_SCREENSHOTS) }, + Setting(context, Settings.PREF_AUTO_READ_OTP, + R.string.auto_read_otp, R.string.auto_read_otp_summary + ) { setting -> + val activity = LocalContext.current.getActivity() ?: return@Setting + var granted by remember { mutableStateOf(PermissionsUtil.checkAllPermissionsGranted(activity, Manifest.permission.RECEIVE_SMS)) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + granted = it + if (granted) + activity.prefs().edit { putBoolean(setting.key, true) } + } + SwitchPreference(setting, Defaults.PREF_AUTO_READ_OTP, + allowCheckedChange = { + if (it && !granted) { + launcher.launch(Manifest.permission.RECEIVE_SMS) + false + } else true + } + ) + }, Setting(context, Settings.PREF_USE_CONTACTS, R.string.use_contacts_dict, R.string.use_contacts_dict_summary ) { setting -> diff --git a/app/src/main/res/layout/otp_suggestion.xml b/app/src/main/res/layout/otp_suggestion.xml new file mode 100644 index 000000000..c8276c630 --- /dev/null +++ b/app/src/main/res/layout/otp_suggestion.xml @@ -0,0 +1,36 @@ + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb0c379d1..69456e5ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -180,6 +180,12 @@ Suggest recent screenshots Show recently taken screenshots as a suggestion + + Auto-read OTP from SMS + + Show the code from an incoming SMS as a suggestion you can tap to insert + + Dismiss OTP suggestion Compress screenshot suggestions From 7131d6253aac5462806ae45b4a71a1ec961059ce Mon Sep 17 00:00:00 2001 From: Rohan Lodhi <42321434+RohanLodhi@users.noreply.github.com> Date: Sat, 6 Jun 2026 17:03:51 +0530 Subject: [PATCH 056/118] changed registration flag --- app/src/main/java/helium314/keyboard/latin/LatinIME.java | 3 ++- .../java/helium314/keyboard/latin/OtpSuggestionManager.kt | 5 ++++- .../keyboard/settings/screens/TextCorrectionScreen.kt | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index b0121f98d..d3abb54f5 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1733,7 +1733,8 @@ public boolean tryShowOtpSuggestion() { if (!hasSuggestionStripView()) return false; final View otpView = mOtpSuggestionManager.getOtpSuggestionView(mSuggestionStripView); if (otpView != null) { - mSuggestionStripView.setExternalSuggestionView(otpView, true); + // false: the OTP chip layout already has its own close button (wired in the manager) + mSuggestionStripView.setExternalSuggestionView(otpView, false); return true; } return false; diff --git a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt index b800462d8..669c36482 100644 --- a/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt +++ b/app/src/main/java/helium314/keyboard/latin/OtpSuggestionManager.kt @@ -76,7 +76,10 @@ class OtpSuggestionManager(private val latinIME: LatinIME) { latinIME, smsReceiver, IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION), - ContextCompat.RECEIVER_NOT_EXPORTED // SMS_RECEIVED is a protected system broadcast + // EXPORTED is required: SMS_RECEIVED is delivered by the system/telephony process + // (an external sender), so a NOT_EXPORTED receiver never receives it. This is safe + // because SMS_RECEIVED is a protected broadcast that only the system can send. + ContextCompat.RECEIVER_EXPORTED ) isRegistered = true } catch (e: Exception) { diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt index 4be567699..a233d286b 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextCorrectionScreen.kt @@ -91,6 +91,7 @@ fun TextCorrectionScreen( Settings.PREF_SUGGEST_SCREENSHOTS, if (prefs.getBoolean(Settings.PREF_SUGGEST_SCREENSHOTS, Defaults.PREF_SUGGEST_SCREENSHOTS)) Settings.PREF_COMPRESS_SCREENSHOTS else null, + Settings.PREF_AUTO_READ_OTP, Settings.PREF_USE_CONTACTS, Settings.PREF_USE_APPS ) From 03f8efd96d3d7c5951fb5b64a971599f245ff756 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 7 Jun 2026 04:10:38 +0000 Subject: [PATCH 057/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 59a40ea91..fde05d550 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2817928179 +DownloadsDownloads2887928879 From 6095b7779ef2478b535f31cdfee63990d82ab453 Mon Sep 17 00:00:00 2001 From: iBasim <57762287+iBasim@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:08:32 +0300 Subject: [PATCH 058/118] Update ar.txt --- app/src/main/assets/locale_key_texts/ar.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/locale_key_texts/ar.txt b/app/src/main/assets/locale_key_texts/ar.txt index f98dc8f6e..ca087aeea 100644 --- a/app/src/main/assets/locale_key_texts/ar.txt +++ b/app/src/main/assets/locale_key_texts/ar.txt @@ -12,7 +12,7 @@ ى ئ ز ژ و ؤ -punctuation !fixedOrder!7 ◌ٕ|ٕ ◌ٔ|ٔ ◌ْ|ْ ◌ٍ|ٍ ◌ٌ|ٌ ◌ً|ً ◌ّ|ّ ◌ٖ|ٖ ◌ٰ|ٰ ◌ٓ|ٓ ◌ِ|ِ ◌ُ|ُ ◌َ|َ ـــ|ـ +punctuation !fixedOrder!7 ّ◌|ّ ْ◌|ْ َ◌|َ ِ◌|ِ ُ◌|ُ ٍ◌|ٍ ً◌|ً ٌ◌|ٌ ٓ◌|ٓ ٰ◌|ٰ ٕ◌|ٕ ٔ◌|ٔ ٖ◌|ٖ ـــ|ـ « „ “ ” » ‚ ‘ ’ ‹ › From 13b64045d2f2388af6042fddcb993fccbac65bd8 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sun, 7 Jun 2026 19:36:17 +0530 Subject: [PATCH 059/118] refactor: replace onnxruntime with llamacpp Switches offline proofreader to llamacpp-kotlin GGUF and updates model settings UI to resolve 16 KB page alignment compatibility warnings. --- app/build.gradle.kts | 11 +- app/proguard-rules.pro | 3 - app/src/main/AndroidManifest.xml | 2 +- .../settings/screens/AdvancedScreen.kt | 77 +-- .../keyboard/latin/utils/ProofreadService.kt | 556 ++++-------------- 5 files changed, 135 insertions(+), 514 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c4549c4ca..55b91a9a3 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,7 +16,7 @@ if (keystorePropertiesFile.exists()) { } android { - compileSdk = 35 + compileSdk = 36 defaultConfig { applicationId = "com.leanbitlab.leantype" @@ -45,6 +45,7 @@ android { create("offline") { dimension = "privacy" applicationIdSuffix = ".offline" + minSdk = 26 } create("offlinelite") { dimension = "privacy" @@ -141,7 +142,7 @@ android { path = File("src/main/jni/Android.mk") } } -// ndkVersion = "28.0.13004108" + ndkVersion = "28.0.13004108" packaging { jniLibs { @@ -235,8 +236,10 @@ dependencies { "standardOptimisedImplementation"("androidx.security:security-crypto:1.1.0-alpha06") // local llm proofreading (offline) - // ONNX Runtime for T5 encoder-decoder grammar models - "offlineImplementation"("com.microsoft.onnxruntime:onnxruntime-android:1.17.3") + "offlineImplementation"("io.github.ljcamargo:llamacpp-kotlin:0.4.0") + + // Force 16 KB page-aligned version of graphics-path + implementation("androidx.graphics:graphics-path:1.1.0") // test testImplementation(kotlin("test")) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 5feeb81f6..50d6ba6d4 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -28,9 +28,6 @@ # Keep java-llama.cpp classes -keep class de.kherud.llama.** { *; } -# ONNX Runtime configurations --dontwarn com.google.protobuf.** --keep class ai.onnxruntime.** { *; } # Fix correct service name -keep class helium314.keyboard.latin.utils.ProofreadService { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2f7bf9bfd..c071760ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only - + diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 133c5069f..7bff825e8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -547,12 +547,10 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( if (BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.OFFLINE_MODEL_PATH, R.string.offline_model_title, R.string.offline_model_summary) { setting -> val context = LocalContext.current val service = remember { helium314.keyboard.latin.utils.ProofreadService(context) } - var encoderPath by remember { mutableStateOf(service.getModelPath()) } - var decoderPath by remember { mutableStateOf(service.getDecoderPath()) } - var tokenizerPath by remember { mutableStateOf(service.getTokenizerPath()) } + var modelPath by remember { mutableStateOf(service.getModelPath()) } - // Encoder file picker - val encoderLauncher = androidx.activity.compose.rememberLauncherForActivityResult( + // GGUF Model file picker + val modelLauncher = androidx.activity.compose.rememberLauncherForActivityResult( contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() ) { uri -> uri?.let { @@ -562,77 +560,26 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( Log.e("AdvancedScreen", "Failed to take persistable permission", e) } service.setModelPath(it.toString()) - encoderPath = it.toString() - FeedbackManager.message(context, "Encoder selected") - } - } - - // Decoder file picker - val decoderLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { - try { - context.contentResolver.takePersistableUriPermission(it, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: Exception) { - Log.e("AdvancedScreen", "Failed to take persistable permission", e) - } - service.setDecoderPath(it.toString()) - decoderPath = it.toString() - FeedbackManager.message(context, "Decoder selected") - } - } - - // Tokenizer file picker - val tokenizerLauncher = androidx.activity.compose.rememberLauncherForActivityResult( - contract = androidx.activity.result.contract.ActivityResultContracts.OpenDocument() - ) { uri -> - uri?.let { - try { - context.contentResolver.takePersistableUriPermission(it, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - } catch (e: Exception) { - Log.e("AdvancedScreen", "Failed to take persistable permission", e) - } - service.setTokenizerPath(it.toString()) - tokenizerPath = it.toString() - FeedbackManager.message(context, "Tokenizer selected") + modelPath = it.toString() + FeedbackManager.message(context, "Model selected") } } androidx.compose.foundation.layout.Column { - // Encoder (required) - Preference( - name = "Encoder Model (.onnx)", - description = if (encoderPath != null) service.getModelName() else "Required - select encoder ONNX file", - onClick = { encoderLauncher.launch(arrayOf("application/octet-stream", "*/*")) } - ) - - // Decoder (required for generation) - Preference( - name = "Decoder Model (.onnx)", - description = if (decoderPath != null) "Selected" else "Required - select decoder ONNX file", - onClick = { decoderLauncher.launch(arrayOf("application/octet-stream", "*/*")) } - ) - - // Tokenizer (required for proper tokenization) Preference( - name = "Tokenizer (tokenizer.json)", - description = if (tokenizerPath != null) "Selected" else "Required - select tokenizer.json", - onClick = { tokenizerLauncher.launch(arrayOf("application/json", "*/*")) } + name = "GGUF Model (.gguf)", + description = if (modelPath != null) service.getModelName() else "Required - select local GGUF model file", + onClick = { modelLauncher.launch(arrayOf("application/octet-stream", "*/*")) } ) - if (encoderPath != null || decoderPath != null || tokenizerPath != null) { + if (modelPath != null) { Preference( - name = "Remove All Models", - description = "Unload models and free memory", + name = "Remove Model", + description = "Unload model and free memory", onClick = { service.unloadModel() service.setModelPath(null) - service.setDecoderPath(null) - service.setTokenizerPath(null) - encoderPath = null - decoderPath = null - tokenizerPath = null + modelPath = null } ) } diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 0b20e490c..3cc4152eb 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -9,9 +9,6 @@ import android.content.SharedPreferences import android.net.Uri import android.provider.OpenableColumns import android.util.Log -import ai.onnxruntime.OnnxTensor -import ai.onnxruntime.OrtEnvironment -import ai.onnxruntime.OrtSession import helium314.keyboard.latin.settings.Defaults import helium314.keyboard.latin.settings.Settings import kotlinx.coroutines.Dispatchers @@ -20,64 +17,62 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.channels.BufferOverflow +import org.nehuatl.llamacpp.LlamaHelper import java.io.File -import java.io.FileOutputStream -import java.nio.FloatBuffer -import java.nio.LongBuffer /** - * Offline proofreading service using ONNX Runtime with T5 grammar correction models. - * - * T5 uses encoder-decoder architecture: - * 1. Encoder: Processes input text → encoder hidden states - * 2. Decoder: Uses hidden states to generate corrected text token by token - * + * Offline proofreading service using llamacpp-kotlin with GGUF models. + * + * Uses LlamaHelper for on-device inference with llama.cpp backend. + * Supports any GGUF model for text correction/generation. + * * Expected model files: - * - encoder_model_quant.onnx - * - init_decoder_quant.onnx (initial decoder) - * - tokenizer.json (T5 vocabulary) + * - Any GGUF format model file */ class ProofreadService(private val context: Context) { - private val prefs: SharedPreferences by lazy { + private val sharedPrefs: SharedPreferences by lazy { context.prefs() } - fun getPrefs(): SharedPreferences = prefs + fun getPrefs(): SharedPreferences = sharedPrefs // Singleton holder for model state to prevent reloading on every request object ModelHolder { - var ortEnvironment: OrtEnvironment? = null - var encoderSession: OrtSession? = null - var decoderSession: OrtSession? = null - var currentEncoderPath: String? = null - var currentDecoderPath: String? = null - var tokenizer: T5Tokenizer? = null + var llamaHelper: LlamaHelper? = null + var currentModelPath: String? = null var isModelAvailable: Boolean = true - private var modelDir: File? = null + var isModelLoaded: Boolean = false // Smart Unload Logic private var unloadJob: Job? = null private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO) private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes + // Flow for LLM events + val llmFlow = MutableSharedFlow( + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + @Synchronized - fun scheduleUnload(context: Context) { // Context required to check prefs + fun scheduleUnload(context: Context) { unloadJob?.cancel() - // Check preference val prefs = context.prefs() val keepLoaded = prefs.getBoolean(Settings.PREF_OFFLINE_KEEP_MODEL_LOADED, Defaults.PREF_OFFLINE_KEEP_MODEL_LOADED) if (keepLoaded) { - Log.i("OnnxProofreadService", "Model unload skipped (Keep Model Loaded enabled)") + Log.i(TAG, "Model unload skipped (Keep Model Loaded enabled)") return } unloadJob = scope.launch { delay(UNLOAD_DELAY_MS) unloadModel() - Log.i("OnnxProofreadService", "Offline AI model unloaded due to inactivity") + Log.i(TAG, "Offline AI model unloaded due to inactivity") } } @@ -90,103 +85,63 @@ class ProofreadService(private val context: Context) { @Synchronized fun unloadModel() { try { - encoderSession?.close() - decoderSession?.close() - ortEnvironment?.close() + llamaHelper?.release() } catch (e: Exception) { - Log.w("OnnxProofreadService", "Error closing ONNX sessions", e) + Log.w(TAG, "Error unloading llama model", e) } - encoderSession = null - decoderSession = null - ortEnvironment = null - currentEncoderPath = null - currentDecoderPath = null - tokenizer = null - isModelAvailable = true // Reset availability flag on unload + llamaHelper = null + currentModelPath = null + isModelLoaded = false + isModelAvailable = true } @Synchronized fun loadModel( context: Context, - encoderPath: String, - decoderPath: String?, - tokenizerPath: String? + modelPath: String ): Boolean { - cancelUnload() // Cancel any pending unload since we are loading/using it + cancelUnload() - // Check if already loaded with same paths - if (encoderSession != null && currentEncoderPath == encoderPath && - (decoderPath.isNullOrBlank() || (decoderSession != null && currentDecoderPath == decoderPath))) { + // Check if already loaded with same path + if (isModelLoaded && currentModelPath == modelPath && llamaHelper != null) { return true } - unloadModel() // Ensure clean slate if paths changed + unloadModel() // Ensure clean slate if path changed return try { - // Create model cache directory - modelDir = File(context.cacheDir, "onnx_model") - modelDir!!.mkdirs() - - // Initialize tokenizer - tokenizer = T5Tokenizer() - if (!tokenizerPath.isNullOrBlank()) { - val tokenizerFile = copyUriToCache(context, Uri.parse(tokenizerPath), "tokenizer.json", modelDir!!) - if (tokenizerFile != null) { - tokenizer!!.loadVocab(tokenizerFile) - } - } - - // Initialize ONNX Runtime - ortEnvironment = OrtEnvironment.getEnvironment() - val sessionOptions = OrtSession.SessionOptions().apply { - setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT) - setIntraOpNumThreads(4) - } - - // Copy and load encoder - val encoderFile = copyUriToCache(context, Uri.parse(encoderPath), "encoder.onnx", modelDir!!) - if (encoderFile == null) { - Log.e("OnnxProofreadService", "Failed to copy encoder") - return false + val contentResolver = context.contentResolver + val helper = LlamaHelper( + contentResolver, + scope, + llmFlow + ) + + // Load the model with default context length + val contextLength = 2048 + var loadSuccess = false + helper.load( + path = modelPath, + contextLength = contextLength + ) { _ -> + loadSuccess = true } - encoderSession = ortEnvironment!!.createSession(encoderFile.absolutePath, sessionOptions) - currentEncoderPath = encoderPath - - // Copy and load decoder if provided - if (!decoderPath.isNullOrBlank()) { - val decoderFile = copyUriToCache(context, Uri.parse(decoderPath), "decoder.onnx", modelDir!!) - if (decoderFile != null) { - decoderSession = ortEnvironment!!.createSession(decoderFile.absolutePath, sessionOptions) - currentDecoderPath = decoderPath - } - } - + // Give the model a moment to load (it's async internally) + // We'll verify on first inference if it's truly loaded + llamaHelper = helper + currentModelPath = modelPath + isModelLoaded = true isModelAvailable = true true } catch (e: Throwable) { - Log.e("OnnxProofreadService", "Failed to load ONNX models", e) + Log.e(TAG, "Failed to load GGUF model", e) isModelAvailable = false false } } - private fun copyUriToCache(context: Context, uri: Uri, targetName: String, dir: File): File? { - val targetFile = File(dir, targetName) - if (targetFile.exists() && targetFile.length() > 0) return targetFile - - return try { - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(targetFile).use { output -> - input.copyTo(output) - } - } - if (targetFile.exists() && targetFile.length() > 0) targetFile else null - } catch (e: Exception) { - Log.e("OnnxProofreadService", "Failed to copy $targetName", e) - null - } - } + private const val TAG = "LlamaProofreadService" } // AI Provider support (API compatibility) @@ -218,26 +173,26 @@ class ProofreadService(private val context: Context) { fun getGroqModel(): String = "Offline Mode" fun setGroqModel(model: String) { /* No-op */ } - // Model management - encoder path - fun getModelPath(): String? = prefs.getString(KEY_ENCODER_PATH, null) + // Model management - single model path (no encoder/decoder split) + fun getModelPath(): String? = sharedPrefs.getString(KEY_MODEL_PATH, null) fun setModelPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { - remove(KEY_ENCODER_PATH) + remove(KEY_MODEL_PATH) } else { - putString(KEY_ENCODER_PATH, path) + putString(KEY_MODEL_PATH, path) } apply() } ModelHolder.unloadModel() } - // Decoder path (separate setting) - fun getDecoderPath(): String? = prefs.getString(KEY_DECODER_PATH, null) + // Decoder path (kept for API compatibility, not used with llamacpp) + fun getDecoderPath(): String? = sharedPrefs.getString(KEY_DECODER_PATH, null) fun setDecoderPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { remove(KEY_DECODER_PATH) } else { @@ -245,14 +200,13 @@ class ProofreadService(private val context: Context) { } apply() } - ModelHolder.unloadModel() } - // Tokenizer path (vocabulary file) - fun getTokenizerPath(): String? = prefs.getString(KEY_TOKENIZER_PATH, null) + // Tokenizer path (not needed with GGUF - tokenizer is embedded) + fun getTokenizerPath(): String? = sharedPrefs.getString(KEY_TOKENIZER_PATH, null) fun setTokenizerPath(path: String?) { - prefs.edit().apply { + sharedPrefs.edit().apply { if (path.isNullOrBlank()) { remove(KEY_TOKENIZER_PATH) } else { @@ -260,14 +214,12 @@ class ProofreadService(private val context: Context) { } apply() } - ModelHolder.unloadModel() - ModelHolder.tokenizer = null } - fun getSystemPrompt(): String = prefs.getString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, "") ?: "" + fun getSystemPrompt(): String = sharedPrefs.getString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, "") ?: "" fun setSystemPrompt(prompt: String) { - prefs.edit().putString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, prompt).apply() + sharedPrefs.edit().putString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, prompt).apply() } fun getModelName(): String { @@ -312,34 +264,25 @@ class ProofreadService(private val context: Context) { } /** - * Copy a content URI to cache and return the local file path. - */ - - - /** - * Run T5 encoder-decoder inference for grammar correction. - */ - /** - * Run T5 encoder-decoder inference for translation. + * Run llamacpp inference for translation. */ suspend fun translate(text: String): Result { - val target = prefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE - // T5 standard prefix for translation - val prompt = "translate English to $target: " + val target = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE + val prompt = "Translate the following text to $target. Output only the translation, nothing else:\n\n" return proofread(text, overridePrompt = prompt) } /** - * Run T5 encoder-decoder inference. + * Run llamacpp inference for proofreading/text correction. */ suspend fun proofread(text: String, overridePrompt: String? = null): Result = withContext(Dispatchers.IO) { - val encoderPath = getModelPath() - if (encoderPath.isNullOrBlank()) { - return@withContext Result.failure(ProofreadException("Model not loaded. Please select encoder ONNX file.")) + val modelPath = getModelPath() + if (modelPath.isNullOrBlank()) { + return@withContext Result.failure(ProofreadException("Model not loaded. Please select a GGUF model file.")) } // Load model (or get cached) - if (!ModelHolder.loadModel(context, encoderPath, getDecoderPath(), getTokenizerPath())) { + if (!ModelHolder.loadModel(context, modelPath)) { Log.e(TAG, "Model load failed") return@withContext Result.failure(ProofreadException("Failed to load model.")) } @@ -348,58 +291,51 @@ class ProofreadService(private val context: Context) { ModelHolder.cancelUnload() try { - val maxTokens = prefs.getInt(Settings.PREF_OFFLINE_MAX_TOKENS, Defaults.PREF_OFFLINE_MAX_TOKENS) + val maxTokens = sharedPrefs.getInt(Settings.PREF_OFFLINE_MAX_TOKENS, Defaults.PREF_OFFLINE_MAX_TOKENS) - // 1. Tokenize input - val prompt = overridePrompt ?: getSystemPrompt() - val inputText = if (prompt.isNotBlank()) "$prompt$text" else text - val inputIds = ModelHolder.tokenizer!!.encode(inputText, addPrefix = false) - - val batchSize = 1L - val seqLen = inputIds.size.toLong() - val inputShape = longArrayOf(batchSize, seqLen) - - // 2. Create input tensors - val inputTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(inputIds), inputShape) - val attentionMask = LongArray(inputIds.size) { 1L } - val attentionTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(attentionMask), inputShape) - - // 3. Run encoder - val encoderInputs = mapOf( - "input_ids" to inputTensor, - "attention_mask" to attentionTensor - ) - - - val startTime = System.currentTimeMillis() - val encoderResults = ModelHolder.encoderSession!!.run(encoderInputs) - val encoderTime = System.currentTimeMillis() - startTime - - // Get encoder hidden states - val encoderOutput = encoderResults[0] - val hiddenStates = encoderOutput.value // [batch, seq, hidden_dim] - - // 4. Run decoder (if available) - val outputText = if (ModelHolder.decoderSession != null && hiddenStates is Array<*>) { - runDecoderLoop(hiddenStates, attentionMask, maxTokens) + // Build the prompt + val systemPrompt = overridePrompt ?: getSystemPrompt() + val fullPrompt = if (systemPrompt.isNotBlank()) { + "$systemPrompt$text" } else { - Log.w(TAG, "Decoder not available, returning original text") - text + // Default proofreading prompt + "Correct the grammar and spelling of the following text. Output only the corrected text, nothing else:\n\n$text" } - // Clean up - inputTensor.close() - attentionTensor.close() - encoderResults.close() + // Collect generated text from the flow + val generatedText = StringBuilder() + val helper = ModelHolder.llamaHelper + ?: return@withContext Result.failure(ProofreadException("Model not available")) + + // Use predict and collect tokens + helper.predict(fullPrompt) + // Collect events until done + ModelHolder.llmFlow.collect { event -> + when (event) { + is LlamaHelper.LLMEvent.Ongoing -> { + generatedText.append(event.word) + } + is LlamaHelper.LLMEvent.Done -> { + return@collect + } + is LlamaHelper.LLMEvent.Error -> { + throw ProofreadException(event.toString()) + } + else -> { /* Ignore other events */ } + } + } + // Schedule unload after work is done ModelHolder.scheduleUnload(context) + val output = generatedText.toString().trim() + // Strip prompt prefix if model echoed it back - val cleanedOutput = if (prompt.isNotBlank() && outputText.startsWith(prompt, ignoreCase = true)) { - outputText.removePrefix(prompt).trimStart() + val cleanedOutput = if (systemPrompt.isNotBlank() && output.startsWith(systemPrompt, ignoreCase = true)) { + output.removePrefix(systemPrompt).trimStart() } else { - outputText + output } if (cleanedOutput.isNotBlank()) { @@ -415,276 +351,14 @@ class ProofreadService(private val context: Context) { } } - /** - * Run decoder auto-regressively to generate output tokens. - * Supports multiple T5 decoder variants: - * - Basic decoder (input_ids, encoder_hidden_states, encoder_attention_mask) - * - Decoder with past (adds past_key_values/pkv_* inputs) - * - Merged decoder (adds use_cache_branch flag) - */ - private fun runDecoderLoop(encoderHiddenStates: Array<*>, encoderAttentionMask: LongArray, maxTokens: Int): String { - if (ModelHolder.decoderSession == null) return "" - - try { - // Get hidden states as 3D array [batch, seq, hidden] - @Suppress("UNCHECKED_CAST") - val hiddenArray = encoderHiddenStates[0] as? Array ?: return "" - val seqLen = hiddenArray.size - val hiddenDim = hiddenArray[0].size - - - - // Flatten hidden states for tensor - val flatHidden = FloatArray(seqLen * hiddenDim) - for (i in 0 until seqLen) { - System.arraycopy(hiddenArray[i], 0, flatHidden, i * hiddenDim, hiddenDim) - } - - // Create encoder_hidden_states tensor - val hiddenShape = longArrayOf(1, seqLen.toLong(), hiddenDim.toLong()) - val hiddenTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(flatHidden), hiddenShape) - - // Create encoder attention mask tensor - val attentionShape = longArrayOf(1, encoderAttentionMask.size.toLong()) - val attentionTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(encoderAttentionMask), attentionShape) - - // Analyze decoder inputs to determine model type - val inputNames = ModelHolder.decoderSession!!.inputNames.toList() - val pkvInputNames = inputNames.filter { it.startsWith("past_key_values") || it.startsWith("pkv") } - val useCacheBranchInput = inputNames.find { it == "use_cache_branch" } - val numLayers = pkvInputNames.size / 4 // 4 tensors per layer (decoder key/value, encoder key/value) - - val hasPkvInputs = pkvInputNames.isNotEmpty() - val isMergedDecoder = useCacheBranchInput != null - - - - // Start with decoder start token (pad_token = 0 for T5) - val generatedTokens = mutableListOf(0L) - val eosTokenId = ModelHolder.tokenizer!!.getEosTokenId() - - // KV-cache storage for decoders that output present.* tensors - var pastKeyValues: Map? = null - - val startTime = System.currentTimeMillis() - - for (step in 0 until maxTokens) { - // For KV-cache models, only pass the last token after first step - var isValidPkv = false - if (hasPkvInputs && pastKeyValues != null) { - val currentPkv = pastKeyValues!!.values.firstOrNull() - if (currentPkv != null) { - val sequenceLength = currentPkv.info.shape[2] - if (sequenceLength > 0) { - isValidPkv = true - } - } - } - - // CRITICAL FIX: Only use valid pastKeyValues if model actually accepts PKV inputs - val inputTokens = if (step > 0 && isValidPkv && hasPkvInputs) { - longArrayOf(generatedTokens.last()) - } else { - generatedTokens.toLongArray() - } - - val decoderShape = longArrayOf(1, inputTokens.size.toLong()) - val decoderInputTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, LongBuffer.wrap(inputTokens), decoderShape) - - // Build decoder inputs - val decoderInputs = mutableMapOf() - for (inputName in inputNames) { - when { - inputName.contains("input_ids") || inputName.contains("decoder_input_ids") -> - decoderInputs[inputName] = decoderInputTensor - inputName.contains("encoder_hidden_states") || inputName.contains("hidden_states") -> - decoderInputs[inputName] = hiddenTensor - inputName.contains("encoder_attention_mask") || inputName.contains("attention_mask") -> - decoderInputs[inputName] = attentionTensor - } - } - - // Handle use_cache_branch for merged decoders - if (isMergedDecoder && useCacheBranchInput != null) { - val useCacheValue = step > 0 // false on first run, true after - val useCacheTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, booleanArrayOf(useCacheValue)) - decoderInputs[useCacheBranchInput] = useCacheTensor - } - - // Add past_key_values from previous step (if available and model expects them) - if (isValidPkv) { - for ((name, tensor) in pastKeyValues!!) { - // Map present.X.* output names to past_key_values.X.* or pkv_* input names - val inputName = name.replace("present", "past_key_values") - if (inputNames.contains(inputName)) { - decoderInputs[inputName] = tensor - } else { - // Try pkv format (pkv_0, pkv_1, etc.) - val pkvMatch = pkvInputNames.find { it.endsWith(name.substringAfter("present.")) } - if (pkvMatch != null) { - decoderInputs[pkvMatch] = tensor - } - } - } - } else if (hasPkvInputs) { - // First step with PKV model or invalid cache: provide zero tensors - // T5 pkv format: pkv_0 to pkv_N where first half is decoder self-attn, second half is encoder cross-attn - // Shape: [batch, num_heads, seq_len, head_dim] - - var numHeads = 8L // Default T5-small - - // improved head detection from model metadata - try { - // Try to find the shape of the first PKV input - val pkvInfo = ModelHolder.decoderSession!!.inputInfo[pkvInputNames.first()] - val shape = pkvInfo?.info as? ai.onnxruntime.TensorInfo - if (shape != null) { - val dims = shape.shape - // Shape is usually [batch, heads, seq, dim] -> index 1 - if (dims.size == 4 && dims[1] > 0) { - numHeads = dims[1] - } - } - } catch (e: Exception) { - Log.w(TAG, "Could not detect numHeads from model info", e) - } - - val headDim = hiddenDim.toLong() / numHeads - val numPkv = pkvInputNames.size - - for (pkvName in pkvInputNames) { - // Determine if this is encoder cross-attention or decoder self-attention - // pkv_0 to pkv_(N/2-1) = decoder self-attention (seq_len = 0 initially) - // pkv_(N/2) to pkv_(N-1) = encoder cross-attention (seq_len = encoder_seq_len) - val pkvIndex = pkvName.removePrefix("pkv_").removePrefix("past_key_values.").toIntOrNull() ?: 0 - val isEncoderPkv = pkvIndex >= numPkv / 2 || pkvName.contains("encoder") - - val pkvSeqLen = if (isEncoderPkv) seqLen.toLong() else 0L - val pkvShape = longArrayOf(1, numHeads, pkvSeqLen, headDim) - val emptyPkv = FloatArray((1 * numHeads * pkvSeqLen * headDim).toInt()) - val pkvTensor = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(emptyPkv), pkvShape) - decoderInputs[pkvName] = pkvTensor - } - } - - - - // Run decoder step - val decoderResults = ModelHolder.decoderSession!!.run(decoderInputs) - - // Get logits (usually first output) - var logitsOutput: Any? = null - val newPastKeyValues = mutableMapOf() - - for (i in 0 until decoderResults.size()) { - val outputInfo = ModelHolder.decoderSession!!.outputNames.toList()[i] - val outputValue = decoderResults[i] - - when { - outputInfo == "logits" || i == 0 -> { - logitsOutput = outputValue.value - } - outputInfo.startsWith("present") -> { - // Save present.* outputs for next step - // Need to copy tensor data since result will be closed - val tensorValue = outputValue.value - if (tensorValue is Array<*>) { - @Suppress("UNCHECKED_CAST") - val floatData = tensorValue as? Array>> - if (floatData != null) { - val batch = floatData.size - val heads = floatData[0].size - val seqL = floatData[0][0].size - val dim = floatData[0][0][0].size - val flat = FloatArray(batch * heads * seqL * dim) - var idx = 0 - for (b in 0 until batch) { - for (h in 0 until heads) { - for (s in 0 until seqL) { - System.arraycopy(floatData[b][h][s], 0, flat, idx, dim) - idx += dim - } - } - } - val shape = longArrayOf(batch.toLong(), heads.toLong(), seqL.toLong(), dim.toLong()) - newPastKeyValues[outputInfo] = OnnxTensor.createTensor(ModelHolder.ortEnvironment!!, FloatBuffer.wrap(flat), shape) - } - } - } - } - } - - val nextToken = getNextToken(logitsOutput, inputTokens.size.toLong() - 1) - - // Close previous PKV tensors and update with new ones - pastKeyValues?.values?.forEach { it.close() } - pastKeyValues = if (newPastKeyValues.isNotEmpty()) newPastKeyValues else null - - decoderInputTensor.close() - decoderResults.close() - - // Check for EOS - if (nextToken == eosTokenId) { - break - } - - generatedTokens.add(nextToken) - } - - val decoderTime = System.currentTimeMillis() - startTime - - // Clean up - pastKeyValues?.values?.forEach { it.close() } - hiddenTensor.close() - attentionTensor.close() - - // Decode tokens (skip first token which is start token) - val outputTokens = generatedTokens.drop(1).toLongArray() - return ModelHolder.tokenizer!!.decode(outputTokens) - - } catch (e: Exception) { - Log.e(TAG, "Decoder loop failed", e) - return "" - } - } - - /** - * Get next token from logits using greedy decoding. - */ - private fun getNextToken(logits: Any?, position: Long): Long { - val pos = position.toInt() - return when (logits) { - is Array<*> -> { - // Shape: [batch, seq, vocab] - @Suppress("UNCHECKED_CAST") - val batchLogits = logits[0] as? Array - if (batchLogits != null && pos < batchLogits.size) { - val vocabLogits = batchLogits[pos] - // Argmax - vocabLogits.indices.maxByOrNull { vocabLogits[it] }?.toLong() ?: 0L - } else 0L - } - is FloatArray -> { - // Direct vocab logits - logits.indices.maxByOrNull { logits[it] }?.toLong() ?: 0L - } - else -> { - Log.w(TAG, "Unknown logits type: ${logits?.javaClass}") - 0L - } - } - } - - - class ProofreadException(message: String) : Exception(message) class TranslateException(message: String) : Exception(message) companion object { - private const val TAG = "OnnxProofreadService" - private const val KEY_ENCODER_PATH = "offline_model_path" + private const val TAG = "LlamaProofreadService" + private const val KEY_MODEL_PATH = "offline_model_path" private const val KEY_DECODER_PATH = "offline_decoder_path" private const val KEY_TOKENIZER_PATH = "offline_tokenizer_path" - val AVAILABLE_MODELS = listOf("T5 Grammar Correction (ONNX)") + val AVAILABLE_MODELS = listOf("GGUF Model (Local)") } } From 5405844358e127b90ab7e7f2af865fed48e02fd1 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sun, 7 Jun 2026 19:59:20 +0530 Subject: [PATCH 060/118] suggestion delete blacklist always. reload blacklist interface add. --- .../keyboard/keyboard/KeyboardSwitcher.java | 4 ++ .../keyboard/latin/DictionaryFacilitator.java | 2 + .../latin/DictionaryFacilitatorImpl.kt | 46 +++++++++++++------ .../helium314/keyboard/latin/LatinIME.java | 4 ++ .../latin/SingleDictionaryFacilitator.kt | 2 + 5 files changed, 43 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 19e7bde5f..047c45d76 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -93,6 +93,10 @@ public static KeyboardSwitcher getInstance() { return sInstance; } + public LatinIME getLatinIME() { + return mLatinIME; + } + private KeyboardSwitcher() { // Intentional empty constructor for singleton. } diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java index fb2525536..0650e0109 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitator.java @@ -107,6 +107,8 @@ void resetDictionaries( /** removes the word from all editable dictionaries, and adds it to a blacklist in case it's in a read-only dictionary */ void removeWord(String word); + void reloadBlacklist(); + void closeDictionaries(); /** main dictionaries are loaded asynchronously after resetDictionaries */ diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 8372761f2..d0a29b794 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -660,6 +660,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { } } + override fun reloadBlacklist() { + for (dictionaryGroup in dictionaryGroups) { + dictionaryGroup.reloadBlacklist() + } + } + override fun clearUserHistoryDictionary(context: Context) { for (dictionaryGroup in dictionaryGroups) { dictionaryGroup.getSubDict(Dictionary.TYPE_USER_HISTORY)?.clear() @@ -791,6 +797,12 @@ private class DictionaryGroup( /** Removes a word from all dictionaries in this group. If the word is in a read-only dictionary, it is blacklisted. */ fun removeWord(word: String) { + addToBlacklist(word) + val lowercase = word.lowercase(locale) + if (word != lowercase) { + addToBlacklist(lowercase) + } + // remove from user history getSubDict(Dictionary.TYPE_USER_HISTORY)?.removeUnigramEntryDynamically(word) @@ -800,26 +812,11 @@ private class DictionaryGroup( val contactsDict = getSubDict(Dictionary.TYPE_CONTACTS) if (contactsDict != null && contactsDict.isInDictionary(word)) { contactsDict.removeUnigramEntryDynamically(word) // will be gone until next reload of dict - addToBlacklist(word) - return } val appsDict = getSubDict(Dictionary.TYPE_APPS) if (appsDict != null && appsDict.isInDictionary(word)) { appsDict.removeUnigramEntryDynamically(word) // will be gone until next reload of dict - addToBlacklist(word) - return - } - - val mainDict = mainDict ?: return - if (mainDict.isValidWord(word)) { - addToBlacklist(word) - return - } - - val lowercase = word.lowercase(locale) - if (getDict(Dictionary.TYPE_MAIN)!!.isValidWord(lowercase)) { - addToBlacklist(lowercase) } } @@ -916,6 +913,25 @@ private class DictionaryGroup( } } + fun reloadBlacklist() { + if (blacklistFile?.isFile != true) { + synchronized(blacklistLock) { + blacklist.clear() + } + return + } + scope.launch { + synchronized(blacklistLock) { + try { + blacklist.clear() + blacklist.addAll(blacklistFile.readLines()) + } catch (e: IOException) { + Log.e(TAG, "Exception while trying to read blacklist from ${blacklistFile.name}", e) + } + } + } + } + // --------------- Dictionary handling ------------------- fun setMainDict(newMainDict: Dictionary?) { diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 3095457be..45da8875a 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1773,6 +1773,10 @@ public void removeSuggestion(final String word) { mDictionaryFacilitator.removeWord(word); } + public DictionaryFacilitator getDictionaryFacilitator() { + return mDictionaryFacilitator; + } + @Override public void removeExternalSuggestions() { setNeutralSuggestionStrip(); diff --git a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt index d9afe3bc1..c4facc3fa 100644 --- a/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt +++ b/app/src/main/java/helium314/keyboard/latin/SingleDictionaryFacilitator.kt @@ -130,6 +130,8 @@ class SingleDictionaryFacilitator(private val dict: Dictionary) : DictionaryFaci override fun removeWord(word: String) {} + override fun reloadBlacklist() {} + override fun clearUserHistoryDictionary(context: Context) {} override fun localesAndConfidences(): String? = null From 736ea03bc1315571bd5b4cadc9fa1f126dc44bac Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sun, 7 Jun 2026 20:05:15 +0530 Subject: [PATCH 061/118] blocked words screen add. dictionary screen integration done. settings strings update. --- .../keyboard/settings/SettingsNavHost.kt | 5 + .../settings/screens/BlockedWordsScreen.kt | 287 ++++++++++++++++++ .../settings/screens/DictionaryScreen.kt | 17 ++ app/src/main/res/values/strings.xml | 5 + 4 files changed, 314 insertions(+) create mode 100644 app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt index 55e295236..7f3d0504f 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsNavHost.kt @@ -31,6 +31,7 @@ import helium314.keyboard.settings.screens.LanguageScreen import helium314.keyboard.settings.screens.MainSettingsScreen import helium314.keyboard.settings.screens.PersonalDictionariesScreen import helium314.keyboard.settings.screens.PersonalDictionaryScreen +import helium314.keyboard.settings.screens.BlockedWordsScreen import helium314.keyboard.settings.screens.PreferencesScreen import helium314.keyboard.settings.screens.SecondaryLayoutScreen import helium314.keyboard.settings.screens.SubtypeScreen @@ -144,6 +145,9 @@ fun SettingsNavHost( composable(SettingsDestination.PersonalDictionaries) { PersonalDictionariesScreen(onClickBack = ::goBack) } + composable(SettingsDestination.BlockedWords) { + BlockedWordsScreen(onClickBack = ::goBack) + } composable(SettingsDestination.Languages) { LanguageScreen(onClickBack = ::goBack) } @@ -186,6 +190,7 @@ object SettingsDestination { const val ColorsNight = "colors_night/" const val PersonalDictionaries = "personal_dictionaries" const val PersonalDictionary = "personal_dictionary/" + const val BlockedWords = "blocked_words" const val Languages = "languages" const val Subtype = "subtype/" const val Layouts = "layouts" diff --git a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt new file mode 100644 index 000000000..0987e0a8c --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.screens + +import android.content.Context +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import helium314.keyboard.latin.R +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.settings.DropDownField +import helium314.keyboard.settings.SearchScreen +import helium314.keyboard.settings.dialogs.ThreeButtonAlertDialog +import helium314.keyboard.settings.dialogs.ConfirmationDialog +import java.io.File +import java.io.IOException +import java.util.Locale + +private data class BlockedWord(val word: String, val locale: Locale) + +private fun getBlacklistFile(context: Context, locale: Locale): File { + val dir = File(context.filesDir, "blacklists") + if (!dir.exists()) dir.mkdirs() + return File(dir, "${locale.toLanguageTag()}.txt") +} + +private fun loadBlockedWords(context: Context): List { + val dir = File(context.filesDir, "blacklists") + if (!dir.exists() || !dir.isDirectory) return emptyList() + val list = mutableListOf() + dir.listFiles()?.forEach { file -> + if (file.isFile && file.name.endsWith(".txt")) { + val localeTag = file.name.substringBefore(".txt") + val locale = Locale.forLanguageTag(localeTag) + try { + file.readLines().forEach { line -> + val trimmed = line.trim() + if (trimmed.isNotEmpty()) { + list.add(BlockedWord(trimmed, locale)) + } + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error reading blacklist file $file", e) + } + } + } + return list.sortedWith(compareBy({ it.word.lowercase() }, { it.locale.toLanguageTag() })) +} + +private fun addBlockedWord(context: Context, word: String, locale: Locale) { + val file = getBlacklistFile(context, locale) + try { + val existing = if (file.exists()) file.readLines().map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() else mutableSetOf() + if (existing.add(word)) { + file.writeText(existing.joinToString("\n") + "\n") + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error adding word to blacklist", e) + } +} + +private fun removeBlockedWord(context: Context, word: String, locale: Locale) { + val file = getBlacklistFile(context, locale) + try { + if (file.exists()) { + val existing = file.readLines().map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() + if (existing.remove(word)) { + if (existing.isEmpty()) { + file.delete() + } else { + file.writeText(existing.joinToString("\n") + "\n") + } + } + } + } catch (e: Exception) { + Log.e("BlockedWords", "Error removing word from blacklist", e) + } +} + +private fun notifyKeyboardToReload() { + KeyboardSwitcher.getInstance().getLatinIME()?.getDictionaryFacilitator()?.reloadBlacklist() +} + +@Composable +fun BlockedWordsScreen( + onClickBack: () -> Unit, +) { + val ctx = LocalContext.current + var refreshTrigger by remember { mutableStateOf(0) } + val blockedWords = remember(refreshTrigger) { loadBlockedWords(ctx) } + var selectedWord: BlockedWord? by remember { mutableStateOf(null) } + var showClearAllDialog by remember { mutableStateOf(false) } + + Box(Modifier.fillMaxSize()) { + SearchScreen( + onClickBack = onClickBack, + title = { Text(stringResource(R.string.edit_blocked_words)) }, + menu = listOf( + stringResource(R.string.clear_all) to { showClearAllDialog = true } + ), + filteredItems = { term -> + blockedWords.filter { it.word.startsWith(term, true) } + }, + itemContent = { item -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + .clickable { selectedWord = item } + .padding(vertical = 6.dp, horizontal = 16.dp) + ) { + Column(modifier = Modifier.weight(1f)) { + Text(item.word, style = MaterialTheme.typography.bodyLarge) + Text( + item.locale.getLocaleDisplayNameForUserDictSettings(ctx), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + androidx.compose.material3.IconButton( + onClick = { + removeBlockedWord(ctx, item.word, item.locale) + notifyKeyboardToReload() + refreshTrigger++ + } + ) { + Icon( + painter = painterResource(R.drawable.ic_bin), + contentDescription = stringResource(R.string.delete) + ) + } + } + } + ) + ExtendedFloatingActionButton( + onClick = { selectedWord = BlockedWord("", getSortedDictionaryLocales().firstOrNull() ?: Locale.getDefault()) }, + text = { Text(stringResource(R.string.add_blocked_word)) }, + icon = { Icon(painter = painterResource(R.drawable.ic_plus), stringResource(R.string.add_blocked_word)) }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(all = 12.dp) + .then(Modifier.safeDrawingPadding()) + ) + } + + if (selectedWord != null) { + EditBlockedWordDialog(selectedWord!!, onDismissRequest = { + selectedWord = null + refreshTrigger++ + }) + } + + if (showClearAllDialog) { + ConfirmationDialog( + onDismissRequest = { showClearAllDialog = false }, + onConfirmed = { + showClearAllDialog = false + val dir = File(ctx.filesDir, "blacklists") + if (dir.exists() && dir.isDirectory) { + dir.listFiles()?.forEach { it.delete() } + } + notifyKeyboardToReload() + refreshTrigger++ + }, + content = { Text(stringResource(R.string.clear_all_blocked_words_confirmation)) } + ) + } +} + +@Composable +private fun EditBlockedWordDialog( + blockedWord: BlockedWord, + onDismissRequest: () -> Unit +) { + val ctx = LocalContext.current + val focusRequester = remember { FocusRequester() } + var wordText by remember { mutableStateOf(blockedWord.word) } + var wordLocale by remember { mutableStateOf(blockedWord.locale) } + + val localesList = remember { getSortedDictionaryLocales().toList() } + + val alreadyExists = remember(wordText, wordLocale) { + if (wordText.isBlank()) false + else { + val file = File(ctx.filesDir, "blacklists/${wordLocale.toLanguageTag()}.txt") + if (file.exists()) file.readLines().map { it.trim() }.contains(wordText.trim()) else false + } + } + + val isNew = blockedWord.word.isEmpty() + val isSaveEnabled = wordText.isNotBlank() && (!alreadyExists || (!isNew && wordText == blockedWord.word && wordLocale == blockedWord.locale)) + + fun save() { + if (wordText.isNotBlank()) { + val cleanWord = wordText.trim() + if (!isNew && (blockedWord.word != cleanWord || blockedWord.locale != wordLocale)) { + removeBlockedWord(ctx, blockedWord.word, blockedWord.locale) + } + addBlockedWord(ctx, cleanWord, wordLocale) + notifyKeyboardToReload() + } + } + + ThreeButtonAlertDialog( + onDismissRequest = onDismissRequest, + onConfirmed = { + save() + onDismissRequest() + }, + checkOk = { isSaveEnabled }, + confirmButtonText = stringResource(R.string.save), + neutralButtonText = if (isNew) null else stringResource(R.string.delete), + onNeutral = { + removeBlockedWord(ctx, blockedWord.word, blockedWord.locale) + notifyKeyboardToReload() + onDismissRequest() + }, + title = { + Text(if (isNew) stringResource(R.string.add_blocked_word) else stringResource(R.string.edit_blocked_words)) + }, + content = { + LaunchedEffect(blockedWord) { + if (isNew) { + focusRequester.requestFocus() + } + } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + TextField( + value = wordText, + onValueChange = { wordText = it }, + modifier = Modifier.fillMaxWidth().focusRequester(focusRequester), + singleLine = true, + label = { Text("Word") }, + keyboardActions = KeyboardActions { + if (isSaveEnabled) { + save() + onDismissRequest() + } + } + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.user_dict_settings_add_locale_option_name), Modifier.fillMaxWidth(0.3f)) + DropDownField( + items = localesList, + selectedItem = wordLocale, + onSelected = { wordLocale = it }, + ) { + Text(it.getLocaleDisplayNameForUserDictSettings(ctx)) + } + } + if (alreadyExists && (isNew || wordText != blockedWord.word || wordLocale != blockedWord.locale)) { + Text( + stringResource(R.string.blocked_word_already_present), + color = MaterialTheme.colorScheme.error + ) + } + } + } + ) +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt index 572e76f3e..f1e380f2f 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -114,6 +114,23 @@ fun DictionaryScreen( NextScreenIcon() } androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) + + // Blocked Words Entry + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(vertical = 4.dp, horizontal = 16.dp) + .fillMaxWidth() + .clickable { SettingsDestination.navigateTo(SettingsDestination.BlockedWords) } + ) { + Text( + stringResource(R.string.edit_blocked_words), + style = MaterialTheme.typography.titleMedium + ) + NextScreenIcon() + } + androidx.compose.material3.Divider(modifier = Modifier.padding(vertical = 4.dp)) // Personal Dictionary Setting val prefs = ctx.prefs() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cb0c379d1..32258a6fc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,11 @@ Capitalize the first word of each sentence Personal dictionary + Blocked words + Manage words blocked from suggestions + Block a word + Word is already blocked + Are you sure you want to unblock all words? Main dictionary From bae38694f2c5f57e6dba0c2881b91d13c4385926 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sun, 7 Jun 2026 20:22:29 +0530 Subject: [PATCH 062/118] blacklist check case-insensitive. lowercase canonicalization added. user dictionary suggestion leak resolved. --- .../latin/DictionaryFacilitatorImpl.kt | 18 +++++++------- .../settings/screens/BlockedWordsScreen.kt | 24 ++++++++++++------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index d0a29b794..f4dfaa5d4 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -875,7 +875,7 @@ private class DictionaryGroup( scope.launch { synchronized(blacklistLock) { try { - addAll(blacklistFile.readLines()) + addAll(blacklistFile.readLines().map { it.lowercase(locale) }) } catch (e: IOException) { Log.e(TAG, "Exception while trying to read blacklist from ${blacklistFile.name}", e) } @@ -883,28 +883,30 @@ private class DictionaryGroup( } } - fun isBlacklisted(word: String) = blacklist.contains(word) + fun isBlacklisted(word: String): Boolean = blacklist.contains(word.lowercase(locale)) fun addToBlacklist(word: String) { - if (!blacklist.add(word) || blacklistFile == null) return + val lowercase = word.lowercase(locale) + if (!blacklist.add(lowercase) || blacklistFile == null) return scope.launch { synchronized(blacklistLock) { try { if (blacklistFile.isDirectory) blacklistFile.delete() - blacklistFile.appendText("$word\n") + blacklistFile.appendText("$lowercase\n") } catch (e: IOException) { - Log.e(TAG, "Exception while trying to add word \"$word\" to blacklist ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to add word \"$lowercase\" to blacklist ${blacklistFile.name}", e) } } } } fun removeFromBlacklist(word: String) { - if (!blacklist.remove(word) || blacklistFile == null) return + val lowercase = word.lowercase(locale) + if (!blacklist.remove(lowercase) || blacklistFile == null) return scope.launch { synchronized(blacklistLock) { try { - val newLines = blacklistFile.readLines().filterNot { it == word } + val newLines = blacklistFile.readLines().filterNot { it.lowercase(locale) == lowercase } blacklistFile.writeText(newLines.joinToString("\n")) } catch (e: IOException) { Log.e(TAG, "Exception while trying to remove word \"$word\" to blacklist ${blacklistFile.name}", e) @@ -924,7 +926,7 @@ private class DictionaryGroup( synchronized(blacklistLock) { try { blacklist.clear() - blacklist.addAll(blacklistFile.readLines()) + blacklist.addAll(blacklistFile.readLines().map { it.lowercase(locale) }) } catch (e: IOException) { Log.e(TAG, "Exception while trying to read blacklist from ${blacklistFile.name}", e) } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt index 0987e0a8c..e363c7db1 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/BlockedWordsScreen.kt @@ -60,7 +60,7 @@ private fun loadBlockedWords(context: Context): List { val locale = Locale.forLanguageTag(localeTag) try { file.readLines().forEach { line -> - val trimmed = line.trim() + val trimmed = line.trim().lowercase(locale) if (trimmed.isNotEmpty()) { list.add(BlockedWord(trimmed, locale)) } @@ -70,14 +70,16 @@ private fun loadBlockedWords(context: Context): List { } } } - return list.sortedWith(compareBy({ it.word.lowercase() }, { it.locale.toLanguageTag() })) + val uniqueList = list.distinct() + return uniqueList.sortedWith(compareBy({ it.word.lowercase() }, { it.locale.toLanguageTag() })) } private fun addBlockedWord(context: Context, word: String, locale: Locale) { val file = getBlacklistFile(context, locale) + val lowercaseWord = word.trim().lowercase(locale) try { - val existing = if (file.exists()) file.readLines().map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() else mutableSetOf() - if (existing.add(word)) { + val existing = if (file.exists()) file.readLines().map { it.trim().lowercase(locale) }.filter { it.isNotEmpty() }.toMutableSet() else mutableSetOf() + if (existing.add(lowercaseWord)) { file.writeText(existing.joinToString("\n") + "\n") } } catch (e: Exception) { @@ -87,10 +89,11 @@ private fun addBlockedWord(context: Context, word: String, locale: Locale) { private fun removeBlockedWord(context: Context, word: String, locale: Locale) { val file = getBlacklistFile(context, locale) + val lowercaseWord = word.trim().lowercase(locale) try { if (file.exists()) { - val existing = file.readLines().map { it.trim() }.filter { it.isNotEmpty() }.toMutableSet() - if (existing.remove(word)) { + val existing = file.readLines().map { it.trim().lowercase(locale) }.filter { it.isNotEmpty() }.toMutableSet() + if (existing.remove(lowercaseWord)) { if (existing.isEmpty()) { file.delete() } else { @@ -210,17 +213,20 @@ private fun EditBlockedWordDialog( if (wordText.isBlank()) false else { val file = File(ctx.filesDir, "blacklists/${wordLocale.toLanguageTag()}.txt") - if (file.exists()) file.readLines().map { it.trim() }.contains(wordText.trim()) else false + if (file.exists()) { + val cleanLower = wordText.trim().lowercase(wordLocale) + file.readLines().map { it.trim().lowercase(wordLocale) }.contains(cleanLower) + } else false } } val isNew = blockedWord.word.isEmpty() - val isSaveEnabled = wordText.isNotBlank() && (!alreadyExists || (!isNew && wordText == blockedWord.word && wordLocale == blockedWord.locale)) + val isSaveEnabled = wordText.isNotBlank() && (!alreadyExists || (!isNew && wordText.trim().lowercase(wordLocale) == blockedWord.word.lowercase(blockedWord.locale) && wordLocale == blockedWord.locale)) fun save() { if (wordText.isNotBlank()) { val cleanWord = wordText.trim() - if (!isNew && (blockedWord.word != cleanWord || blockedWord.locale != wordLocale)) { + if (!isNew && (blockedWord.word.lowercase(blockedWord.locale) != cleanWord.lowercase(wordLocale) || blockedWord.locale != wordLocale)) { removeBlockedWord(ctx, blockedWord.word, blockedWord.locale) } addBlockedWord(ctx, cleanWord, wordLocale) From 27f003f0def38a6e623652acc23602bd0446cd17 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sun, 7 Jun 2026 20:23:58 +0530 Subject: [PATCH 063/118] blacklist regex support added. compiled patterns cached. compile-time receiver errors resolved. --- .../latin/DictionaryFacilitatorImpl.kt | 59 ++++++++++++++----- 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index f4dfaa5d4..bcf618162 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -870,31 +870,53 @@ private class DictionaryGroup( else null } + @Volatile + private var compiledBlacklistPatterns: List = emptyList() + + private fun rebuildCompiledPatterns() { + compiledBlacklistPatterns = blacklist.map { pattern -> + try { + Regex(pattern, RegexOption.IGNORE_CASE) + } catch (e: Exception) { + Regex(Regex.escape(pattern), RegexOption.IGNORE_CASE) + } + } + } + private val blacklist = hashSetOf().apply { - if (blacklistFile?.isFile != true) return@apply + val file = blacklistFile + if (file?.isFile != true) return@apply scope.launch { synchronized(blacklistLock) { try { - addAll(blacklistFile.readLines().map { it.lowercase(locale) }) + addAll(file.readLines().map { it.lowercase(locale) }) + rebuildCompiledPatterns() } catch (e: IOException) { - Log.e(TAG, "Exception while trying to read blacklist from ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) } } } } - fun isBlacklisted(word: String): Boolean = blacklist.contains(word.lowercase(locale)) + fun isBlacklisted(word: String): Boolean { + val patterns = compiledBlacklistPatterns + return patterns.any { it.matches(word) } + } fun addToBlacklist(word: String) { val lowercase = word.lowercase(locale) - if (!blacklist.add(lowercase) || blacklistFile == null) return + synchronized(blacklistLock) { + if (!blacklist.add(lowercase) || blacklistFile == null) return + rebuildCompiledPatterns() + } + val file = blacklistFile ?: return scope.launch { synchronized(blacklistLock) { try { - if (blacklistFile.isDirectory) blacklistFile.delete() - blacklistFile.appendText("$lowercase\n") + if (file.isDirectory) file.delete() + file.appendText("$lowercase\n") } catch (e: IOException) { - Log.e(TAG, "Exception while trying to add word \"$lowercase\" to blacklist ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to add word \"$lowercase\" to blacklist ${file.name}", e) } } } @@ -902,23 +924,29 @@ private class DictionaryGroup( fun removeFromBlacklist(word: String) { val lowercase = word.lowercase(locale) - if (!blacklist.remove(lowercase) || blacklistFile == null) return + synchronized(blacklistLock) { + if (!blacklist.remove(lowercase) || blacklistFile == null) return + rebuildCompiledPatterns() + } + val file = blacklistFile ?: return scope.launch { synchronized(blacklistLock) { try { - val newLines = blacklistFile.readLines().filterNot { it.lowercase(locale) == lowercase } - blacklistFile.writeText(newLines.joinToString("\n")) + val newLines = file.readLines().filterNot { it.lowercase(locale) == lowercase } + file.writeText(newLines.joinToString("\n")) } catch (e: IOException) { - Log.e(TAG, "Exception while trying to remove word \"$word\" to blacklist ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to remove word \"$word\" to blacklist ${file.name}", e) } } } } fun reloadBlacklist() { - if (blacklistFile?.isFile != true) { + val file = blacklistFile + if (file == null || !file.isFile) { synchronized(blacklistLock) { blacklist.clear() + rebuildCompiledPatterns() } return } @@ -926,9 +954,10 @@ private class DictionaryGroup( synchronized(blacklistLock) { try { blacklist.clear() - blacklist.addAll(blacklistFile.readLines().map { it.lowercase(locale) }) + blacklist.addAll(file.readLines().map { it.lowercase(locale) }) + rebuildCompiledPatterns() } catch (e: IOException) { - Log.e(TAG, "Exception while trying to read blacklist from ${blacklistFile.name}", e) + Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) } } } From c53c8eaa1aa05201b533f3de197586b0f6b0981b Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sun, 7 Jun 2026 22:28:15 +0530 Subject: [PATCH 064/118] SearchScreen remember key fix. filteredItems lambda dependency added. list auto-refresh working. --- app/src/main/java/helium314/keyboard/settings/SearchScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt index a9189ec0c..a9dd8b6dc 100644 --- a/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/SearchScreen.kt @@ -244,7 +244,7 @@ fun SearchScreen( content() } } else { - val items = remember(searchText.text) { filteredItems(searchText.text) } + val items = remember(searchText.text, filteredItems) { filteredItems(searchText.text) } Scaffold( contentWindowInsets = WindowInsets(0) ) { innerPadding -> From f91617dd3c0e81761c69b5f895c7f11ceb0a8507 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sun, 7 Jun 2026 23:32:34 +0530 Subject: [PATCH 065/118] fix(layout): align Arabic diacritics spacing --- app/src/main/assets/locale_key_texts/ar.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/locale_key_texts/ar.txt b/app/src/main/assets/locale_key_texts/ar.txt index ca087aeea..dfb44df4d 100644 --- a/app/src/main/assets/locale_key_texts/ar.txt +++ b/app/src/main/assets/locale_key_texts/ar.txt @@ -12,7 +12,7 @@ ى ئ ز ژ و ؤ -punctuation !fixedOrder!7 ّ◌|ّ ْ◌|ْ َ◌|َ ِ◌|ِ ُ◌|ُ ٍ◌|ٍ ً◌|ً ٌ◌|ٌ ٓ◌|ٓ ٰ◌|ٰ ٕ◌|ٕ ٔ◌|ٔ ٖ◌|ٖ ـــ|ـ +punctuation !fixedOrder!7 ّ◌|ّ ْ◌|ْ َ◌|َ ِ◌|ِ ُ◌|ُ ٍ◌|ٍ ً◌|ً ٌ◌|ٌ ٓ◌|ٓ ٰ◌|ٰ ٕ◌|ٕ ٔ◌|ٔ ٖ◌|ٖ ـــ|ـ « „ “ ” » ‚ ‘ ’ ‹ › From 74aef035e30cc013db913df166f48d5510324416 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 8 Jun 2026 04:14:50 +0000 Subject: [PATCH 066/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index fde05d550..34bae8ead 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2887928879 +DownloadsDownloads2909629096 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index a3ec88a5a..9204362ae 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars471471 +StarsStars472472 From 33b98cfbff3ff982fa50660b7202b49f6173cdf0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 03:43:06 +0000 Subject: [PATCH 067/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 34bae8ead..4c70cbc36 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2909629096 +DownloadsDownloads2927529275 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index 9204362ae..bd3adcd64 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars472472 +StarsStars475475 From f646b93a8151a3b290a589c6d0378de20d3119ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 10 Jun 2026 03:57:38 +0000 Subject: [PATCH 068/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 4c70cbc36..2a7af187f 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2927529275 +DownloadsDownloads2945829458 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index bd3adcd64..6834cbe4c 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars475475 +StarsStars477477 From ef14ac35d6b36f1958ed8ba1d1e570a0a65b3536 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 11 Jun 2026 04:11:24 +0000 Subject: [PATCH 069/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 2a7af187f..a0c73a87c 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2945829458 +DownloadsDownloads2962129621 From a4cc084ff671e09587296cfdba365923444fdc7c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 12 Jun 2026 04:12:47 +0000 Subject: [PATCH 070/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index a0c73a87c..c36fbeca2 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2962129621 +DownloadsDownloads2975029750 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index 6834cbe4c..757f11fca 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars477477 +StarsStars476476 From e6f1caaf2cd4c4548bc0b0fdbb8058c3e925b6d5 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 11 Jun 2026 22:04:04 -0700 Subject: [PATCH 071/118] Allow for reasoning models; handle structured content arrays in API responses Parse JSONArray content format with type and text fields. Extract reasoning_content when main content is blank. Fall back to firstChoice text field if content extraction fails. --- .../keyboard/latin/utils/ProofreadService.kt | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt index 4edd4a2ec..bd9e4a19b 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -520,6 +520,39 @@ class ProofreadService(private val context: Context) { val firstChoice = choices.getJSONObject(0) val message = firstChoice.optJSONObject("message") var content = message?.optString("content", "") ?: "" + + if (message != null) { + val contentArray = message.optJSONArray("content") + if (contentArray != null) { + val parts = mutableListOf() + for (i in 0 until contentArray.length()) { + when (val part = contentArray.opt(i)) { + is String -> if (part.isNotBlank()) parts.add(part) + is JSONObject -> { + val type = part.optString("type", "") + val text = part.optString("text", "").ifBlank { + part.optString("content", "") + } + if (text.isNotBlank() && (showThinking || type != "reasoning")) { + parts.add(text) + } + } + } + } + content = parts.joinToString("\n") + } + + content = content.trim() + if (content.isBlank() && showThinking) { + content = message.optString("reasoning_content", "").trim().ifBlank { + message.optString("reasoning", "").trim() + } + } + } + + if (content.isBlank()) { + content = firstChoice.optString("text", "").trim() + } if (!showThinking && content.isNotBlank()) { // Filter out ... blocks From dfaf16bfe205516bb1b17bbd2cdff829abcd14e2 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Mon, 8 Jun 2026 19:11:12 +0530 Subject: [PATCH 072/118] feat: add regex expander & fix dictionary crash --- .../keyboard/latin/utils/TextExpanderUtils.kt | 24 +++++- .../settings/screens/DictionaryScreen.kt | 4 +- .../settings/screens/TextExpanderScreen.kt | 86 ++++++++++++++++--- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 5c91ebe86..2e67f4449 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -16,6 +16,7 @@ object TextExpanderUtils { const val PREF_ENABLED = "pref_text_expander_enabled" const val PREF_PREFIX = "pref_text_expander_prefix" const val PREF_DATA = "pref_text_expander_data" + const val REGEX_PREFIX = "__regex__:" fun isEnabled(context: Context): Boolean { return context.prefs().getBoolean(PREF_ENABLED, false) @@ -195,8 +196,27 @@ object TextExpanderUtils { val shortcuts = getShortcuts(context) // Check exact match or lowercase match - val template = shortcuts[shortcut] ?: shortcuts[shortcut.lowercase(Locale.getDefault())] ?: return null + val template = shortcuts[shortcut] ?: shortcuts[shortcut.lowercase(Locale.getDefault())] + if (template != null) { + return expand(template, context) + } + + // Check regex matches + for ((key, value) in shortcuts) { + if (key.startsWith(REGEX_PREFIX)) { + val patternStr = key.substring(REGEX_PREFIX.length) + try { + val regex = Regex(patternStr, RegexOption.IGNORE_CASE) + if (regex.matches(shortcut)) { + val replaced = regex.replace(shortcut, value) + return expand(replaced, context) + } + } catch (e: Exception) { + // ignore invalid regex + } + } + } - return expand(template, context) + return null } } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt index f1e380f2f..cdf8c8bff 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/DictionaryScreen.kt @@ -64,7 +64,9 @@ fun DictionaryScreen( val enabledLanguages = SubtypeSettings.getEnabledSubtypes(true).map { it.locale().language } val cachedDictFolders = DictionaryInfoUtils.getCacheDirectories(ctx).map { it.name } val comparer = compareBy({ it.language !in enabledLanguages }, { it.toLanguageTag() !in cachedDictFolders }, { it.displayName }) - val dictionaryLocales = listOf(Locale(SubtypeLocaleUtils.NO_LANGUAGE)) + getDictionaryLocales(ctx).sortedWith(comparer) + val dictionaryLocales = listOf(Locale(SubtypeLocaleUtils.NO_LANGUAGE)) + getDictionaryLocales(ctx) + .filter { it.language != SubtypeLocaleUtils.NO_LANGUAGE } + .sortedWith(comparer) var selectedLocale: Locale? by remember { mutableStateOf(null) } var showAddDictDialog by remember { mutableStateOf(false) } val dictPicker = dictionaryFilePicker(selectedLocale) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index e8a1a8014..a118e53bc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -89,6 +89,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { var editingShortcut by remember { mutableStateOf("") } var editingTemplate by remember { mutableStateOf(TextFieldValue("")) } var originalShortcutToEdit by remember { mutableStateOf(null) } + var editingIsRegex by remember { mutableStateOf(false) } Box(modifier = Modifier.fillMaxSize()) { SearchScreen( @@ -104,7 +105,10 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { filteredItems = { term -> shortcutsMap.entries .filter { (shortcut, template) -> - shortcut.contains(term, ignoreCase = true) || + val displayShortcut = if (shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX)) { + shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) + } else shortcut + displayShortcut.contains(term, ignoreCase = true) || template.contains(term, ignoreCase = true) } .map { Pair(it.key, it.value) } @@ -116,9 +120,11 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { template = template, prefix = prefixText, onEdit = { - editingShortcut = shortcut + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut editingTemplate = TextFieldValue(template) originalShortcutToEdit = shortcut + editingIsRegex = isRegex showAddDialog = true }, onDelete = { @@ -382,9 +388,11 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { template = template, prefix = prefixText, onEdit = { - editingShortcut = shortcut + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + editingShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut editingTemplate = TextFieldValue(template) originalShortcutToEdit = shortcut + editingIsRegex = isRegex showAddDialog = true }, onDelete = { @@ -409,6 +417,7 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { editingShortcut = "" editingTemplate = TextFieldValue("") originalShortcutToEdit = null + editingIsRegex = false showAddDialog = true }, text = { Text("Add Shortcut") }, @@ -425,20 +434,28 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { if (showAddDialog) { val focusRequester = remember { FocusRequester() } val isEditMode = originalShortcutToEdit != null + val isRegexValid = remember(editingShortcut, editingIsRegex) { + !editingIsRegex || runCatching { Regex(editingShortcut.trim()) }.isSuccess + } ThreeButtonAlertDialog( onDismissRequest = { showAddDialog = false }, onConfirmed = { val updated = shortcutsMap.toMutableMap() - if (isEditMode && originalShortcutToEdit != editingShortcut) { + if (isEditMode) { updated.remove(originalShortcutToEdit) } - updated[editingShortcut.trim()] = editingTemplate.text + val key = if (editingIsRegex) { + TextExpanderUtils.REGEX_PREFIX + editingShortcut.trim() + } else { + editingShortcut.trim() + } + updated[key] = editingTemplate.text shortcutsMap = updated TextExpanderUtils.saveShortcuts(context, updated) showAddDialog = false }, - checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() }, + checkOk = { editingShortcut.trim().isNotEmpty() && editingTemplate.text.isNotEmpty() && isRegexValid }, confirmButtonText = if (isEditMode) "Save" else "Add", neutralButtonText = if (isEditMode) "Delete" else null, onNeutral = { @@ -460,13 +477,41 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { TextField( value = editingShortcut, - onValueChange = { editingShortcut = it.replace(" ", "") }, + onValueChange = { editingShortcut = if (editingIsRegex) it else it.replace(" ", "") }, modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), singleLine = true, - label = { Text("Shortcut (e.g. 'brb', 'em')") } + label = { Text(if (editingIsRegex) "Regex Pattern (e.g. '(\\d+)usd')" else "Shortcut (e.g. 'brb', 'em')") } ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = "Regular Expression", + style = MaterialTheme.typography.bodyMedium + ) + androidx.compose.material3.Switch( + checked = editingIsRegex, + onCheckedChange = { checked -> + editingIsRegex = checked + if (!checked) { + editingShortcut = editingShortcut.replace(" ", "") + } + } + ) + } + + if (editingIsRegex && !isRegexValid) { + Text( + text = "⚠️ Invalid regular expression pattern", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall + ) + } OutlinedTextField( value = editingTemplate, @@ -538,6 +583,9 @@ private fun ShortcutItem( onEdit: () -> Unit, onDelete: () -> Unit ) { + val isRegex = shortcut.startsWith(TextExpanderUtils.REGEX_PREFIX) + val displayShortcut = if (isRegex) shortcut.substring(TextExpanderUtils.REGEX_PREFIX.length) else shortcut + ElevatedCard( modifier = Modifier .fillMaxWidth() @@ -556,7 +604,10 @@ private fun ShortcutItem( horizontalArrangement = Arrangement.SpaceBetween ) { Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { Box( modifier = Modifier .clip(RoundedCornerShape(8.dp)) @@ -564,13 +615,28 @@ private fun ShortcutItem( .padding(horizontal = 8.dp, vertical = 4.dp) ) { Text( - text = "$prefix$shortcut", + text = if (isRegex) displayShortcut else "$prefix$displayShortcut", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold, fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace ) } + if (isRegex) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer) + .padding(horizontal = 6.dp, vertical = 2.dp) + ) { + Text( + text = "Regex", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + fontWeight = FontWeight.Bold + ) + } + } } Spacer(modifier = Modifier.height(8.dp)) Text( From da4d87fe09c77091c63e731d25bc16b93c3a0854 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 9 Jun 2026 20:31:52 +0530 Subject: [PATCH 073/118] feat(touchpad): double tap to select word & fix emoji popup preview --- .../helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt | 2 ++ .../java/helium314/keyboard/keyboard/KeyboardLayoutSet.java | 1 + .../keyboard/keyboard/clipboard/ClipboardHistoryView.kt | 1 + .../helium314/keyboard/keyboard/emoji/EmojiPalettesView.java | 2 ++ 4 files changed, 6 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 9d43053a9..e9840ea63 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -564,6 +564,8 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onDoubleTap() { if (connection.hasSelection()) { onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } } override fun onScroll(direction: Int) { diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java index 572b9efb2..fd3095a40 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardLayoutSet.java @@ -227,6 +227,7 @@ public static KeyboardLayoutSet buildEmojiClipBottomRow(final Context context, @ final int height = ResourceUtils.getKeyboardHeight(context.getResources(), Settings.getValues()); builder.setKeyboardGeometry(width, height); builder.setSubtype(RichInputMethodManager.getInstance().getCurrentSubtype()); + builder.setSplitLayoutEnabled(Settings.getValues().mIsSplitKeyboardEnabled); return builder.build(); } diff --git a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt index c00d06649..719ac2617 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt @@ -414,6 +414,7 @@ class ClipboardHistoryView @JvmOverloads constructor( private fun setBottomRowLayout(elementId: Int) { val editorInfo = this.editorInfo ?: return val keyboardView = findViewById(R.id.bottom_row_keyboard) + keyboardView.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) keyboardView.setKeyboardActionListener(this) // Set 'this' as listener to intercept PointerTracker.switchTo(keyboardView) val kls = KeyboardLayoutSet.Builder.buildEmojiClipBottomRow(context, editorInfo) diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index 8eb8995c2..1cab99930 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -721,6 +721,7 @@ public void resetMetaState() { KeyboardLayoutSet kls = builder.build(); bottomRow.setKeyboard(kls.getKeyboard(KeyboardId.ELEMENT_ALPHABET)); + bottomRow.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn); // Focus mSearchBar.requestFocus(); @@ -833,6 +834,7 @@ private void setupBottomRowKeyboard(final EditorInfo editorInfo, if (keyboardView == null || !this.isAttachedToWindow()) { return; } + keyboardView.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn); EditorInfo ei = editorInfo != null ? editorInfo : mEditorInfo; keyboardView.setKeyboardActionListener(keyboardActionListener); From 28c20cbdf9ec1078a039cd0168cd045c4f8f87fb Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 9 Jun 2026 22:11:44 +0530 Subject: [PATCH 074/118] feat(offline): add settings for custom sampling & prompt --- .../keyboard/latin/settings/Defaults.kt | 4 + .../keyboard/latin/settings/Settings.java | 4 + .../settings/screens/AdvancedScreen.kt | 59 +++++++++ app/src/main/res/values/strings.xml | 10 ++ .../keyboard/latin/utils/ProofreadHelper.kt | 2 +- .../keyboard/latin/utils/ProofreadService.kt | 115 +++++++++++++++++- .../keyboard/latin/utils/ProofreadService.kt | 2 + .../keyboard/latin/utils/ProofreadService.kt | 2 + 8 files changed, 191 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index fafe652d9..540274c57 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -153,7 +153,11 @@ object Defaults { const val PREF_FORCE_AUTO_CAPS = false const val PREF_OFFLINE_TEMP = 0.1f // Lower for faster, more deterministic proofreading const val PREF_OFFLINE_TOP_P = 0.5f // Lower for faster token sampling + const val PREF_OFFLINE_TOP_K = 40 + const val PREF_OFFLINE_MIN_P = 0.05f + const val PREF_OFFLINE_SHOW_THINKING = false const val PREF_OFFLINE_SYSTEM_PROMPT = "Correct the grammar and spelling. Output only the corrected text." + const val PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT = "Translate the following text to {lang}. Output only the translation, nothing else:\n\n" const val PREF_OFFLINE_MAX_TOKENS = 64 // Accurate (64 tokens) default const val PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "French" const val PREF_OFFLINE_KEEP_MODEL_LOADED = false diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 058f1d824..490f6f110 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -159,7 +159,11 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_FORCE_AUTO_CAPS = "force_auto_caps"; public static final String PREF_OFFLINE_TEMP = "offline_temp"; public static final String PREF_OFFLINE_TOP_P = "offline_top_p"; + public static final String PREF_OFFLINE_TOP_K = "offline_top_k"; + public static final String PREF_OFFLINE_MIN_P = "offline_min_p"; + public static final String PREF_OFFLINE_SHOW_THINKING = "offline_show_thinking"; public static final String PREF_OFFLINE_SYSTEM_PROMPT = "offline_system_prompt"; + public static final String PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT = "offline_translate_system_prompt"; public static final String PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "offline_translate_target_language"; public static final String PREF_OFFLINE_MAX_TOKENS = "offline_max_tokens"; public static final String PREF_OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded"; diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 7bff825e8..fa8f8192b 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -604,7 +604,25 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( onClick = { showSystemPromptDialog = true } ) + var showTranslateSystemPromptDialog by remember { mutableStateOf(false) } + if (showTranslateSystemPromptDialog) { + TextInputDialog( + title = { Text("Translate System Instruction") }, + initialText = service.getTranslateSystemPrompt(), + checkTextValid = { true }, + onConfirmed = { + service.setTranslateSystemPrompt(it) + showTranslateSystemPromptDialog = false + }, + onDismissRequest = { showTranslateSystemPromptDialog = false } + ) + } + Preference( + name = "Translate Instruction", + description = service.getTranslateSystemPrompt().takeIf { it.isNotBlank() } ?: "Default", + onClick = { showTranslateSystemPromptDialog = true } + ) // Target Language for Translation val languageSetting = Setting(context, Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, R.string.translate_target_language_title, R.string.translate_target_language_summary) { } @@ -626,7 +644,48 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp) ) + // Temperature + SliderPreference( + name = stringResource(R.string.offline_temp_title), + key = Settings.PREF_OFFLINE_TEMP, + default = Defaults.PREF_OFFLINE_TEMP, + range = 0.0f..2.0f, + description = { String.format("%.2f", it) } + ) + + // Top-P + SliderPreference( + name = stringResource(R.string.offline_top_p_title), + key = Settings.PREF_OFFLINE_TOP_P, + default = Defaults.PREF_OFFLINE_TOP_P, + range = 0.0f..1.0f, + description = { String.format("%.2f", it) } + ) + + // Top-K + SliderPreference( + name = stringResource(R.string.offline_top_k_title), + key = Settings.PREF_OFFLINE_TOP_K, + default = Defaults.PREF_OFFLINE_TOP_K, + range = 1.0f..100.0f, + description = { it.toString() } + ) + + // Min-P + SliderPreference( + name = stringResource(R.string.offline_min_p_title), + key = Settings.PREF_OFFLINE_MIN_P, + default = Defaults.PREF_OFFLINE_MIN_P, + range = 0.0f..1.0f, + description = { String.format("%.2f", it) } + ) + // Show Thinking + val showThinkingSetting = Setting(context, Settings.PREF_OFFLINE_SHOW_THINKING, R.string.offline_show_thinking_title, R.string.offline_show_thinking_summary) { } + SwitchPreference( + setting = showThinkingSetting, + default = Defaults.PREF_OFFLINE_SHOW_THINKING + ) val tokenEntries = context.resources.getStringArray(R.array.offline_max_tokens_entries) val tokenValues = context.resources.getStringArray(R.array.offline_max_tokens_values).map { it.toInt() } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78fa73d58..e4a3a1f6f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1204,6 +1204,16 @@ New dictionary: Manage local LLM Offline AI Max Tokens Maximum length of the generated correction + Temperature + Controls randomness: lower is more focused and deterministic + Top-P (Nucleus Sampling) + Controls diversity of vocabulary: lower cuts off less likely options + Top-K + Limits choices to K most probable tokens + Min-P + Minimum probability threshold relative to most likely token + Show Thinking + Display reasoning process and internal thinking tokens if the model generates them diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index e7d30bf4e..61ddb1789 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -206,7 +206,7 @@ object ProofreadHelper { text = text, noTextErrorResId = R.string.proofread_no_text, errorResId = R.string.proofread_error, - apiCall = { service -> service.proofread(text, overridePrompt = prompt) }, + apiCall = { service -> service.proofread(text, overridePrompt = prompt, showThinking = showThinking) }, onSuccess = onSuccess, onError = onError ) diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 3cc4152eb..ede02b484 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -222,6 +222,12 @@ class ProofreadService(private val context: Context) { sharedPrefs.edit().putString(Settings.PREF_OFFLINE_SYSTEM_PROMPT, prompt).apply() } + fun getTranslateSystemPrompt(): String = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT, "") ?: "" + + fun setTranslateSystemPrompt(prompt: String) { + sharedPrefs.edit().putString(Settings.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT, prompt).apply() + } + fun getModelName(): String { val path = getModelPath() if (path.isNullOrBlank()) return "No Model Selected" @@ -268,14 +274,15 @@ class ProofreadService(private val context: Context) { */ suspend fun translate(text: String): Result { val target = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE - val prompt = "Translate the following text to $target. Output only the translation, nothing else:\n\n" + val systemPromptTemplate = getTranslateSystemPrompt().takeIf { it.isNotBlank() } ?: Defaults.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT + val prompt = systemPromptTemplate.replace("{lang}", target) return proofread(text, overridePrompt = prompt) } /** * Run llamacpp inference for proofreading/text correction. */ - suspend fun proofread(text: String, overridePrompt: String? = null): Result = withContext(Dispatchers.IO) { + suspend fun proofread(text: String, overridePrompt: String? = null, showThinking: Boolean? = null): Result = withContext(Dispatchers.IO) { val modelPath = getModelPath() if (modelPath.isNullOrBlank()) { return@withContext Result.failure(ProofreadException("Model not loaded. Please select a GGUF model file.")) @@ -292,6 +299,11 @@ class ProofreadService(private val context: Context) { try { val maxTokens = sharedPrefs.getInt(Settings.PREF_OFFLINE_MAX_TOKENS, Defaults.PREF_OFFLINE_MAX_TOKENS) + val temp = sharedPrefs.getFloat(Settings.PREF_OFFLINE_TEMP, Defaults.PREF_OFFLINE_TEMP) + val topP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_TOP_P, Defaults.PREF_OFFLINE_TOP_P) + val topK = sharedPrefs.getInt(Settings.PREF_OFFLINE_TOP_K, Defaults.PREF_OFFLINE_TOP_K) + val minP = sharedPrefs.getFloat(Settings.PREF_OFFLINE_MIN_P, Defaults.PREF_OFFLINE_MIN_P) + val showThinkingVal = showThinking ?: sharedPrefs.getBoolean(Settings.PREF_OFFLINE_SHOW_THINKING, Defaults.PREF_OFFLINE_SHOW_THINKING) // Build the prompt val systemPrompt = overridePrompt ?: getSystemPrompt() @@ -307,8 +319,17 @@ class ProofreadService(private val context: Context) { val helper = ModelHolder.llamaHelper ?: return@withContext Result.failure(ProofreadException("Model not available")) - // Use predict and collect tokens - helper.predict(fullPrompt) + // Use predict with custom parameters + predictWithParams( + helper = helper, + prompt = fullPrompt, + temp = temp, + topP = topP, + topK = topK, + minP = minP, + maxTokens = maxTokens, + showThinking = showThinkingVal + ) // Collect events until done ModelHolder.llmFlow.collect { event -> @@ -338,8 +359,15 @@ class ProofreadService(private val context: Context) { output } - if (cleanedOutput.isNotBlank()) { - Result.success(cleanedOutput) + // Post-process to strip thinking/reasoning tags if showThinkingVal is false + val finalOutput = if (!showThinkingVal) { + stripThinkingTags(cleanedOutput) + } else { + cleanedOutput + } + + if (finalOutput.isNotBlank()) { + Result.success(finalOutput) } else { Result.success(text) } @@ -351,6 +379,81 @@ class ProofreadService(private val context: Context) { } } + private fun predictWithParams( + helper: LlamaHelper, + prompt: String, + temp: Float, + topP: Float, + topK: Int, + minP: Float, + maxTokens: Int, + showThinking: Boolean + ) { + try { + // Get currentContext via reflection + val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + val currentContext = currentContextField.get(helper) as? Int ?: throw IllegalStateException("Model not loaded yet") + + // Get llama via reflection + val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llamaLazy = llamaField.get(helper) as Lazy + val llama = llamaLazy.value + + // Reset tokenCount and allText + val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + tokenCountField.set(helper, 0) + + val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + allTextField.set(helper, "") + + // Emit Started event + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Started(prompt)) + + // Build parameters map + val params = mutableMapOf( + "prompt" to prompt, + "emit_partial_completion" to showThinking, + "temperature" to temp.toDouble(), + "top_p" to topP.toDouble(), + "top_k" to topK, + "min_p" to minP.toDouble(), + "n_predict" to maxTokens + ) + + // Get completionJob field + val completionJobField = LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + + // Launch completion using helper.scope + val job = helper.scope.launch { + val startTime = System.currentTimeMillis() + try { + llama.launchCompletion(currentContext, params) + } catch (e: Throwable) { + Log.e(TAG, "Completion failed", e) + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Completion failed: ${e.message}")) + return@launch + } + val duration = System.currentTimeMillis() - startTime + val allText = allTextField.get(helper) as String + val tokenCount = tokenCountField.get(helper) as Int + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Done(allText, tokenCount, duration)) + } + completionJobField.set(helper, job) + } catch (e: Throwable) { + Log.e(TAG, "Failed to setup prediction", e) + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Error("Failed to setup prediction: ${e.message}")) + } + } + + private fun stripThinkingTags(text: String): String { + return text + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("[\\s\\S]*?", RegexOption.IGNORE_CASE), "") + .replace(Regex("
[\\s\\S]*?
", RegexOption.IGNORE_CASE), "") + .trim() + } + class ProofreadException(message: String) : Exception(message) class TranslateException(message: String) : Exception(message) diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt index e927888ca..2a3e3e8b3 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -34,6 +34,8 @@ class ProofreadService(private val context: Context) { fun unloadModel() { /* No-op */ } fun getSystemPrompt(): String = "" fun setSystemPrompt(prompt: String) { /* No-op */ } + fun getTranslateSystemPrompt(): String = "" + fun setTranslateSystemPrompt(prompt: String) { /* No-op */ } fun getDecoderPath(): String? = null fun setDecoderPath(path: String?) { /* No-op */ } fun getTokenizerPath(): String? = null diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt index 4edd4a2ec..bd6c4a162 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -171,6 +171,8 @@ class ProofreadService(private val context: Context) { fun unloadModel() { /* No-op */ } fun getSystemPrompt(): String = "Fix grammar and spelling" fun setSystemPrompt(prompt: String) { /* No-op */ } + fun getTranslateSystemPrompt(): String = "" + fun setTranslateSystemPrompt(prompt: String) { /* No-op */ } fun getDecoderPath(): String? = null fun setDecoderPath(path: String?) { /* No-op */ } fun getTokenizerPath(): String? = null From 92b8a46557cf9217e359bab112c378a37f1bf8ed Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 12 Jun 2026 21:17:20 +0530 Subject: [PATCH 075/118] chore: update gitignore - add .env, .pi/ and remove duplicate --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index bc03f7232..9e18d66b9 100755 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,8 @@ docs/superpowers/ .agents .kilo/ .antigravitycli/ + +.env + +# AI agent config (personal, not shared) +.pi/ From 007afc6178242c58cd8c8ebb0c4996a01e4c957c Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Fri, 12 Jun 2026 21:20:55 +0530 Subject: [PATCH 076/118] docs: add F-Droid reproducibility delay notice --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e7702a486..439ff4644 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,8 @@ This fork adds **optional AI-powered features** using Gemini, Groq, and OpenAI-c +> **⚠️ Note:** F-Droid releases might be delayed or stuck again due to reproducibility verification issues. For the latest version, use GitHub Releases or Obtainium. + ### 📦 Choose Your Version #### 1. Standard Version (`-standard-release.apk`) From 6073ba0296f59b2beff4114afeeb1b0237f12164 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 00:15:57 +0530 Subject: [PATCH 077/118] chore: bump version to v3.8.6 and add changelog --- app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/3860.txt | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/3860.txt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 55b91a9a3..54b536ac2 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "com.leanbitlab.leantype" minSdk = 21 targetSdk = 35 - versionCode = 3850 - versionName = "3.8.5" + versionCode = 3860 + versionName = "3.8.6" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") diff --git a/fastlane/metadata/android/en-US/changelogs/3860.txt b/fastlane/metadata/android/en-US/changelogs/3860.txt new file mode 100644 index 000000000..63abf09b5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/3860.txt @@ -0,0 +1,8 @@ +- Support custom sampling and prompts in offline AI settings +- Switch offline AI backend from ONNX Runtime to llama.cpp +- Double tap on touchpad (toolbar key) to select word +- Add regex expansion support to Text Expander +- Add option to automatically read and suggest OTP from SMS +- Add Blocked Words screen with case-insensitive and regex blacklist support +- Align Arabic diacritics spacing and update layout popup options +- Fix dictionary crashes and emoji popup preview issues From 475338541bf4246aef1a7194acc02f8e756bf159 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 00:22:57 +0530 Subject: [PATCH 078/118] fix(touchpad): always select word on double tap and update docs --- .../keyboard/keyboard/KeyboardActionListenerImpl.kt | 6 +----- docs/FEATURES.md | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index e9840ea63..3a952b7c3 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -562,11 +562,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp onCodeInput(Constants.CODE_ENTER, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } override fun onDoubleTap() { - if (connection.hasSelection()) { - onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) - } else { - onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) - } + onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } override fun onScroll(direction: Int) { onCodeInput(direction, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index d89d7f881..8adb6dd70 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -330,4 +330,4 @@ Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to cont * **Two-finger drag**: Performs fast vertical scrolling (simulating arrow keys up/down). * **Two-finger tap**: Simulates a mouse click/Enter. * **Long press (hold finger)**: Activates text selection mode. Dragging while holding will select text. Releasing the finger exits selection mode. -* **Double tap by single finger**: Deletes the selected text or words (if a text selection exists). +* **Double tap by single finger**: Selects the word under the cursor. From 8aa21f5bfd2b64d86f763b43c8c10163bc404fa9 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 00:31:05 +0530 Subject: [PATCH 079/118] feat(touchpad): implement multi-finger gestures and update docs --- .../keyboard/KeyboardActionListenerImpl.kt | 10 ++++ .../keyboard/keyboard/TouchpadView.java | 55 ++++++++++++++++++- docs/FEATURES.md | 8 ++- .../android/en-US/changelogs/3860.txt | 2 +- 4 files changed, 70 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 3a952b7c3..9f458ceac 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -567,6 +567,16 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onScroll(direction: Int) { onCodeInput(direction, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } + override fun onTwoFingerDoubleTap() { + if (connection.hasSelection()) { + onCodeInput(KeyCode.CLIPBOARD_COPY, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_ALL, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } + override fun onThreeFingerTap() { + onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } }) } diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index ef5a2a54d..bbaeecc31 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -32,6 +32,8 @@ public interface TouchpadListener { void onSingleTap(); void onDoubleTap(); void onScroll(int direction); + void onTwoFingerDoubleTap(); + void onThreeFingerTap(); } private TouchpadListener mListener; @@ -50,12 +52,25 @@ public interface TouchpadListener { // Two-finger scroll tracking private boolean mIsTwoFingerScroll; + private float mTwoFingerLastX; private float mTwoFingerLastY; + private float mScrollAccX; private float mScrollAccY; // Two-finger tap tracking private boolean mIsTwoFingerTap; private long mTwoFingerDownTime; + private long mLastTwoFingerTapTime = 0; + private final Runnable mTwoFingerTapRunnable = new Runnable() { + @Override + public void run() { + if (mListener != null) mListener.onSingleTap(); + } + }; + + // Three-finger tap tracking + private boolean mIsThreeFingerTap; + private long mThreeFingerDownTime; private static final int SCROLL_THRESHOLD = 40; @@ -164,18 +179,42 @@ private void setupTouchSurface() { mIsTwoFingerTap = true; mTwoFingerDownTime = System.currentTimeMillis(); mIsDragging = false; + mTwoFingerLastX = (event.getX(0) + event.getX(1)) / 2f; mTwoFingerLastY = (event.getY(0) + event.getY(1)) / 2f; + mScrollAccX = 0; mScrollAccY = 0; + } else if (pointerCount == 3) { + mIsThreeFingerTap = true; + mThreeFingerDownTime = System.currentTimeMillis(); + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + mIsDragging = false; } return true; case MotionEvent.ACTION_MOVE: if (mIsTwoFingerScroll && pointerCount >= 2) { + float midX = (event.getX(0) + event.getX(1)) / 2f; float midY = (event.getY(0) + event.getY(1)) / 2f; + float deltaX = midX - mTwoFingerLastX; float deltaY = midY - mTwoFingerLastY; + mTwoFingerLastX = midX; mTwoFingerLastY = midY; + + mScrollAccX += deltaX; mScrollAccY += deltaY; + while (mScrollAccX >= SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + if (mListener != null) mListener.onScroll(KeyCode.WORD_RIGHT); + mScrollAccX -= SCROLL_THRESHOLD; + } + while (mScrollAccX <= -SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + if (mListener != null) mListener.onScroll(KeyCode.WORD_LEFT); + mScrollAccX += SCROLL_THRESHOLD; + } + while (mScrollAccY >= SCROLL_THRESHOLD) { mIsTwoFingerTap = false; if (mListener != null) mListener.onScroll(KeyCode.ARROW_DOWN); @@ -225,6 +264,7 @@ private void setupTouchSurface() { mIsDragging = false; mIsTwoFingerScroll = false; mIsTwoFingerTap = false; + mIsThreeFingerTap = false; if (mSelectionMode) { mSelectionMode = false; applySurfaceColor(); @@ -234,10 +274,23 @@ private void setupTouchSurface() { case MotionEvent.ACTION_POINTER_UP: if (pointerCount == 2) { if (mIsTwoFingerTap && (System.currentTimeMillis() - mTwoFingerDownTime) < 300) { - if (mListener != null) mListener.onSingleTap(); + long now = System.currentTimeMillis(); + if (now - mLastTwoFingerTapTime < 350) { + removeCallbacks(mTwoFingerTapRunnable); + if (mListener != null) mListener.onTwoFingerDoubleTap(); + mLastTwoFingerTapTime = 0; + } else { + mLastTwoFingerTapTime = now; + postDelayed(mTwoFingerTapRunnable, 250); + } } mIsTwoFingerScroll = false; mIsTwoFingerTap = false; + } else if (pointerCount == 3) { + if (mIsThreeFingerTap && (System.currentTimeMillis() - mThreeFingerDownTime) < 300) { + if (mListener != null) mListener.onThreeFingerTap(); + } + mIsThreeFingerTap = false; } return true; } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 8adb6dd70..85fab6eed 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -327,7 +327,9 @@ Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to cont ### Touchpad Gestures * **Single-finger drag**: Moves the cursor in 2D space (simulating arrow keys left/right/up/down) to navigate text. -* **Two-finger drag**: Performs fast vertical scrolling (simulating arrow keys up/down). -* **Two-finger tap**: Simulates a mouse click/Enter. -* **Long press (hold finger)**: Activates text selection mode. Dragging while holding will select text. Releasing the finger exits selection mode. +* **Two-finger drag**: Moves the cursor vertically (scrolling up/down) or horizontally (word-by-word navigation). +* **Two-finger tap**: Simulates Enter. +* **Two-finger double tap**: Copies selected text (or Selects All if no selection exists). +* **Three-finger tap**: Pastes clipboard contents at the cursor. +* **Long press (hold finger)**: Activates text selection mode. Dragging while holding will select text. * **Double tap by single finger**: Selects the word under the cursor. diff --git a/fastlane/metadata/android/en-US/changelogs/3860.txt b/fastlane/metadata/android/en-US/changelogs/3860.txt index 63abf09b5..87d8417e5 100644 --- a/fastlane/metadata/android/en-US/changelogs/3860.txt +++ b/fastlane/metadata/android/en-US/changelogs/3860.txt @@ -1,6 +1,6 @@ - Support custom sampling and prompts in offline AI settings - Switch offline AI backend from ONNX Runtime to llama.cpp -- Double tap on touchpad (toolbar key) to select word +- Add touchpad gestures: double tap to select word, two-finger horizontal swipe to navigate word-by-word, two-finger double tap to copy, and three-finger tap to paste - Add regex expansion support to Text Expander - Add option to automatically read and suggest OTP from SMS - Add Blocked Words screen with case-insensitive and regex blacklist support From 45f44dad7c489046e643133c9f53aeec016e121c Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 00:37:40 +0530 Subject: [PATCH 080/118] feat(touchpad): reorganize gestures for intuitive rich text editing layout --- .../keyboard/KeyboardActionListenerImpl.kt | 20 ++++++ .../keyboard/keyboard/TouchpadView.java | 61 ++++++++++++++++++- docs/FEATURES.md | 25 +++++--- .../android/en-US/changelogs/3860.txt | 2 +- 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 9f458ceac..0fdbc6893 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -577,6 +577,26 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onThreeFingerTap() { onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } + override fun onThreeFingerDoubleTap() { + onCodeInput(KeyCode.CLIPBOARD_CUT, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onThreeFingerSwipeLeft() { + if (connection.hasSelection()) { + onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + onCodeInput(KeyCode.DELETE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + } + override fun onThreeFingerSwipeRight() { + // Empty for future use + } + override fun onThreeFingerSwipeUp() { + onCodeInput(KeyCode.UNDO, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } + override fun onThreeFingerSwipeDown() { + onCodeInput(KeyCode.REDO, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } }) } diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index bbaeecc31..836bfded5 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -34,6 +34,11 @@ public interface TouchpadListener { void onScroll(int direction); void onTwoFingerDoubleTap(); void onThreeFingerTap(); + void onThreeFingerDoubleTap(); + void onThreeFingerSwipeLeft(); + void onThreeFingerSwipeRight(); + void onThreeFingerSwipeUp(); + void onThreeFingerSwipeDown(); } private TouchpadListener mListener; @@ -68,9 +73,19 @@ public void run() { } }; - // Three-finger tap tracking + // Three-finger tap & swipe tracking private boolean mIsThreeFingerTap; private long mThreeFingerDownTime; + private long mLastThreeFingerTapTime = 0; + private final Runnable mThreeFingerTapRunnable = new Runnable() { + @Override + public void run() { + if (mListener != null) mListener.onThreeFingerTap(); + } + }; + private boolean mIsThreeFingerSwipe; + private float mThreeFingerStartX; + private float mThreeFingerStartY; private static final int SCROLL_THRESHOLD = 40; @@ -186,6 +201,9 @@ private void setupTouchSurface() { } else if (pointerCount == 3) { mIsThreeFingerTap = true; mThreeFingerDownTime = System.currentTimeMillis(); + mIsThreeFingerSwipe = true; + mThreeFingerStartX = (event.getX(0) + event.getX(1) + event.getX(2)) / 3f; + mThreeFingerStartY = (event.getY(0) + event.getY(1) + event.getY(2)) / 3f; mIsTwoFingerScroll = false; mIsTwoFingerTap = false; mIsDragging = false; @@ -225,6 +243,35 @@ private void setupTouchSurface() { if (mListener != null) mListener.onScroll(KeyCode.ARROW_UP); mScrollAccY += SCROLL_THRESHOLD; } + } else if (mIsThreeFingerSwipe && pointerCount >= 3) { + float midX = (event.getX(0) + event.getX(1) + event.getX(2)) / 3f; + float midY = (event.getY(0) + event.getY(1) + event.getY(2)) / 3f; + float deltaX = midX - mThreeFingerStartX; + float deltaY = midY - mThreeFingerStartY; + + float density = getContext().getResources().getDisplayMetrics().density; + float swipeThreshold = 50f * density; + + if (Math.abs(deltaX) > swipeThreshold || Math.abs(deltaY) > swipeThreshold) { + mIsThreeFingerTap = false; + mIsThreeFingerSwipe = false; + + if (mListener != null) { + if (Math.abs(deltaX) > Math.abs(deltaY)) { + if (deltaX < 0) { + mListener.onThreeFingerSwipeLeft(); + } else { + mListener.onThreeFingerSwipeRight(); + } + } else { + if (deltaY < 0) { + mListener.onThreeFingerSwipeUp(); + } else { + mListener.onThreeFingerSwipeDown(); + } + } + } + } } else if (mIsDragging && pointerCount == 1) { float deltaX = event.getX() - mLastTouchX; float deltaY = event.getY() - mLastTouchY; @@ -265,6 +312,7 @@ private void setupTouchSurface() { mIsTwoFingerScroll = false; mIsTwoFingerTap = false; mIsThreeFingerTap = false; + mIsThreeFingerSwipe = false; if (mSelectionMode) { mSelectionMode = false; applySurfaceColor(); @@ -288,9 +336,18 @@ private void setupTouchSurface() { mIsTwoFingerTap = false; } else if (pointerCount == 3) { if (mIsThreeFingerTap && (System.currentTimeMillis() - mThreeFingerDownTime) < 300) { - if (mListener != null) mListener.onThreeFingerTap(); + long now = System.currentTimeMillis(); + if (now - mLastThreeFingerTapTime < 350) { + removeCallbacks(mThreeFingerTapRunnable); + if (mListener != null) mListener.onThreeFingerDoubleTap(); + mLastThreeFingerTapTime = 0; + } else { + mLastThreeFingerTapTime = now; + postDelayed(mThreeFingerTapRunnable, 250); + } } mIsThreeFingerTap = false; + mIsThreeFingerSwipe = false; } return true; } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 85fab6eed..6c9419b31 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -326,10 +326,21 @@ Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to cont * **Toolbar shortcut**: Tap the **Touchpad** icon in the toolbar for a persistent touchpad overlay. ### Touchpad Gestures -* **Single-finger drag**: Moves the cursor in 2D space (simulating arrow keys left/right/up/down) to navigate text. -* **Two-finger drag**: Moves the cursor vertically (scrolling up/down) or horizontally (word-by-word navigation). -* **Two-finger tap**: Simulates Enter. -* **Two-finger double tap**: Copies selected text (or Selects All if no selection exists). -* **Three-finger tap**: Pastes clipboard contents at the cursor. -* **Long press (hold finger)**: Activates text selection mode. Dragging while holding will select text. -* **Double tap by single finger**: Selects the word under the cursor. + +#### 1 Finger (Navigation & Selection) +* **Drag**: Moves the cursor precisely character-by-character. +* **Double Tap**: Selects the word under the cursor. +* **Long Press & Drag**: Enters text selection mode and selects text as you drag. + +#### 2 Fingers (Fast Navigation & Copying) +* **Vertical Drag**: Moves the cursor vertically line-by-line. +* **Horizontal Drag**: Moves the cursor horizontally word-by-word. +* **Tap**: Simulates Enter. +* **Double Tap**: Copies selected text (or Selects All if no selection exists). + +#### 3 Fingers (Clipboard, Undo/Redo & Deletion) +* **Tap**: Pastes clipboard contents at the cursor. +* **Double Tap**: Cuts selected text. +* **Swipe Left**: Deletes/Backspaces selection (or deletes the word to the left of the cursor if no selection exists). +* **Swipe Up**: Undo. +* **Swipe Down**: Redo. diff --git a/fastlane/metadata/android/en-US/changelogs/3860.txt b/fastlane/metadata/android/en-US/changelogs/3860.txt index 87d8417e5..6bb73ef6b 100644 --- a/fastlane/metadata/android/en-US/changelogs/3860.txt +++ b/fastlane/metadata/android/en-US/changelogs/3860.txt @@ -1,6 +1,6 @@ - Support custom sampling and prompts in offline AI settings - Switch offline AI backend from ONNX Runtime to llama.cpp -- Add touchpad gestures: double tap to select word, two-finger horizontal swipe to navigate word-by-word, two-finger double tap to copy, and three-finger tap to paste +- Add rich touchpad gestures: double tap to select word, two-finger horizontal drag to navigate word-by-word, two-finger double tap to copy, three-finger tap to paste, three-finger double tap to cut, three-finger swipe left to delete selection/word, and three-finger swipe up/down for undo/redo - Add regex expansion support to Text Expander - Add option to automatically read and suggest OTP from SMS - Add Blocked Words screen with case-insensitive and regex blacklist support From cbf50c56d498cc7f11533854c2131ae25d8287ce Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 01:03:13 +0530 Subject: [PATCH 081/118] feat(touchpad): migrate gestures to 1 and 2 fingers --- .../keyboard/KeyboardActionListenerImpl.kt | 10 +- .../keyboard/keyboard/TouchpadView.java | 202 ++++++++++-------- docs/FEATURES.md | 16 +- .../android/en-US/changelogs/3860.txt | 2 +- 4 files changed, 129 insertions(+), 101 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index 0fdbc6893..e91c15c81 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -559,7 +559,7 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp } } override fun onSingleTap() { - onCodeInput(Constants.CODE_ENTER, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + onCodeInput(Constants.CODE_SPACE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } override fun onDoubleTap() { onCodeInput(KeyCode.CLIPBOARD_SELECT_WORD, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) @@ -571,14 +571,18 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp if (connection.hasSelection()) { onCodeInput(KeyCode.CLIPBOARD_COPY, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } else { - onCodeInput(KeyCode.CLIPBOARD_SELECT_ALL, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } } override fun onThreeFingerTap() { onCodeInput(KeyCode.CLIPBOARD_PASTE, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) } override fun onThreeFingerDoubleTap() { - onCodeInput(KeyCode.CLIPBOARD_CUT, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + if (connection.hasSelection()) { + onCodeInput(KeyCode.CLIPBOARD_CUT, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } else { + onCodeInput(KeyCode.CLIPBOARD_SELECT_ALL, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false) + } } override fun onThreeFingerSwipeLeft() { if (connection.hasSelection()) { diff --git a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java index 836bfded5..98fac787b 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/TouchpadView.java @@ -61,15 +61,41 @@ public interface TouchpadListener { private float mTwoFingerLastY; private float mScrollAccX; private float mScrollAccY; + private float mTwoFingerStartX; + private float mTwoFingerStartY; + private boolean mHasScrolledHorizontally; + private boolean mIsTwoFingerLongPress; + private int mTwoFingerTapCount = 0; + + private final Runnable mTwoFingerLongPressRunnable = new Runnable() { + @Override + public void run() { + if (mIsTwoFingerTap) { + mIsTwoFingerLongPress = true; + if (mListener != null) { + mListener.onThreeFingerSwipeLeft(); + } + postDelayed(this, 150); + } + } + }; // Two-finger tap tracking private boolean mIsTwoFingerTap; private long mTwoFingerDownTime; - private long mLastTwoFingerTapTime = 0; private final Runnable mTwoFingerTapRunnable = new Runnable() { @Override public void run() { - if (mListener != null) mListener.onSingleTap(); + if (mListener != null) { + if (mTwoFingerTapCount == 1) { + mListener.onSingleTap(); + } else if (mTwoFingerTapCount == 2) { + mListener.onTwoFingerDoubleTap(); + } else if (mTwoFingerTapCount >= 3) { + mListener.onThreeFingerDoubleTap(); + } + } + mTwoFingerTapCount = 0; } }; @@ -177,9 +203,14 @@ private void setupTouchSurface() { mTouchpadSurface.setOnTouchListener((v, event) -> { mGestureDetector.onTouchEvent(event); final int pointerCount = event.getPointerCount(); + android.util.Log.i("TouchpadViewRaw", "action=" + MotionEvent.actionToString(event.getActionMasked()) + ", pointers=" + pointerCount); switch (event.getActionMasked()) { case MotionEvent.ACTION_DOWN: + android.util.Log.i("TouchpadView", "ACTION_DOWN"); + if (v.getParent() != null) { + v.getParent().requestDisallowInterceptTouchEvent(true); + } mLastTouchX = event.getX(); mLastTouchY = event.getY(); mAccX = 0; @@ -189,24 +220,26 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_POINTER_DOWN: + android.util.Log.i("TouchpadView", "ACTION_POINTER_DOWN: pointerCount=" + pointerCount); + if (v.getParent() != null) { + v.getParent().requestDisallowInterceptTouchEvent(true); + } if (pointerCount == 2) { mIsTwoFingerScroll = true; mIsTwoFingerTap = true; mTwoFingerDownTime = System.currentTimeMillis(); mIsDragging = false; - mTwoFingerLastX = (event.getX(0) + event.getX(1)) / 2f; - mTwoFingerLastY = (event.getY(0) + event.getY(1)) / 2f; + mTwoFingerStartX = (event.getX(0) + event.getX(1)) / 2f; + mTwoFingerStartY = (event.getY(0) + event.getY(1)) / 2f; + mTwoFingerLastX = mTwoFingerStartX; + mTwoFingerLastY = mTwoFingerStartY; mScrollAccX = 0; mScrollAccY = 0; - } else if (pointerCount == 3) { - mIsThreeFingerTap = true; - mThreeFingerDownTime = System.currentTimeMillis(); - mIsThreeFingerSwipe = true; - mThreeFingerStartX = (event.getX(0) + event.getX(1) + event.getX(2)) / 3f; - mThreeFingerStartY = (event.getY(0) + event.getY(1) + event.getY(2)) / 3f; - mIsTwoFingerScroll = false; - mIsTwoFingerTap = false; - mIsDragging = false; + mHasScrolledHorizontally = false; + mIsTwoFingerLongPress = false; + + removeCallbacks(mTwoFingerTapRunnable); + postDelayed(mTwoFingerLongPressRunnable, 400); } return true; @@ -214,63 +247,56 @@ private void setupTouchSurface() { if (mIsTwoFingerScroll && pointerCount >= 2) { float midX = (event.getX(0) + event.getX(1)) / 2f; float midY = (event.getY(0) + event.getY(1)) / 2f; - float deltaX = midX - mTwoFingerLastX; - float deltaY = midY - mTwoFingerLastY; - mTwoFingerLastX = midX; - mTwoFingerLastY = midY; + float deltaX = midX - mTwoFingerStartX; + float deltaY = midY - mTwoFingerStartY; - mScrollAccX += deltaX; - mScrollAccY += deltaY; - - while (mScrollAccX >= SCROLL_THRESHOLD) { - mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.WORD_RIGHT); - mScrollAccX -= SCROLL_THRESHOLD; - } - while (mScrollAccX <= -SCROLL_THRESHOLD) { + float density = getContext().getResources().getDisplayMetrics().density; + + if (Math.abs(midX - mTwoFingerStartX) > 5f * density || Math.abs(midY - mTwoFingerStartY) > 5f * density) { mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.WORD_LEFT); - mScrollAccX += SCROLL_THRESHOLD; + removeCallbacks(mTwoFingerLongPressRunnable); } - while (mScrollAccY >= SCROLL_THRESHOLD) { - mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.ARROW_DOWN); - mScrollAccY -= SCROLL_THRESHOLD; - } - while (mScrollAccY <= -SCROLL_THRESHOLD) { + float swipeThreshold = 35f * density; + if (!mHasScrolledHorizontally && Math.abs(deltaY) > Math.abs(deltaX) && Math.abs(deltaY) > swipeThreshold) { + mIsTwoFingerScroll = false; mIsTwoFingerTap = false; - if (mListener != null) mListener.onScroll(KeyCode.ARROW_UP); - mScrollAccY += SCROLL_THRESHOLD; - } - } else if (mIsThreeFingerSwipe && pointerCount >= 3) { - float midX = (event.getX(0) + event.getX(1) + event.getX(2)) / 3f; - float midY = (event.getY(0) + event.getY(1) + event.getY(2)) / 3f; - float deltaX = midX - mThreeFingerStartX; - float deltaY = midY - mThreeFingerStartY; - - float density = getContext().getResources().getDisplayMetrics().density; - float swipeThreshold = 50f * density; - - if (Math.abs(deltaX) > swipeThreshold || Math.abs(deltaY) > swipeThreshold) { - mIsThreeFingerTap = false; - mIsThreeFingerSwipe = false; - + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + if (mListener != null) { - if (Math.abs(deltaX) > Math.abs(deltaY)) { - if (deltaX < 0) { - mListener.onThreeFingerSwipeLeft(); - } else { - mListener.onThreeFingerSwipeRight(); - } + if (deltaY < 0) { + mListener.onThreeFingerSwipeUp(); } else { - if (deltaY < 0) { - mListener.onThreeFingerSwipeUp(); - } else { - mListener.onThreeFingerSwipeDown(); - } + mListener.onThreeFingerSwipeDown(); } } + } else { + float lastDeltaX = midX - mTwoFingerLastX; + mTwoFingerLastX = midX; + mTwoFingerLastY = midY; + + mScrollAccX += lastDeltaX; + + while (mScrollAccX >= SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + mHasScrolledHorizontally = true; + if (mListener != null) mListener.onScroll(KeyCode.WORD_RIGHT); + mScrollAccX -= SCROLL_THRESHOLD; + } + while (mScrollAccX <= -SCROLL_THRESHOLD) { + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); + mHasScrolledHorizontally = true; + if (mListener != null) mListener.onScroll(KeyCode.WORD_LEFT); + mScrollAccX += SCROLL_THRESHOLD; + } } } else if (mIsDragging && pointerCount == 1) { float deltaX = event.getX() - mLastTouchX; @@ -282,7 +308,6 @@ private void setupTouchSurface() { mAccY += deltaY; int sensitivity = Settings.getValues().mTouchpadSensitivity; - // Base threshold is 110 for normal slow cursor, but 70 for fast selection int baseThreshold = mSelectionMode ? 70 : 110; int threshold = baseThreshold - (int) (sensitivity * 0.6f); if (threshold < 10) threshold = 10; @@ -307,12 +332,27 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_UP: + android.util.Log.i("TouchpadView", "ACTION_UP"); + mIsDragging = false; + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mIsTwoFingerLongPress = false; + if (mSelectionMode) { + mSelectionMode = false; + applySurfaceColor(); + } + return true; + case MotionEvent.ACTION_CANCEL: + android.util.Log.i("TouchpadView", "ACTION_CANCEL"); mIsDragging = false; mIsTwoFingerScroll = false; mIsTwoFingerTap = false; - mIsThreeFingerTap = false; - mIsThreeFingerSwipe = false; + removeCallbacks(mTwoFingerLongPressRunnable); + mIsTwoFingerLongPress = false; + mTwoFingerTapCount = 0; + removeCallbacks(mTwoFingerTapRunnable); if (mSelectionMode) { mSelectionMode = false; applySurfaceColor(); @@ -320,34 +360,22 @@ private void setupTouchSurface() { return true; case MotionEvent.ACTION_POINTER_UP: + android.util.Log.i("TouchpadView", "ACTION_POINTER_UP: pointerCount=" + pointerCount); if (pointerCount == 2) { + removeCallbacks(mTwoFingerLongPressRunnable); + if (mIsTwoFingerLongPress) { + mIsTwoFingerLongPress = false; + mIsTwoFingerScroll = false; + mIsTwoFingerTap = false; + return true; + } if (mIsTwoFingerTap && (System.currentTimeMillis() - mTwoFingerDownTime) < 300) { - long now = System.currentTimeMillis(); - if (now - mLastTwoFingerTapTime < 350) { - removeCallbacks(mTwoFingerTapRunnable); - if (mListener != null) mListener.onTwoFingerDoubleTap(); - mLastTwoFingerTapTime = 0; - } else { - mLastTwoFingerTapTime = now; - postDelayed(mTwoFingerTapRunnable, 250); - } + mTwoFingerTapCount++; + removeCallbacks(mTwoFingerTapRunnable); + postDelayed(mTwoFingerTapRunnable, 250); } mIsTwoFingerScroll = false; mIsTwoFingerTap = false; - } else if (pointerCount == 3) { - if (mIsThreeFingerTap && (System.currentTimeMillis() - mThreeFingerDownTime) < 300) { - long now = System.currentTimeMillis(); - if (now - mLastThreeFingerTapTime < 350) { - removeCallbacks(mThreeFingerTapRunnable); - if (mListener != null) mListener.onThreeFingerDoubleTap(); - mLastThreeFingerTapTime = 0; - } else { - mLastThreeFingerTapTime = now; - postDelayed(mThreeFingerTapRunnable, 250); - } - } - mIsThreeFingerTap = false; - mIsThreeFingerSwipe = false; } return true; } diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 6c9419b31..b4f028085 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -332,15 +332,11 @@ Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to cont * **Double Tap**: Selects the word under the cursor. * **Long Press & Drag**: Enters text selection mode and selects text as you drag. -#### 2 Fingers (Fast Navigation & Copying) -* **Vertical Drag**: Moves the cursor vertically line-by-line. -* **Horizontal Drag**: Moves the cursor horizontally word-by-word. -* **Tap**: Simulates Enter. -* **Double Tap**: Copies selected text (or Selects All if no selection exists). - -#### 3 Fingers (Clipboard, Undo/Redo & Deletion) -* **Tap**: Pastes clipboard contents at the cursor. -* **Double Tap**: Cuts selected text. -* **Swipe Left**: Deletes/Backspaces selection (or deletes the word to the left of the cursor if no selection exists). +#### 2 Fingers (Navigation, Clipboard, History & Deletion) +* **Drag Left/Right**: Moves the cursor horizontally word-by-word. * **Swipe Up**: Undo. * **Swipe Down**: Redo. +* **Tap**: Inserts a space character. +* **Double Tap**: Copies selected text (or Pastes clipboard contents if no selection exists). +* **Triple Tap**: Cuts selected text (or Selects All if no selection exists). +* **Press & Hold (Long Press)**: Deletes (backspaces) selection / word to the left. Repeats automatically if held. diff --git a/fastlane/metadata/android/en-US/changelogs/3860.txt b/fastlane/metadata/android/en-US/changelogs/3860.txt index 6bb73ef6b..331d64987 100644 --- a/fastlane/metadata/android/en-US/changelogs/3860.txt +++ b/fastlane/metadata/android/en-US/changelogs/3860.txt @@ -1,6 +1,6 @@ - Support custom sampling and prompts in offline AI settings - Switch offline AI backend from ONNX Runtime to llama.cpp -- Add rich touchpad gestures: double tap to select word, two-finger horizontal drag to navigate word-by-word, two-finger double tap to copy, three-finger tap to paste, three-finger double tap to cut, three-finger swipe left to delete selection/word, and three-finger swipe up/down for undo/redo +- Add rich touchpad gestures: double tap to select word, two-finger drag to navigate word-by-word, two-finger tap to insert space, two-finger double tap to copy/paste, two-finger triple tap to cut/select all, two-finger swipe up/down for undo/redo, and two-finger hold to backspace word/selection - Add regex expansion support to Text Expander - Add option to automatically read and suggest OTP from SMS - Add Blocked Words screen with case-insensitive and regex blacklist support From 4a9672aeea7ada7b064e30fbdf122745d9a9ec52 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 01:05:12 +0530 Subject: [PATCH 082/118] docs: update features for llama.cpp migration --- docs/FEATURES.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b4f028085..e97332a06 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -88,7 +88,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | **Groq** | 🟡 Average | 🟢 Easy | High | **Speed** | | **Google Gemini** | 🔴 Low | 🟢 Easy | Generous | General Purpose | | **HF/OpenAI-compatible** | ⚙️ *Varies* | 🟡 Medium | *Varies* | **Fully Customizable** | -| **Offline (ONNX)** | 🟢 **Best** | 🟡 Medium | ∞ Unlimited | **Privacy** | +| **Offline (Llama)** | 🟢 **Best** | 🟡 Medium | ∞ Unlimited | **Privacy** | > [!TIP] > The **HF/OpenAI-compatible** option is fully customizable—you can change the API endpoint, token, and model to use *any* OpenAI-compatible service (OpenRouter, Mistral, DeepSeek, HuggingFace, etc.). @@ -265,30 +265,30 @@ Control how the result is inserted. **Note**: This feature is only available in the "Offline" build flavor of LeanType. -Offline proofreading runs entirely on your device using the ONNX Runtime engine. No data leaves your device. +Offline proofreading runs entirely on your device using the `llama.cpp` runtime. No data leaves your device. > [!NOTE] > **Status: Beta / Experimental** -> This feature is in a test phase. The engine is designed to be compatible with various T5-based ONNX models (Basic, Quantized, KV-Cache). We encourage you to experiment with different models to find the best balance of speed and accuracy for your device. +> Running large language models on device requires a modern smartphone with sufficient RAM (typically 6GB+). We recommend using highly quantized, compact GGUF models (e.g. Q4_K_M or IQ4_NL) for the best balance of speed, accuracy, and memory usage. ### Setup Instructions -1. **Download Model Files**: Download the **Encoder**, **Decoder**, and **Tokenizer** for your chosen model from the table below. +1. **Download a GGUF Model**: Download a compatible `.gguf` model file (see Recommended Models below). 2. **Configure App**: * Go to **Settings > Advanced**. - * **Encoder Model**: Select the downloaded `.onnx` encoder file. - * **Decoder Model**: Select the downloaded `.onnx` decoder file. - * **Tokenizer**: Select the `tokenizer.json` file. - * **System Instruction**: Enter the text specified in the "System Instruction" column for your model (leave empty if specified). + * **GGUF Model**: Select the downloaded `.gguf` model file. + * **System Instruction**: (Optional) Customize the prompt used to guide the model when proofreading text. + * **Translate Instruction**: (Optional) Customize the prompt used for translation. + * **Target Language**: Select the target language for offline translation. + * **Sampling Settings**: Adjust temperature, Top-K, and Top-P to control model creativity. ### Recommended Models -| Model & Purpose | Performance / Size | System Instruction | Download Links (Direct) | -| :--- | :--- | :--- | :--- | -| **Visheratin T5 Tiny**
*(Grammar Correction Only)* | ⚡ **Fastest**
~35 MB
Low RAM usage | **Empty**
(Leave blank) | • [Encoder](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/resolve/main/encoder_model_quant.onnx)
• [Decoder](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/resolve/main/init_decoder_quant.onnx)
• [Tokenizer](https://huggingface.co/visheratin/t5-efficient-tiny-grammar-correction/tree/main) | -| **Flan-T5 Small**
*(Translation & General)* | 🐢 **Slower**
~300 MB
Higher accuracy | **Required**
`fix grammar: `
or
`translate English to Spanish: ` | • [Encoder](https://huggingface.co/Xenova/flan-t5-small/resolve/main/onnx/encoder_model_quantized.onnx)
• [Decoder](https://huggingface.co/Xenova/flan-t5-small/resolve/main/onnx/decoder_model_quantized.onnx)
• [Tokenizer](https://huggingface.co/Xenova/flan-t5-small/tree/main) | +* **Llama 3.2 1B Instruct (Q4_K_M)**: Excellent general purpose compact model (~900 MB). +* **Qwen 2.5 1.5B Instruct (Q4_K_M)**: High accuracy and quality, fast on modern devices (~1.1 GB). +* **Qwen 2.5 0.5B Instruct (Q4_K_M)**: Extremely lightweight, very fast with minimal memory footprint (~350 MB). -*Note: For Flan-T5, the quantized models linked above are standard recommendations. Users have also reported success with `bnb4` quantized variants if available.* +You can find and download these models in GGUF format on HuggingFace (e.g., from users like `bartowski` or `Qwen`). --- From ac80271b9f13d43dd451c94b091e54cf703d25dd Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 01:06:25 +0530 Subject: [PATCH 083/118] docs: note model-dependent accuracy in features --- docs/FEATURES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index e97332a06..4c840ac06 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -269,7 +269,7 @@ Offline proofreading runs entirely on your device using the `llama.cpp` runtime. > [!NOTE] > **Status: Beta / Experimental** -> Running large language models on device requires a modern smartphone with sufficient RAM (typically 6GB+). We recommend using highly quantized, compact GGUF models (e.g. Q4_K_M or IQ4_NL) for the best balance of speed, accuracy, and memory usage. +> Running large language models on device requires a modern smartphone with sufficient RAM (typically 6GB+). We recommend using highly quantized, compact GGUF models (e.g. Q4_K_M or IQ4_NL) for the best balance of speed, accuracy, and memory usage. The overall accuracy of proofreading and translations will depend entirely on the capabilities of the specific model you choose. ### Setup Instructions From 36e52045a3f8bfce1846c37a387e98f7371e53c7 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 01:12:03 +0530 Subject: [PATCH 084/118] fix(touchpad): exit touchpad mode when opening clipboard or emoji --- .../helium314/keyboard/keyboard/KeyboardSwitcher.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 047c45d76..42c14b3e9 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -381,6 +381,10 @@ public void setEmojiKeyboard() { if (DEBUG_ACTION) { Log.d(TAG, "setEmojiKeyboard"); } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. @@ -405,6 +409,10 @@ public void setClipboardKeyboard() { if (DEBUG_ACTION) { Log.d(TAG, "setClipboardKeyboard"); } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } mMainKeyboardFrame.setVisibility(View.VISIBLE); // The visibility of {@link #mKeyboardView} must be aligned with {@link // #MainKeyboardFrame}. From 88be2c9c1a8d8d5f866d70593ff0016e55d40ef7 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 01:25:12 +0530 Subject: [PATCH 085/118] perf(offline): optimize proofreading latency and load times --- .../helium314/keyboard/latin/LatinIME.java | 1 + .../keyboard/latin/utils/ProofreadHelper.kt | 13 +++ .../keyboard/latin/utils/ProofreadService.kt | 105 +++++++++++++++--- .../keyboard/latin/utils/ProofreadHelper.kt | 5 + .../keyboard/latin/utils/ProofreadHelper.kt | 8 ++ 5 files changed, 115 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 1c6e64a22..444db4ca1 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -917,6 +917,7 @@ private void onStartInputInternal(final EditorInfo editorInfo, final boolean res void onStartInputViewInternal(final EditorInfo editorInfo, final boolean restarting) { super.onStartInputView(editorInfo, restarting); + helium314.keyboard.latin.utils.ProofreadHelper.preloadModel(this); mDictionaryFacilitator.onStartInput(); // Switch to the null consumer to handle cases leading to early exit below, for diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index 61ddb1789..daed7186c 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -34,6 +34,19 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + /** + * Preload the model in the background to avoid initial latency. + */ + @JvmStatic + fun preloadModel(context: Context) { + val service = ProofreadService(context) + val modelPath = service.getModelPath() + if (modelPath.isNullOrBlank()) return + scope.launch { + ProofreadService.ModelHolder.loadModel(context, modelPath) + } + } + /** * Cancel the current proofreading/translation operation if one is in progress. */ diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index ede02b484..c809448bd 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -19,6 +19,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import org.nehuatl.llamacpp.LlamaHelper import java.io.File @@ -50,6 +53,7 @@ class ProofreadService(private val context: Context) { private var unloadJob: Job? = null private val scope = CoroutineScope(kotlinx.coroutines.SupervisorJob() + Dispatchers.IO) private const val UNLOAD_DELAY_MS = 10 * 60 * 1000L // 10 minutes + private val loadMutex = Mutex() // Flow for LLM events val llmFlow = MutableSharedFlow( @@ -95,11 +99,10 @@ class ProofreadService(private val context: Context) { isModelAvailable = true } - @Synchronized - fun loadModel( + suspend fun loadModel( context: Context, modelPath: String - ): Boolean { + ): Boolean = loadMutex.withLock { cancelUnload() // Check if already loaded with same path @@ -117,18 +120,71 @@ class ProofreadService(private val context: Context) { llmFlow ) - // Load the model with default context length - val contextLength = 2048 - var loadSuccess = false - helper.load( - path = modelPath, - contextLength = contextLength - ) { _ -> - loadSuccess = true + // Get llama via reflection + val llamaField = LlamaHelper::class.java.getDeclaredField("llama\$delegate").apply { isAccessible = true } + val llamaLazy = llamaField.get(helper) as Lazy + val llama = llamaLazy.value + + // Detach model file descriptor + val uri = android.net.Uri.parse(modelPath) + val pfd = contentResolver.openFileDescriptor(uri, "r") + ?: throw IllegalArgumentException("Failed to open model file descriptor") + val modelFd = pfd.detachFd() + + // Calculate optimal threads count (4 threads is the sweet spot for mobile CPUs) + val cores = Runtime.getRuntime().availableProcessors() + val threads = if (cores <= 4) cores else 4 + + Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=true") + + // Construct parameters map + val params = mutableMapOf( + "model" to modelPath, + "model_fd" to modelFd, + "use_mmap" to true, + "use_mlock" to false, + "n_ctx" to 2048, + "embedding" to false, + "n_batch" to 512, + "n_threads" to threads, + "n_gpu_layers" to 0, + "vocab_only" to false, + "lora" to "", + "lora_scaled" to 1.0, + "rope_freq_base" to 0.0, + "rope_freq_scale" to 0.0 + ) + + // JNI callback called by native code for each token + val callback: (String) -> Unit = { word -> + try { + val allTextField = LlamaHelper::class.java.getDeclaredField("allText").apply { isAccessible = true } + val currentAllText = allTextField.get(helper) as String + allTextField.set(helper, currentAllText + word) + + val tokenCountField = LlamaHelper::class.java.getDeclaredField("tokenCount").apply { isAccessible = true } + val currentCount = tokenCountField.get(helper) as Int + tokenCountField.set(helper, currentCount + 1) + + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Ongoing(word, currentCount + 1)) + } catch (e: Throwable) { + Log.e(TAG, "Error in native token callback", e) + } } - // Give the model a moment to load (it's async internally) - // We'll verify on first inference if it's truly loaded + // Start the engine + val result = llama.startEngine(params, callback) + + val contextId = result?.get("contextId") as? Int + ?: throw IllegalStateException("contextId not found in result map") + + // Set currentContext via reflection + val currentContextField = LlamaHelper::class.java.getDeclaredField("currentContext").apply { isAccessible = true } + currentContextField.set(helper, contextId) + + // Emit Loaded event + helper.sharedFlow.tryEmit(LlamaHelper.LLMEvent.Loaded(modelPath)) + llamaHelper = helper currentModelPath = modelPath isModelLoaded = true @@ -332,20 +388,21 @@ class ProofreadService(private val context: Context) { ) // Collect events until done - ModelHolder.llmFlow.collect { event -> + ModelHolder.llmFlow.takeWhile { event -> when (event) { is LlamaHelper.LLMEvent.Ongoing -> { generatedText.append(event.word) + true } is LlamaHelper.LLMEvent.Done -> { - return@collect + false } is LlamaHelper.LLMEvent.Error -> { throw ProofreadException(event.toString()) } - else -> { /* Ignore other events */ } + else -> true } - } + }.collect {} // Schedule unload after work is done ModelHolder.scheduleUnload(context) @@ -373,6 +430,20 @@ class ProofreadService(private val context: Context) { } } catch (e: Throwable) { + if (e is kotlinx.coroutines.CancellationException) { + // Cancel completion job if running + try { + val helper = ModelHolder.llamaHelper + if (helper != null) { + val completionJobField = LlamaHelper::class.java.getDeclaredField("completionJob").apply { isAccessible = true } + val completionJob = completionJobField.get(helper) as? Job + completionJob?.cancel() + } + } catch (ex: Throwable) { + Log.w(TAG, "Failed to cancel completion job", ex) + } + throw e + } Log.e(TAG, "Proofread failed", e) ModelHolder.scheduleUnload(context) // Ensure we still schedule unload on error Result.failure(ProofreadException(e.message ?: "Unknown error")) diff --git a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index ec537f622..3e7537c8c 100644 --- a/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/offlinelite/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -23,6 +23,11 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + @JvmStatic + fun preloadModel(context: Context) { + // No-op for offlinelite flavor (no AI support) + } + @JvmStatic fun cancelCurrentOperation() { /* No-op */ } diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt index ee26866f6..08e7b78b5 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadHelper.kt @@ -36,6 +36,14 @@ object ProofreadHelper { var lastOriginalText: String? = null private set + /** + * Preload the model in the background to avoid initial latency. + */ + @JvmStatic + fun preloadModel(context: Context) { + // No-op for standard flavor (runs API based proofreader) + } + /** * Cancel the current proofreading/translation operation if one is in progress. */ From ef19784322a1f85b8b4569ceeb7192a40cd5d833 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 01:46:46 +0530 Subject: [PATCH 086/118] fix(offline): improve GGUF prompt formatting and output cleaning --- .../keyboard/latin/utils/ProofreadService.kt | 57 +++++++++++++++---- 1 file changed, 46 insertions(+), 11 deletions(-) diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index c809448bd..0eb4635d0 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -135,13 +135,13 @@ class ProofreadService(private val context: Context) { val cores = Runtime.getRuntime().availableProcessors() val threads = if (cores <= 4) cores else 4 - Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=true") + Log.i(TAG, "Loading GGUF model: threads=$threads (cores=$cores), use_mmap=false") // Construct parameters map val params = mutableMapOf( "model" to modelPath, "model_fd" to modelFd, - "use_mmap" to true, + "use_mmap" to false, "use_mlock" to false, "n_ctx" to 2048, "embedding" to false, @@ -363,11 +363,21 @@ class ProofreadService(private val context: Context) { // Build the prompt val systemPrompt = overridePrompt ?: getSystemPrompt() - val fullPrompt = if (systemPrompt.isNotBlank()) { - "$systemPrompt$text" + val fullPrompt = if (systemPrompt.contains("{text}")) { + systemPrompt.replace("{text}", text) + } else if (overridePrompt != null) { + // Translation or specific override + "Instruction: ${systemPrompt.trim()}\nInput: $text\nOutput:" } else { - // Default proofreading prompt - "Correct the grammar and spelling of the following text. Output only the corrected text, nothing else:\n\n$text" + // Default proofreading with few-shot examples for better local model guidance + val instruction = systemPrompt.ifBlank { "Correct the grammar and spelling of the input text. Output only the corrected text, nothing else." } + "Instruction: ${instruction.trim()}\n\n" + + "Input: heko hw r u\n" + + "Output: Hello, how are you?\n\n" + + "Input: what you name\n" + + "Output: What is your name?\n\n" + + "Input: $text\n" + + "Output:" } // Collect generated text from the flow @@ -409,11 +419,35 @@ class ProofreadService(private val context: Context) { val output = generatedText.toString().trim() - // Strip prompt prefix if model echoed it back - val cleanedOutput = if (systemPrompt.isNotBlank() && output.startsWith(systemPrompt, ignoreCase = true)) { - output.removePrefix(systemPrompt).trimStart() - } else { - output + // Robust cleaning of the generated output + var cleanedOutput = output + if (cleanedOutput.startsWith(fullPrompt, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(fullPrompt.length).trim() + } else if (systemPrompt.isNotBlank() && cleanedOutput.startsWith(systemPrompt, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(systemPrompt.length).trim() + if (cleanedOutput.startsWith(text, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(text.length).trim() + } + } + + // Also strip common prefixes that the model might generate or echo + val prefixesToStrip = listOf( + "Output:", "Corrected:", "Translation:", "Response:", "Result:", + "Output: ", "Corrected: ", "Translation: ", "Response: ", "Result: " + ) + for (prefix in prefixesToStrip) { + if (cleanedOutput.startsWith(prefix, ignoreCase = true)) { + cleanedOutput = cleanedOutput.substring(prefix.length).trim() + break + } + } + + // If the model wrapped the output in quotes, strip them + if (cleanedOutput.startsWith("\"") && cleanedOutput.endsWith("\"")) { + cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() + } + if (cleanedOutput.startsWith("'") && cleanedOutput.endsWith("'")) { + cleanedOutput = cleanedOutput.substring(1, cleanedOutput.length - 1).trim() } // Post-process to strip thinking/reasoning tags if showThinkingVal is false @@ -423,6 +457,7 @@ class ProofreadService(private val context: Context) { cleanedOutput } + Log.i(TAG, "proofread: input='$text' prompt='$fullPrompt' generated='$output' final='$finalOutput'") if (finalOutput.isNotBlank()) { Result.success(finalOutput) } else { From 05fb5b92c025d194e2248927a2007452423d4b09 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 01:59:48 +0530 Subject: [PATCH 087/118] fix(offline): truncate model output at template markers and add native stop sequences --- .../keyboard/latin/utils/ProofreadService.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 0eb4635d0..20a2e3338 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -430,6 +430,17 @@ class ProofreadService(private val context: Context) { } } + // Truncate at the first occurrence of subsequent template markers + val markers = listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:", "Input:", "Instruction:", "Output:", "Corrected:") + for (marker in markers) { + val idx = cleanedOutput.indexOf(marker, ignoreCase = true) + if (idx != -1) { + if (marker.startsWith("\n") || idx > 0) { + cleanedOutput = cleanedOutput.substring(0, idx).trim() + } + } + } + // Also strip common prefixes that the model might generate or echo val prefixesToStrip = listOf( "Output:", "Corrected:", "Translation:", "Response:", "Result:", @@ -523,7 +534,8 @@ class ProofreadService(private val context: Context) { "top_p" to topP.toDouble(), "top_k" to topK, "min_p" to minP.toDouble(), - "n_predict" to maxTokens + "n_predict" to maxTokens, + "stop" to listOf("\nInput:", "\nInstruction:", "\nOutput:", "\nCorrected:") ) // Get completionJob field From 7877afb5325c19b0b215c44a86896070a93a2119 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 02:02:37 +0530 Subject: [PATCH 088/118] fix(offline): implement dynamic target-language-specific few-shot examples for GGUF translation --- .../keyboard/latin/utils/ProofreadService.kt | 68 ++++++++++++++++++- 1 file changed, 65 insertions(+), 3 deletions(-) diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index 20a2e3338..a08c0b518 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -332,13 +332,18 @@ class ProofreadService(private val context: Context) { val target = sharedPrefs.getString(Settings.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE, Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE) ?: Defaults.PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE val systemPromptTemplate = getTranslateSystemPrompt().takeIf { it.isNotBlank() } ?: Defaults.PREF_OFFLINE_TRANSLATE_SYSTEM_PROMPT val prompt = systemPromptTemplate.replace("{lang}", target) - return proofread(text, overridePrompt = prompt) + return proofread(text, overridePrompt = prompt, targetLanguage = target) } /** * Run llamacpp inference for proofreading/text correction. */ - suspend fun proofread(text: String, overridePrompt: String? = null, showThinking: Boolean? = null): Result = withContext(Dispatchers.IO) { + suspend fun proofread( + text: String, + overridePrompt: String? = null, + showThinking: Boolean? = null, + targetLanguage: String? = null + ): Result = withContext(Dispatchers.IO) { val modelPath = getModelPath() if (modelPath.isNullOrBlank()) { return@withContext Result.failure(ProofreadException("Model not loaded. Please select a GGUF model file.")) @@ -367,7 +372,17 @@ class ProofreadService(private val context: Context) { systemPrompt.replace("{text}", text) } else if (overridePrompt != null) { // Translation or specific override - "Instruction: ${systemPrompt.trim()}\nInput: $text\nOutput:" + val examples = targetLanguage?.let { getTranslationFewShot(it) } ?: emptyList() + if (examples.isNotEmpty()) { + var builder = "Instruction: ${systemPrompt.trim()}\n\n" + for (ex in examples) { + builder += "Input: ${ex.first}\nOutput: ${ex.second}\n\n" + } + builder += "Input: $text\nOutput:" + builder + } else { + "Instruction: ${systemPrompt.trim()}\nInput: $text\nOutput:" + } } else { // Default proofreading with few-shot examples for better local model guidance val instruction = systemPrompt.ifBlank { "Correct the grammar and spelling of the input text. Output only the corrected text, nothing else." } @@ -572,6 +587,53 @@ class ProofreadService(private val context: Context) { .trim() } + private fun getTranslationFewShot(targetLanguage: String): List> { + val lang = targetLanguage.trim().lowercase() + return when { + lang.contains("french") || lang.contains("français") -> listOf( + "Hello, how are you?" to "Bonjour, comment allez-vous?", + "My name is Alex." to "Je m'appelle Alex." + ) + lang.contains("spanish") || lang.contains("español") -> listOf( + "Hello, how are you?" to "Hola, ¿cómo estás?", + "My name is Alex." to "Mi nombre es Alex." + ) + lang.contains("german") || lang.contains("deutsch") -> listOf( + "Hello, how are you?" to "Hallo, wie geht es dir?", + "My name is Alex." to "Mein Name ist Alex." + ) + lang.contains("italian") || lang.contains("italiano") -> listOf( + "Hello, how are you?" to "Ciao, come stai?", + "My name is Alex." to "Il mio nome è Alex." + ) + lang.contains("portuguese") || lang.contains("português") -> listOf( + "Hello, how are you?" to "Olá, como você está?", + "My name is Alex." to "Meu nome é Alex." + ) + lang.contains("dutch") || lang.contains("nederlands") -> listOf( + "Hello, how are you?" to "Hallo, hoe gaat het met je?", + "My name is Alex." to "Mijn naam is Alex." + ) + lang.contains("russian") || lang.contains("русский") -> listOf( + "Hello, how are you?" to "Привет, как дела?", + "My name is Alex." to "Меня зовут Алекс." + ) + lang.contains("chinese") || lang.contains("中文") || lang.contains("汉语") -> listOf( + "Hello, how are you?" to "你好,你好吗?", + "My name is Alex." to "我的名字是亚历克斯。" + ) + lang.contains("japanese") || lang.contains("日本語") -> listOf( + "Hello, how are you?" to "こんにちは、お元気ですか?", + "My name is Alex." to "私の名前はアレックスです。" + ) + lang.contains("hindi") || lang.contains("हिन्दी") -> listOf( + "Hello, how are you?" to "नमस्ते, आप कैसे हैं?", + "My name is Alex." to "मेरा नाम एलेक्स है।" + ) + else -> emptyList() + } + } + class ProofreadException(message: String) : Exception(message) class TranslateException(message: String) : Exception(message) From ba7ad8f912f99599f91873a73e721c4d3052364c Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 02:13:45 +0530 Subject: [PATCH 089/118] feat(expander): immediate expand & fix revert --- .../keyboard/latin/inputlogic/InputLogic.java | 28 ++++++++++++++++++- .../keyboard/latin/utils/TextExpanderUtils.kt | 5 ++++ .../settings/screens/TextExpanderScreen.kt | 13 +++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 9f8644ad2..4ee7f3c46 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -1450,6 +1450,32 @@ private void handleNonSeparatorEvent(final Event event, final SettingsValues set mWordComposer.setCapitalizedModeAtStartComposingTime(inputTransaction.getShiftState()); } setComposingTextInternal(getTextWithUnderline(mWordComposer.getTypedWord()), 1); + if (helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isEnabled(mLatinIME) + && helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.isImmediateEnabled(mLatinIME)) { + final String typedWord = mWordComposer.getTypedWord(); + final String prefix = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getPrefix(mLatinIME); + if (prefix.isEmpty()) { + final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(typedWord, mLatinIME); + if (expanded != null) { + commitExpandedText(typedWord, expanded); + resetComposingState(true); + } + } else { + final CharSequence textBefore = mConnection.getTextBeforeCursor(50, 0); + if (textBefore != null) { + final String textStr = textBefore.toString(); + final String targetSuffix = prefix + typedWord; + if (textStr.toLowerCase(java.util.Locale.US).endsWith(targetSuffix.toLowerCase(java.util.Locale.US))) { + final String expanded = helium314.keyboard.latin.utils.TextExpanderUtils.INSTANCE.getExpandedWord(targetSuffix, mLatinIME); + if (expanded != null) { + mConnection.deleteTextBeforeCursor(prefix.length()); + commitExpandedText(targetSuffix, expanded); + resetComposingState(true); + } + } + } + } + } } else { final boolean swapWeakSpace = tryStripSpaceAndReturnWhetherShouldSwapInstead(event, inputTransaction); @@ -1640,7 +1666,7 @@ private void handleBackspaceEvent(final Event event, final InputTransaction inpu final String expectedAfter = mLastExpandedText.substring(beforeLen); if (textBefore != null && textBefore.toString().equals(expectedBefore) && textAfter != null && textAfter.toString().equals(expectedAfter)) { - mConnection.deleteSurroundingText(beforeLen, afterLen); + mConnection.setSelection(expectedCursor - beforeLen, expectedCursor + afterLen); mConnection.commitText(mLastShortcutText, 1); mLastExpandedText = null; mLastShortcutText = null; diff --git a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt index 2e67f4449..eff85bce3 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/TextExpanderUtils.kt @@ -15,6 +15,7 @@ import java.util.Locale object TextExpanderUtils { const val PREF_ENABLED = "pref_text_expander_enabled" const val PREF_PREFIX = "pref_text_expander_prefix" + const val PREF_IMMEDIATE = "pref_text_expander_immediate" const val PREF_DATA = "pref_text_expander_data" const val REGEX_PREFIX = "__regex__:" @@ -22,6 +23,10 @@ object TextExpanderUtils { return context.prefs().getBoolean(PREF_ENABLED, false) } + fun isImmediateEnabled(context: Context): Boolean { + return context.prefs().getBoolean(PREF_IMMEDIATE, false) + } + fun getPrefix(context: Context): String { return context.prefs().getString(PREF_PREFIX, "") ?: "" } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt index a118e53bc..75cf8fb96 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/TextExpanderScreen.kt @@ -79,6 +79,10 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { mutableStateOf(TextExpanderUtils.isEnabled(context)) } + var isImmediateEnabled by remember { + mutableStateOf(TextExpanderUtils.isImmediateEnabled(context)) + } + var shortcutsMap by remember { mutableStateOf(TextExpanderUtils.getShortcuts(context)) } @@ -311,6 +315,15 @@ fun TextExpanderScreen(onClickBack: () -> Unit) { onCheckedChange = { isExpanderEnabled = it } ) + SwitchPreference( + name = "Expand immediately", + key = TextExpanderUtils.PREF_IMMEDIATE, + default = false, + description = "Expand shortcuts immediately without pressing space.", + enabled = isExpanderEnabled, + onCheckedChange = { isImmediateEnabled = it } + ) + // 2. Custom Prefix Configuration OutlinedTextField( value = prefixText, From bd0da6a7ce6d6537b08bb5567aec50374456db76 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 13 Jun 2026 02:30:01 +0530 Subject: [PATCH 090/118] feat: hold toolbar arrow keys to auto-repeat --- .../clipboard/ClipboardHistoryView.kt | 19 ++++++- .../latin/suggestions/SuggestionStripView.kt | 19 ++++++- .../keyboard/latin/utils/ToolbarUtils.kt | 56 +++++++++++++++++++ 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt index 719ac2617..7f447ca02 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/clipboard/ClipboardHistoryView.kt @@ -34,6 +34,8 @@ import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.ResourceUtils import helium314.keyboard.latin.utils.ToolbarKey import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.isRepeatableToolbarKey +import helium314.keyboard.latin.utils.RepeatableKeyTouchListener import helium314.keyboard.latin.utils.getCodeForToolbarKey import helium314.keyboard.latin.utils.getCodeForToolbarKeyLongClick import helium314.keyboard.latin.utils.getEnabledClipboardToolbarKeys @@ -196,8 +198,21 @@ class ClipboardHistoryView @JvmOverloads constructor( val clipboardStrip = KeyboardSwitcher.getInstance().clipboardStrip toolbarKeys.forEach { clipboardStrip.addView(it) - it.setOnClickListener(this@ClipboardHistoryView) - it.setOnLongClickListener(this@ClipboardHistoryView) + val tag = it.tag + if (tag is ToolbarKey && isRepeatableToolbarKey(tag)) { + it.setOnTouchListener(RepeatableKeyTouchListener { repeatCount -> + if (repeatCount == 0 || repeatCount % 4 == 0) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, it, HapticEvent.KEY_PRESS) + } + val code = getCodeForToolbarKey(tag) + if (code != KeyCode.UNSPECIFIED) { + keyboardActionListener.onCodeInput(code, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, repeatCount > 0) + } + }) + } else { + it.setOnClickListener(this@ClipboardHistoryView) + it.setOnLongClickListener(this@ClipboardHistoryView) + } colors.setColor(it, ColorType.TOOL_BAR_KEY) it.setBackgroundResource(R.drawable.toolbar_key_background) colors.setColor(it.background, ColorType.TOOL_BAR_EXPAND_KEY_BACKGROUND) diff --git a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt index f381ec697..a24ed9725 100644 --- a/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt +++ b/app/src/main/java/helium314/keyboard/latin/suggestions/SuggestionStripView.kt @@ -51,6 +51,8 @@ import helium314.keyboard.latin.utils.ToolbarKey import helium314.keyboard.latin.utils.ToolbarMode import helium314.keyboard.latin.utils.addPinnedKey import helium314.keyboard.latin.utils.createToolbarKey +import helium314.keyboard.latin.utils.isRepeatableToolbarKey +import helium314.keyboard.latin.utils.RepeatableKeyTouchListener import helium314.keyboard.latin.utils.dpToPx import helium314.keyboard.latin.utils.getCodeForToolbarKey import helium314.keyboard.latin.utils.getCodeForToolbarKeyLongClick @@ -866,8 +868,21 @@ class SuggestionStripView(context: Context, attrs: AttributeSet?, defStyle: Int) } private fun setupKey(view: ImageButton, colors: Colors) { - view.setOnClickListener(this) - view.setOnLongClickListener(this) + val tag = view.tag + if (tag is ToolbarKey && isRepeatableToolbarKey(tag)) { + view.setOnTouchListener(RepeatableKeyTouchListener { repeatCount -> + if (repeatCount == 0 || repeatCount % 4 == 0) { + AudioAndHapticFeedbackManager.getInstance().performHapticAndAudioFeedback(KeyCode.NOT_SPECIFIED, view, HapticEvent.KEY_PRESS) + } + val code = getCodeForToolbarKey(tag) + if (code != KeyCode.UNSPECIFIED) { + listener.onCodeInput(code, Constants.SUGGESTION_STRIP_COORDINATE, Constants.SUGGESTION_STRIP_COORDINATE, repeatCount > 0) + } + }) + } else { + view.setOnClickListener(this) + view.setOnLongClickListener(this) + } colors.setColor(view, ColorType.TOOL_BAR_KEY) // Set circular background for toolbar keys view.setBackgroundResource(R.drawable.toolbar_key_background) diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index a6ae57c7c..8432abc22 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -7,6 +7,11 @@ import android.view.ViewGroup import android.widget.ImageButton import android.widget.ImageView import androidx.core.content.edit +import android.view.View +import android.view.MotionEvent +import android.os.Handler +import android.os.Looper +import android.annotation.SuppressLint import androidx.core.view.forEach import helium314.keyboard.keyboard.internal.KeyboardIconsSet import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode @@ -409,3 +414,54 @@ fun clearCustomToolbarKeyCodes() { } private var customToolbarKeyCodes: EnumMap>? = null + +fun isRepeatableToolbarKey(key: ToolbarKey): Boolean { + return when (key) { + LEFT, RIGHT, UP, DOWN, + WORD_LEFT, WORD_RIGHT, + PAGE_UP, PAGE_DOWN -> true + else -> false + } +} + +class RepeatableKeyTouchListener( + private val onClick: (repeatCount: Int) -> Unit +) : View.OnTouchListener { + private val handler = Handler(Looper.getMainLooper()) + private var repeatCount = 0 + private val runnable = object : Runnable { + override fun run() { + repeatCount++ + onClick(repeatCount) + handler.postDelayed(this, 50L) + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + when (event.action) { + MotionEvent.ACTION_DOWN -> { + repeatCount = 0 + onClick(0) + handler.postDelayed(runnable, 400L) + v.isPressed = true + return true + } + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + handler.removeCallbacks(runnable) + v.isPressed = false + return true + } + MotionEvent.ACTION_MOVE -> { + val x = event.x + val y = event.y + if (x < 0 || x > v.width || y < 0 || y > v.height) { + handler.removeCallbacks(runnable) + v.isPressed = false + } + return true + } + } + return false + } +} From 08187e728ca81a1d08f0c0abd620ade664caa40e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 13 Jun 2026 04:00:36 +0000 Subject: [PATCH 091/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index c36fbeca2..f369f0b78 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2975029750 +DownloadsDownloads2987029870 From 454494b9c333cc8548e2e953d52dd258b13da8a3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 14 Jun 2026 04:19:04 +0000 Subject: [PATCH 092/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index f369f0b78..c3fccb451 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2987029870 +DownloadsDownloads2997529975 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index 757f11fca..6834cbe4c 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars476476 +StarsStars477477 From 0767b46979c3c4da4df939cda29004145c4d5ecc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 15 Jun 2026 04:42:11 +0000 Subject: [PATCH 093/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index c3fccb451..c567fea3a 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads2997529975 +DownloadsDownloads3014630146 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index 6834cbe4c..1166d231f 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars477477 +StarsStars481481 From bb4bf8f4b90b18ae14164d1c4759d241deba58cd Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 00:24:26 +0530 Subject: [PATCH 094/118] feat: add toggle for insecure AI connections Allows HTTP local endpoints and self-signed HTTPS connections only when explicitly enabled by the user. --- app/src/main/AndroidManifest.xml | 2 + .../keyboard/latin/settings/Defaults.kt | 1 + .../keyboard/latin/settings/Settings.java | 1 + .../keyboard/settings/SettingsContainer.kt | 1 + .../settings/screens/AIIntegrationScreen.kt | 1 + .../settings/screens/AdvancedScreen.kt | 3 ++ app/src/main/res/values/strings.xml | 3 ++ .../main/res/xml/network_security_config.xml | 10 +++++ .../keyboard/latin/utils/ProofreadService.kt | 40 ++++++++++++++++++- 9 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 app/src/main/res/xml/network_security_config.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1e11441bd..a882c3dc9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,8 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:allowBackup="false" android:defaultToDeviceProtectedStorage="true" android:directBootAware="true" + android:networkSecurityConfig="@xml/network_security_config" + android:usesCleartextTraffic="true" tools:remove="android:appComponentFactory" tools:targetApi="p"> diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 540274c57..6b1e77ba9 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -161,6 +161,7 @@ object Defaults { const val PREF_OFFLINE_MAX_TOKENS = 64 // Accurate (64 tokens) default const val PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "French" const val PREF_OFFLINE_KEEP_MODEL_LOADED = false + const val PREF_AI_ALLOW_INSECURE_CONNECTIONS = false const val PREF_ENABLE_CLIPBOARD_HISTORY = true const val PREF_CLIPBOARD_HISTORY_RETENTION_TIME = 15 // minutes const val PREF_CLIPBOARD_HISTORY_PINNED_FIRST = true diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java index 490f6f110..b53e42a6a 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Settings.java +++ b/app/src/main/java/helium314/keyboard/latin/settings/Settings.java @@ -167,6 +167,7 @@ public final class Settings implements SharedPreferences.OnSharedPreferenceChang public static final String PREF_OFFLINE_TRANSLATE_TARGET_LANGUAGE = "offline_translate_target_language"; public static final String PREF_OFFLINE_MAX_TOKENS = "offline_max_tokens"; public static final String PREF_OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded"; + public static final String PREF_AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections"; public static final String PREF_ENABLE_CLIPBOARD_HISTORY = "enable_clipboard_history"; public static final String PREF_SUGGEST_SCREENSHOTS = "suggest_screenshots"; diff --git a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt index 4188e084d..7243cde8d 100644 --- a/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt +++ b/app/src/main/java/helium314/keyboard/settings/SettingsContainer.kt @@ -111,4 +111,5 @@ object SettingsWithoutKey { const val GROQ_MODEL = "groq_model" const val CUSTOM_AI_KEYS = "custom_ai_keys" const val OFFLINE_KEEP_MODEL_LOADED = "offline_keep_model_loaded" + const val AI_ALLOW_INSECURE_CONNECTIONS = "ai_allow_insecure_connections" } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index 9ea9b4ffb..a296b26d6 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -79,6 +79,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { add(SettingsWithoutKey.HUGGINGFACE_TOKEN) add(SettingsWithoutKey.HUGGINGFACE_MODEL) add(SettingsWithoutKey.HUGGINGFACE_ENDPOINT) + add(SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS) add(SettingsWithoutKey.GEMINI_TARGET_LANGUAGE) add(SettingsWithoutKey.TRANSLATE_HUGGINGFACE_MODEL) } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index fa8f8192b..232eb907f 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -455,6 +455,9 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, + Setting(context, SettingsWithoutKey.AI_ALLOW_INSECURE_CONNECTIONS, R.string.ai_allow_insecure_connections_title, R.string.ai_allow_insecure_connections_summary) { setting -> + SwitchPreference(setting, Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS) + }, Setting(context, SettingsWithoutKey.GEMINI_TARGET_LANGUAGE, R.string.translate_target_language_title, R.string.translate_target_language_summary) { setting -> val ctx = LocalContext.current val service = remember { helium314.keyboard.latin.utils.ProofreadService(ctx) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e4a3a1f6f..b906f49f1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -455,6 +455,9 @@ API Endpoint API endpoint URL https://api.groq.com/openai/v1/chat/completions + Insecure HTTP connection blocked. Enable \'Allow Insecure Connections\' in AI settings. + Allow insecure connections + Allow HTTP connections and ignore SSL certificate errors. Warning: exposes input to local network eavesdropping. https://generativelanguage.googleapis.com/v1beta/models?key=%1$s https://api.groq.com/openai/v1/models diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 000000000..4d5bef7d4 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt index bd6c4a162..8f8dbf1f6 100644 --- a/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/standard/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -240,6 +240,13 @@ class ProofreadService(private val context: Context) { securePrefs.edit().putString(KEY_HF_ENDPOINT, endpoint.trim()).apply() } + fun isAllowInsecureConnections(): Boolean = + context.prefs().getBoolean( + helium314.keyboard.latin.settings.Settings.PREF_AI_ALLOW_INSECURE_CONNECTIONS, + helium314.keyboard.latin.settings.Defaults.PREF_AI_ALLOW_INSECURE_CONNECTIONS + ) + + /** * Tests the API key by making a simple request. * @return Result with success message or error @@ -465,9 +472,22 @@ class ProofreadService(private val context: Context) { ) } - val url = URL(getHuggingFaceEndpoint()) + val endpoint = getHuggingFaceEndpoint() + val isHttp = endpoint.startsWith("http://", ignoreCase = true) + val allowInsecure = isAllowInsecureConnections() + + if (isHttp && !allowInsecure) { + return Result.failure( + ProofreadException(context.getString(R.string.insecure_connection_blocked)) + ) + } + + val url = URL(endpoint) val connection = url.openConnection() as HttpURLConnection - + if (allowInsecure && connection is javax.net.ssl.HttpsURLConnection) { + bypassSSLVerification(connection) + } + return try { connection.requestMethod = "POST" connection.setRequestProperty("Content-Type", "application/json") @@ -513,6 +533,22 @@ class ProofreadService(private val context: Context) { } } + private fun bypassSSLVerification(connection: javax.net.ssl.HttpsURLConnection) { + try { + val trustAllCerts = arrayOf(object : javax.net.ssl.X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + val sslContext = javax.net.ssl.SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + connection.sslSocketFactory = sslContext.socketFactory + connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } + } catch (e: Exception) { + Log.e("ProofreadService", "Failed to bypass SSL verification", e) + } + } + private fun parseOpenAIResponse(response: String, showThinking: Boolean): Result { return try { // OpenAI-compatible format: {"choices": [{"message": {"content": "..."}}]} From 9b7cb2e2caf98d09b4793a0143a5500c6ec3d19f Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 00:45:02 +0530 Subject: [PATCH 095/118] feat: add selective backup and restore --- .../preferences/BackupRestorePreference.kt | 330 +++++++++++++++--- app/src/main/res/values/strings.xml | 6 + 2 files changed, 284 insertions(+), 52 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt index 00c51dae6..94a70790f 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/BackupRestorePreference.kt @@ -12,10 +12,21 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.material3.Checkbox import helium314.keyboard.dictionarypack.DictionaryPackConstants import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.keyboard.emoji.SupportedEmojis @@ -56,17 +67,74 @@ fun BackupRestorePreference(setting: Setting) { var showDialog by rememberSaveable { mutableStateOf(false) } val ctx = LocalContext.current var error: String? by rememberSaveable { mutableStateOf(null) } - val backupLauncher = backupLauncher { error = it } - val restoreLauncher = restoreLauncher { error = it } + var selectedCategories by remember { + mutableStateOf( + setOf( + BackupCategory.LAYOUTS, + BackupCategory.THEME_APPEARANCE, + BackupCategory.DICTIONARY_HISTORY, + BackupCategory.CLIPBOARD, + BackupCategory.GENERAL_SETTINGS + ) + ) + } + val backupLauncher = backupLauncher(selectedCategories) { error = it } + val restoreLauncher = restoreLauncher(selectedCategories) { error = it } Preference(name = setting.title, onClick = { showDialog = true }) if (showDialog) { ConfirmationDialog( onDismissRequest = { showDialog = false }, title = { Text(stringResource(R.string.backup_restore_title)) }, - content = { Text(stringResource(R.string.backup_restore_message)) }, + content = { + Column { + Text( + text = stringResource(R.string.backup_select_items), + modifier = Modifier.padding(bottom = 8.dp) + ) + val categories = listOf( + BackupCategory.LAYOUTS to R.string.backup_category_layouts, + BackupCategory.THEME_APPEARANCE to R.string.backup_category_theme, + BackupCategory.DICTIONARY_HISTORY to R.string.backup_category_dictionary, + BackupCategory.CLIPBOARD to R.string.backup_category_clipboard, + BackupCategory.GENERAL_SETTINGS to R.string.backup_category_general + ) + categories.forEach { (category, stringResId) -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .toggleable( + value = selectedCategories.contains(category), + onValueChange = { checked -> + selectedCategories = if (checked) { + selectedCategories + category + } else { + selectedCategories - category + } + } + ) + .padding(vertical = 4.dp) + ) { + Checkbox( + checked = selectedCategories.contains(category), + onCheckedChange = null + ) + Text( + text = stringResource(stringResId), + modifier = Modifier.padding(start = 8.dp) + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text(text = stringResource(R.string.backup_restore_message)) + } + }, confirmButtonText = stringResource(R.string.button_backup), neutralButtonText = stringResource(R.string.button_restore), onNeutral = { + if (selectedCategories.isEmpty()) { + Toast.makeText(ctx, "Please select at least one category", Toast.LENGTH_SHORT).show() + return@ConfirmationDialog + } showDialog = false val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -74,6 +142,10 @@ fun BackupRestorePreference(setting: Setting) { restoreLauncher.launch(intent) }, onConfirmed = { + if (selectedCategories.isEmpty()) { + Toast.makeText(ctx, "Please select at least one category", Toast.LENGTH_SHORT).show() + return@ConfirmationDialog + } val currentDate = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(Calendar.getInstance().time) val intent = Intent(Intent.ACTION_CREATE_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -97,32 +169,40 @@ fun BackupRestorePreference(setting: Setting) { } @Composable -private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLauncher { +private fun backupLauncher( + selectedCategories: Set, + onError: (String) -> Unit +): ManagedActivityResultLauncher { val ctx = LocalContext.current return filePicker { uri -> - // zip all files matching the backup patterns - // essentially this is the typed words information, and user-added dictionaries val filesDir = ctx.filesDir ?: return@filePicker val filesPath = filesDir.path + File.separator val files = mutableListOf() filesDir.walk().forEach { file -> val path = file.path.replace(filesPath, "") - if (file.isFile && backupFilePatterns.any { path.matches(it) }) - files.add(file) + if (file.isFile && backupFilePatterns.any { path.matches(it) }) { + val cat = getCategoryForFilePath(path) + if (cat == null || selectedCategories.contains(cat)) { + files.add(file) + } + } } val protectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx) val protectedFilesPath = protectedFilesDir.path + File.separator val protectedFiles = mutableListOf() protectedFilesDir.walk().forEach { file -> val path = file.path.replace(protectedFilesPath, "") - if (file.isFile && backupFilePatterns.any { path.matches(it) }) - protectedFiles.add(file) + if (file.isFile && backupFilePatterns.any { path.matches(it) }) { + val cat = getCategoryForFilePath(path) + if (cat == null || selectedCategories.contains(cat)) { + protectedFiles.add(file) + } + } } val wait = CountDownLatch(1) ExecutorUtils.getBackgroundExecutor(ExecutorUtils.KEYBOARD).execute { try { ctx.getActivity()?.contentResolver?.openOutputStream(uri)?.use { os -> - // write files to zip val zipStream = ZipOutputStream(os) files.forEach { val fileStream = FileInputStream(it).buffered() @@ -138,27 +218,40 @@ private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLaun fileStream.close() zipStream.closeEntry() } - val dbFile = ctx.getDatabasePath(Database.NAME) - if (dbFile.exists()) { - val fileStream = FileInputStream(dbFile).buffered() - zipStream.putNextEntry(ZipEntry(Database.NAME)) - fileStream.copyTo(zipStream, 1024) - fileStream.close() - zipStream.closeEntry() + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + val dbFile = ctx.getDatabasePath(Database.NAME) + if (dbFile.exists()) { + val fileStream = FileInputStream(dbFile).buffered() + zipStream.putNextEntry(ZipEntry(Database.NAME)) + fileStream.copyTo(zipStream, 1024) + fileStream.close() + zipStream.closeEntry() + } + } + val filteredPrefs = ctx.prefs().all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) } zipStream.putNextEntry(ZipEntry(PREFS_FILE_NAME)) - settingsToJsonStream(ctx.prefs().all, zipStream) + settingsToJsonStream(filteredPrefs, zipStream) zipStream.closeEntry() + + val filteredProtectedPrefs = ctx.protectedPrefs().all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) + } zipStream.putNextEntry(ZipEntry(PROTECTED_PREFS_FILE_NAME)) - settingsToJsonStream(ctx.protectedPrefs().all, zipStream) + settingsToJsonStream(filteredProtectedPrefs, zipStream) zipStream.closeEntry() - // back up auxiliary SharedPreferences files used by individual features - // (gemini_prefs is intentionally excluded: it is EncryptedSharedPreferences - // whose values are tied to a device-specific master key and contains API keys) + for ((entryName, prefsForBackup) in auxiliaryPrefsToBackUp(ctx)) { - zipStream.putNextEntry(ZipEntry(entryName)) - settingsToJsonStream(prefsForBackup.all, zipStream) - zipStream.closeEntry() + val cat = getCategoryForFilePath(entryName) + if (cat == null || selectedCategories.contains(cat)) { + val filteredAuxPrefs = prefsForBackup.all.filter { + selectedCategories.contains(getCategoryForPrefKey(it.key)) + } + zipStream.putNextEntry(ZipEntry(entryName)) + settingsToJsonStream(filteredAuxPrefs, zipStream) + zipStream.closeEntry() + } } zipStream.close() } @@ -174,7 +267,10 @@ private fun backupLauncher(onError: (String) -> Unit): ManagedActivityResultLaun } @Composable -private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLauncher { +private fun restoreLauncher( + selectedCategories: Set, + onError: (String) -> Unit +): ManagedActivityResultLauncher { val ctx = LocalContext.current return filePicker { uri -> val wait = CountDownLatch(1) @@ -186,40 +282,92 @@ private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLau var entry: ZipEntry? = zip.nextEntry val filesDir = ctx.filesDir ?: return@execute val deviceProtectedFilesDir = DeviceProtectedUtils.getFilesDir(ctx) - filesDir.deleteRecursively() - deviceProtectedFilesDir.deleteRecursively() + + // Targeted deletion based on selected categories + if (selectedCategories.contains(BackupCategory.LAYOUTS)) { + File(filesDir, "layouts").deleteRecursively() + } + if (selectedCategories.contains(BackupCategory.DICTIONARY_HISTORY)) { + File(filesDir, "dicts").deleteRecursively() + File(filesDir, "blacklists").deleteRecursively() + filesDir.listFiles()?.forEach { + if (it.name.startsWith("UserHistoryDictionary")) it.delete() + } + } + if (selectedCategories.contains(BackupCategory.THEME_APPEARANCE)) { + File(filesDir, "custom_font").delete() + File(filesDir, "custom_emoji_font").delete() + deviceProtectedFilesDir.listFiles()?.forEach { + if (it.name.startsWith("custom_background_image")) it.delete() + } + } + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + ctx.deleteDatabase(Database.NAME) + } + LayoutUtilsCustom.onLayoutFileChanged() Settings.getInstance().stopListener() while (entry != null) { if (entry.name.startsWith("unprotected${File.separator}")) { val adjustedName = entry.name.substringAfter("unprotected${File.separator}") if (backupFilePatterns.any { adjustedName.matches(it) }) { - if (!restoreEntryToDir(zip, deviceProtectedFilesDir, adjustedName)) { - Log.w("AdvancedScreen", "skipping unsafe backup entry $adjustedName") + val cat = getCategoryForFilePath(adjustedName) + if (cat == null || selectedCategories.contains(cat)) { + File(deviceProtectedFilesDir, adjustedName).delete() + if (!restoreEntryToDir(zip, deviceProtectedFilesDir, adjustedName)) { + Log.w("AdvancedScreen", "skipping unsafe backup entry $adjustedName") + } } } } else if (backupFilePatterns.any { entry.name.matches(it) }) { - if (!restoreEntryToDir(zip, filesDir, entry.name)) { - Log.w("AdvancedScreen", "skipping unsafe backup entry ${entry.name}") + val cat = getCategoryForFilePath(entry.name) + if (cat == null || selectedCategories.contains(cat)) { + File(filesDir, entry.name).delete() + if (!restoreEntryToDir(zip, filesDir, entry.name)) { + Log.w("AdvancedScreen", "skipping unsafe backup entry ${entry.name}") + } } } else if (entry.name == Database.NAME) { - FileUtils.copyStreamToNewFile(zip, restoredDb) + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + FileUtils.copyStreamToNewFile(zip, restoredDb) + } } else if (entry.name == PREFS_FILE_NAME) { val prefLines = String(zip.readBytes()).split("\n") val prefs = ctx.prefs() - prefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, prefs) + prefs.edit(commit = true) { + prefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, prefs, selectedCategories) } else if (entry.name == PROTECTED_PREFS_FILE_NAME) { val prefLines = String(zip.readBytes()).split("\n") val protectedPrefs = ctx.protectedPrefs() - protectedPrefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, protectedPrefs) + protectedPrefs.edit(commit = true) { + protectedPrefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, protectedPrefs, selectedCategories) } else { val auxPrefs = auxiliaryPrefsToBackUp(ctx)[entry.name] if (auxPrefs != null) { - val prefLines = String(zip.readBytes()).split("\n") - auxPrefs.edit(commit = true) { clear() } - readJsonLinesToSettings(prefLines, auxPrefs) + val cat = getCategoryForFilePath(entry.name) + if (cat == null || selectedCategories.contains(cat)) { + val prefLines = String(zip.readBytes()).split("\n") + auxPrefs.edit(commit = true) { + auxPrefs.all.keys.forEach { key -> + if (selectedCategories.contains(getCategoryForPrefKey(key))) { + remove(key) + } + } + } + readJsonLinesToSettings(prefLines, auxPrefs, selectedCategories) + } } } zip.closeEntry() @@ -227,8 +375,9 @@ private fun restoreLauncher(onError: (String) -> Unit): ManagedActivityResultLau } } } - - Database.copyFromDb(restoredDb, ctx) + if (selectedCategories.contains(BackupCategory.CLIPBOARD)) { + Database.copyFromDb(restoredDb, ctx) + } Handler(Looper.getMainLooper()).post { FeedbackManager.message(ctx, R.string.backup_restored) } @@ -277,22 +426,32 @@ private fun settingsToJsonStream(settings: Map, out: OutputStream out.write(Json.encodeToString(stringSets).toByteArray()) } -private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences): Boolean { +private fun readJsonLinesToSettings(list: List, prefs: SharedPreferences, selectedCategories: Set): Boolean { val i = list.iterator() val e = prefs.edit() try { while (i.hasNext()) { when (i.next()) { - "boolean settings" -> Json.decodeFromString>(i.next()).forEach { e.putBoolean(it.key, it.value) } - "int settings" -> Json.decodeFromString>(i.next()).forEach { e.putInt(it.key, it.value) } - "long settings" -> Json.decodeFromString>(i.next()).forEach { e.putLong(it.key, it.value) } - "float settings" -> Json.decodeFromString>(i.next()).forEach { e.putFloat(it.key, it.value) } - "string settings" -> Json.decodeFromString>(i.next()).forEach { e.putString(it.key, it.value) } - "string set settings" -> Json.decodeFromString>>(i.next()).forEach { e.putStringSet(it.key, it.value) } + "boolean settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putBoolean(it.key, it.value) } + "int settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putInt(it.key, it.value) } + "long settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putLong(it.key, it.value) } + "float settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putFloat(it.key, it.value) } + "string settings" -> Json.decodeFromString>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putString(it.key, it.value) } + "string set settings" -> Json.decodeFromString>>(i.next()) + .filter { selectedCategories.contains(getCategoryForPrefKey(it.key)) } + .forEach { e.putStringSet(it.key, it.value) } } } - // commit synchronously so that post-restore actions and a possible process kill - // (e.g. the user closing the app immediately after restore) don't lose data e.commit() return true } catch (e: Exception) { @@ -340,3 +499,70 @@ private val backupFilePatterns by lazy { listOf( "custom_font".toRegex(), "custom_emoji_font".toRegex(), ) } + +enum class BackupCategory { + LAYOUTS, + THEME_APPEARANCE, + DICTIONARY_HISTORY, + CLIPBOARD, + GENERAL_SETTINGS +} + +private fun getCategoryForPrefKey(key: String): BackupCategory { + if (key.startsWith("layout_")) return BackupCategory.LAYOUTS + + val themeKeys = setOf( + "theme_style", "icon_style", "theme_colors", "theme_colors_night", + "theme_key_borders", "theme_auto_day_night", "custom_icon_names", + "navbar_color", "font_scale", "emoji_font_scale", "narrow_key_gaps", + "narrow_key_gaps_level", "emoji_key_fit", "emoji_skin_tone", "space_bar_text" + ) + if (themeKeys.contains(key) + || key.startsWith("user_colors_") + || key.startsWith("user_all_colors_") + || key.startsWith("user_more_colors_") + || key.startsWith("keyboard_height_scale") + || key.startsWith("bottom_padding_scale") + || key.startsWith("side_padding_scale") + || key.startsWith("split_spacer_scale") + ) { + return BackupCategory.THEME_APPEARANCE + } + + val dictKeys = setOf( + "use_personalized_dicts", "block_potentially_offensive", "next_word_prediction", + "suggest_emojis", "inline_emoji_search", "show_emoji_descriptions", + "auto_correction", "more_auto_correction", "auto_correct_threshold", + "autocorrect_shortcuts", "backspace_reverts_autocorrect", "suggest_punctuation", + "add_to_personal_dictionary" + ) + if (dictKeys.contains(key)) return BackupCategory.DICTIONARY_HISTORY + + val clipboardKeys = setOf( + "enable_clipboard_history", "suggest_screenshots", "compress_screenshots", + "clipboard_history_retention_time", "clipboard_history_pinned_first", + "clipboard_fold_pinned", "clear_clipboard_icon" + ) + if (clipboardKeys.contains(key)) return BackupCategory.CLIPBOARD + + return BackupCategory.GENERAL_SETTINGS +} + +private fun getCategoryForFilePath(path: String): BackupCategory? { + if (path.startsWith("layouts${File.separator}") || path.contains("layouts/")) { + return BackupCategory.LAYOUTS + } + if (path.startsWith("custom_background_image") || path == "custom_font" || path == "custom_emoji_font" || path == FLOATING_KEYBOARD_PREFS_FILE_NAME) { + return BackupCategory.THEME_APPEARANCE + } + if (path.startsWith("dicts${File.separator}") || path.startsWith("dicts/") + || path.startsWith("blacklists${File.separator}") || path.startsWith("blacklists/") + || path.startsWith("UserHistoryDictionary") + ) { + return BackupCategory.DICTIONARY_HISTORY + } + if (path == Database.NAME) { + return BackupCategory.CLIPBOARD + } + return null +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b906f49f1..4cd4c5b53 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -458,6 +458,12 @@ Insecure HTTP connection blocked. Enable \'Allow Insecure Connections\' in AI settings. Allow insecure connections Allow HTTP connections and ignore SSL certificate errors. Warning: exposes input to local network eavesdropping. + Layouts + Theme & Custom Backgrounds + Dictionaries & Typing History + Clipboard History + General Settings + Select items to include: https://generativelanguage.googleapis.com/v1beta/models?key=%1$s https://api.groq.com/openai/v1/models From 7cf14c667563c2ecb60043efbc398d47d80b43c6 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 00:48:05 +0530 Subject: [PATCH 096/118] fix: allow same word with different shortcuts --- .../screens/PersonalDictionaryScreen.kt | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt index 7559a50d6..a1c9138e9 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/PersonalDictionaryScreen.kt @@ -134,7 +134,10 @@ private fun EditWordDialog(word: Word, locale: Locale?, onDismissRequest: () -> val focusRequester = remember { FocusRequester() } var newWord by remember { mutableStateOf(word) } var newLocale by remember { mutableStateOf(locale) } - val wordValid = (newWord.word == word.word && locale == newLocale) || !doesWordExist(newWord.word, newLocale, ctx) + val identityUnchanged = newWord.word == word.word + && (newWord.shortcut.isNullOrEmpty() && word.shortcut.isNullOrEmpty() || newWord.shortcut == word.shortcut) + && locale == newLocale + val wordValid = identityUnchanged || !doesWordExist(newWord.word, newWord.shortcut, newLocale, ctx) fun save() { if (newWord != word || locale != newLocale) { deleteWord(word, locale, ctx.contentResolver) @@ -257,18 +260,27 @@ private fun deleteWord(wordDetails: Word, locale: Locale?, resolver: ContentReso } } -private fun doesWordExist(word: String, locale: Locale?, context: Context): Boolean { +private fun doesWordExist(word: String, shortcut: String?, locale: Locale?, context: Context): Boolean { val hasWordProjection = arrayOf(UserDictionary.Words.WORD, UserDictionary.Words.LOCALE) val select: String - val selectArgs: Array? + val selectArgs: Array if (locale == null) { - select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null" - selectArgs = arrayOf(word) + if (shortcut.isNullOrEmpty()) { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null AND (${UserDictionary.Words.SHORTCUT} is null OR ${UserDictionary.Words.SHORTCUT}='')" + selectArgs = arrayOf(word) + } else { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE} is null AND ${UserDictionary.Words.SHORTCUT}=?" + selectArgs = arrayOf(word, shortcut) + } } else { - select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=?" - // requires use of locale string (as opposed to more useful language tag) for interaction with Android system - selectArgs = arrayOf(word, locale.toString()) + if (shortcut.isNullOrEmpty()) { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=? AND (${UserDictionary.Words.SHORTCUT} is null OR ${UserDictionary.Words.SHORTCUT}='')" + selectArgs = arrayOf(word, locale.toString()) + } else { + select = "${UserDictionary.Words.WORD}=? AND ${UserDictionary.Words.LOCALE}=? AND ${UserDictionary.Words.SHORTCUT}=?" + selectArgs = arrayOf(word, locale.toString(), shortcut) + } } val cursor = context.contentResolver.query(UserDictionary.Words.CONTENT_URI, hasWordProjection, select, selectArgs, null) cursor.use { From 97802ea81d61f947ab59a4444264ca4c576629fa Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 01:01:10 +0530 Subject: [PATCH 097/118] feat: strip spaces before punctuation marks --- .../keyboard/latin/inputlogic/InputLogic.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 4ee7f3c46..5e1ba07f2 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -1969,6 +1969,33 @@ private boolean trySwapSwapperAndSpace(final Event event, return true; } + private static boolean isSpaceStrippingPunctuation(final int codePoint) { + return codePoint == '.' + || codePoint == ',' + || codePoint == ';' + || codePoint == ':' + || codePoint == '!' + || codePoint == '?' + || codePoint == ')' + || codePoint == ']' + || codePoint == '}' + || codePoint == '؟' // Arabic question mark + || codePoint == '،' // Arabic comma + || codePoint == '؛' // Arabic semicolon + || codePoint == '।' // Hindi Danda + || codePoint == '॥' // Hindi Double Danda + || codePoint == '。' // CJK full stop + || codePoint == '、' // CJK enumeration comma + || codePoint == ',' // CJK fullwidth comma + || codePoint == '?' // CJK fullwidth question mark + || codePoint == '!' // CJK fullwidth exclamation mark + || codePoint == ':' // CJK fullwidth colon + || codePoint == ';' // CJK fullwidth semicolon + || codePoint == ')' // CJK fullwidth closing parenthesis + || codePoint == '】' // CJK fullwidth closing bracket + || codePoint == '』'; // CJK fullwidth closing quote + } + /* * Strip a trailing space if necessary and returns whether it's a swap weak * space situation. @@ -1988,6 +2015,14 @@ private boolean tryStripSpaceAndReturnWhetherShouldSwapInstead(final Event event mConnection.removeTrailingSpace(); return false; } + + if (isSpaceStrippingPunctuation(codePoint) + && !inputTransaction.getSettingsValues().isUsuallyPrecededBySpace(codePoint)) { + if (mConnection.getCodePointBeforeCursor() == Constants.CODE_SPACE) { + mConnection.removeTrailingSpace(); + } + } + if ((SpaceState.WEAK == inputTransaction.getSpaceState() || SpaceState.SWAP_PUNCTUATION == inputTransaction.getSpaceState()) && isFromSuggestionStrip) { From a2a6f28ceb512d077639f6fdb614192d617c740b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 16 Jun 2026 04:25:45 +0000 Subject: [PATCH 098/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index c567fea3a..1d67a56b7 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads3014630146 +DownloadsDownloads3027130271 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index 1166d231f..1482afbad 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars481481 +StarsStars485485 From 95372e4fe0720c34fca1a67269c698f5e9e03a31 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 17 Jun 2026 04:20:27 +0000 Subject: [PATCH 099/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 1d67a56b7..10e7449dd 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads3027130271 +DownloadsDownloads3040030400 From 1e5a8ac51a01f119316cf80683f508602a693ce3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 18 Jun 2026 04:12:56 +0000 Subject: [PATCH 100/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 10e7449dd..a4cbb5f6f 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads3040030400 +DownloadsDownloads3051430514 From 26fe52444d436ab0c56e0ada9e0f3a45a3139c76 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 19 Jun 2026 04:41:04 +0000 Subject: [PATCH 101/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index a4cbb5f6f..97f0e21b7 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads3051430514 +DownloadsDownloads3060530605 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index 1482afbad..eab6d47ef 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars485485 +StarsStars487487 From 17f541d4d2970828bc733258a1ab675049ff3253 Mon Sep 17 00:00:00 2001 From: nugraha-abd <62243267+nugraha-abd@users.noreply.github.com> Date: Fri, 19 Jun 2026 20:05:12 +0700 Subject: [PATCH 102/118] =?UTF-8?q?Change=20default=20popup=20key=20on=20l?= =?UTF-8?q?etter=20=D8=A7=20in=20Persian=20language?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/assets/locale_key_texts/fa.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/locale_key_texts/fa.txt b/app/src/main/assets/locale_key_texts/fa.txt index e84824d74..636293b52 100644 --- a/app/src/main/assets/locale_key_texts/fa.txt +++ b/app/src/main/assets/locale_key_texts/fa.txt @@ -1,7 +1,7 @@ [popup_keys] ه ﻫ|ه‍ هٔ ة ی ئ ي ﯨ|ى -ا !fixedOrder!5 ٱ ء آ أ إ +ا !fixedOrder!5 آ ء ٱ أ إ ت ة ک ك و ؤ From 3def612d3b56a530127fc5b3b2378e1efe5cda4e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 20 Jun 2026 03:55:18 +0000 Subject: [PATCH 103/118] chore: update README badges [skip ci] --- docs/badges/downloads.svg | 2 +- docs/badges/stars.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/badges/downloads.svg b/docs/badges/downloads.svg index 97f0e21b7..df1200edc 100644 --- a/docs/badges/downloads.svg +++ b/docs/badges/downloads.svg @@ -1 +1 @@ -DownloadsDownloads3060530605 +DownloadsDownloads3069330693 diff --git a/docs/badges/stars.svg b/docs/badges/stars.svg index eab6d47ef..66a371b67 100644 --- a/docs/badges/stars.svg +++ b/docs/badges/stars.svg @@ -1 +1 @@ -StarsStars487487 +StarsStars488488 From 15c903cf5f268f47b33553b44c8c988b813d33b8 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 01:25:14 +0530 Subject: [PATCH 104/118] feat: add handwriting input support --- .../keyboard/KeyboardActionListenerImpl.kt | 8 + .../keyboard/keyboard/KeyboardSwitcher.java | 49 ++++ .../keyboard/internal/KeyboardIconsSet.kt | 3 + .../keyboard_parser/floris/KeyCode.kt | 3 +- .../helium314/keyboard/latin/LatinIME.java | 3 + .../latin/handwriting/HandwritingCanvas.kt | 105 ++++++++ .../latin/handwriting/HandwritingLoader.kt | 92 +++++++ .../handwriting/HandwritingRecognizer.kt | 17 ++ .../latin/handwriting/HandwritingView.kt | 246 ++++++++++++++++++ .../keyboard/latin/utils/ToolbarUtils.kt | 9 +- .../LoadHandwritingPluginPreference.kt | 91 +++++++ .../settings/screens/LibrariesHubScreen.kt | 15 ++ app/src/main/res/layout/handwriting_view.xml | 54 ++++ .../main/res/layout/main_keyboard_frame.xml | 3 + app/src/main/res/values/strings.xml | 9 + 15 files changed, 702 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt create mode 100644 app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt create mode 100644 app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt create mode 100644 app/src/main/res/layout/handwriting_view.xml diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt index e91c15c81..7849e52a8 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardActionListenerImpl.kt @@ -103,6 +103,14 @@ class KeyboardActionListenerImpl(private val latinIME: LatinIME, private val inp override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { when (primaryCode) { + KeyCode.HANDWRITING -> { + if (keyboardSwitcher.isHandwritingShowing) { + keyboardSwitcher.setAlphabetKeyboard() + } else { + keyboardSwitcher.setHandwritingKeyboard() + } + return + } KeyCode.TOGGLE_AUTOCORRECT -> return settings.toggleAutoCorrect() KeyCode.TOGGLE_INCOGNITO_MODE -> { settings.toggleAlwaysIncognitoMode() diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 42c14b3e9..2fb3d3dbd 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -42,6 +42,7 @@ import helium314.keyboard.latin.RichInputMethodManager; import helium314.keyboard.latin.RichInputMethodSubtype; import helium314.keyboard.latin.WordComposer; +import helium314.keyboard.latin.handwriting.HandwritingView; import helium314.keyboard.latin.settings.Settings; import helium314.keyboard.latin.settings.SettingsValues; import helium314.keyboard.latin.suggestions.SuggestionStripView; @@ -69,6 +70,7 @@ public final class KeyboardSwitcher implements KeyboardState.SwitchActions { private SuggestionStripView mSuggestionStripView; private LinearLayout mStripContainer; private ClipboardHistoryView mClipboardHistoryView; + private HandwritingView mHandwritingView; private TouchpadView mTouchpadView; private TextView mFakeToastView; private LatinIME mLatinIME; @@ -358,6 +360,10 @@ private void setMainKeyboardFrame( mSuggestionStripView.setVisibility(stripVisibility); mClipboardHistoryView.setVisibility(View.GONE); mClipboardHistoryView.stopClipboardHistory(); + if (mHandwritingView != null) { + mHandwritingView.setVisibility(View.GONE); + mHandwritingView.stopHandwriting(); + } if (PointerTracker.sPersistentTouchpadModeActive) { if (mTouchpadView != null) { @@ -432,6 +438,45 @@ public void setClipboardKeyboard() { mClipboardHistoryView.setVisibility(View.VISIBLE); } + public void setHandwritingKeyboard() { + if (DEBUG_ACTION) { + Log.d(TAG, "setHandwritingKeyboard"); + } + PointerTracker.sPersistentTouchpadModeActive = false; + if (mTouchpadView != null) { + mTouchpadView.setVisibility(View.GONE); + } + mMainKeyboardFrame.setVisibility(View.VISIBLE); + mKeyboardView.setVisibility(View.GONE); + mEmojiTabStripView.setVisibility(View.GONE); + mSuggestionStripView.setVisibility(View.VISIBLE); + mStripContainer.setVisibility(View.VISIBLE); + mClipboardStripScrollView.setVisibility(View.GONE); + mEmojiPalettesView.setVisibility(View.GONE); + mClipboardHistoryView.setVisibility(View.GONE); + + if (mHandwritingView != null) { + final RichInputMethodSubtype subtype = mRichImm.getCurrentSubtype(); + final String language = subtype.getLocale().getLanguage(); + mHandwritingView.startHandwriting( + mLatinIME.getCurrentInputEditorInfo(), + mLatinIME.mKeyboardActionListener, + language + ); + mHandwritingView.setVisibility(View.VISIBLE); + } + } + + public boolean isHandwritingShowing() { + return mHandwritingView != null && mHandwritingView.isShown(); + } + + public void clearHandwritingCanvas() { + if (mHandwritingView != null) { + mHandwritingView.clearCanvasAndComposition(); + } + } + @Override public void setNumpadKeyboard() { if (DEBUG_ACTION) { @@ -839,6 +884,7 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mMainKeyboardFrame = mCurrentInputView.findViewById(R.id.main_keyboard_frame); mEmojiPalettesView = mCurrentInputView.findViewById(R.id.emoji_palettes_view); mClipboardHistoryView = mCurrentInputView.findViewById(R.id.clipboard_history_view); + mHandwritingView = mCurrentInputView.findViewById(R.id.handwriting_view); mFakeToastView = mCurrentInputView.findViewById(R.id.fakeToast); mKeyboardViewWrapper = mCurrentInputView.findViewById(R.id.keyboard_view_wrapper); @@ -850,6 +896,9 @@ public View onCreateInputView(@NonNull Context displayContext, final boolean isH mEmojiPalettesView.setKeyboardActionListener(mLatinIME.mKeyboardActionListener); mClipboardHistoryView.setHardwareAcceleratedDrawingEnabled(isHardwareAcceleratedDrawingEnabled); mClipboardHistoryView.setKeyboardActionListener(mLatinIME.mKeyboardActionListener); + if (mHandwritingView != null) { + mHandwritingView.setHardwareAcceleratedDrawingEnabled(isHardwareAcceleratedDrawingEnabled); + } mEmojiTabStripView = mCurrentInputView.findViewById(R.id.emoji_tab_strip); mClipboardStripView = mCurrentInputView.findViewById(R.id.clipboard_strip); mClipboardStripScrollView = mCurrentInputView.findViewById(R.id.clipboard_strip_scroll_view); diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index 4ac28534d..df440958b 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -163,6 +163,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_holo ToolbarKey.LEFT -> R.drawable.ic_dpad_left ToolbarKey.RIGHT -> R.drawable.ic_dpad_right @@ -239,6 +240,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_lxx ToolbarKey.LEFT -> R.drawable.ic_dpad_left ToolbarKey.RIGHT -> R.drawable.ic_dpad_right @@ -315,6 +317,7 @@ class KeyboardIconsSet private constructor() { ToolbarKey.AUTOCORRECT -> R.drawable.ic_autocorrect_rounded ToolbarKey.CLEAR_CLIPBOARD -> R.drawable.ic_bin ToolbarKey.CLOSE_HISTORY -> R.drawable.ic_close_rounded + ToolbarKey.HANDWRITING -> R.drawable.ic_edit ToolbarKey.EMOJI -> R.drawable.sym_keyboard_smiley_rounded ToolbarKey.LEFT -> R.drawable.ic_dpad_left_rounded ToolbarKey.RIGHT -> R.drawable.ic_dpad_right_rounded diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index ef601df2a..e308a8cf5 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -193,6 +193,7 @@ object KeyCode { const val CUSTOM_AI_9 = -10069 const val CUSTOM_AI_10 = -10070 const val CLIPBOARD_SEARCH = -10071 + const val HANDWRITING = -10074 // Intents @@ -218,7 +219,7 @@ object KeyCode { TIMESTAMP, CTRL_LEFT, CTRL_RIGHT, ALT_LEFT, ALT_RIGHT, META_LEFT, META_RIGHT, SEND_INTENT_ONE, SEND_INTENT_TWO, SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, - CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE + CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, HANDWRITING -> this // conversion diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 444db4ca1..3a320ea9f 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1710,6 +1710,9 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { mKeyboardSwitcher.getKeyboardShiftMode(), mHandler); updateStateAfterInputTransaction(completeInputTransaction); + if (mKeyboardSwitcher.isHandwritingShowing()) { + mKeyboardSwitcher.clearHandwritingCanvas(); + } if (suggestionInfo.mSourceDict != null && helium314.keyboard.latin.dictionary.Dictionary.TYPE_EMOJI .equals(suggestionInfo.mSourceDict.mDictType)) { diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt new file mode 100644 index 000000000..41a30ac5f --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingCanvas.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.os.Handler +import android.os.Looper +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View + +class HandwritingCanvas @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : View(context, attrs, defStyleAttr) { + + private val strokePaint = Paint().apply { + color = 0xFF3F51B5.toInt() // Default blue, will be overridden by theme later + style = Paint.Style.STROKE + strokeWidth = 10f + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + isAntiAlias = true + } + + private val path = Path() + private val strokes = mutableListOf() + private var currentStroke = mutableListOf() + private var startTime: Long = 0 + private var isRecognitionDone = false + + private val mainHandler = Handler(Looper.getMainLooper()) + private val recognitionTimeout = 700L + private val recognizeRunnable = Runnable { + isRecognitionDone = true + onRecognitionTriggered?.invoke(ArrayList(strokes)) + } + + var onRecognitionTriggered: ((List) -> Unit)? = null + var onStrokeStarted: (() -> Unit)? = null + + fun setStrokeColor(color: Int) { + strokePaint.color = color + invalidate() + } + + fun clear() { + mainHandler.removeCallbacks(recognizeRunnable) + path.reset() + strokes.clear() + currentStroke.clear() + isRecognitionDone = false + invalidate() + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + canvas.drawPath(path, strokePaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + val x = event.x + val y = event.y + val time = event.eventTime + + when (event.action) { + MotionEvent.ACTION_DOWN -> { + mainHandler.removeCallbacks(recognizeRunnable) + if (isRecognitionDone) { + onStrokeStarted?.invoke() + isRecognitionDone = false + } + path.moveTo(x, y) + startTime = time + currentStroke.clear() + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add(0f) // Relative time start + invalidate() + } + MotionEvent.ACTION_MOVE -> { + path.lineTo(x, y) + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add((time - startTime).toFloat()) + invalidate() + } + MotionEvent.ACTION_UP -> { + path.lineTo(x, y) + currentStroke.add(x) + currentStroke.add(y) + currentStroke.add((time - startTime).toFloat()) + strokes.add(currentStroke.toFloatArray()) + currentStroke.clear() + invalidate() + + mainHandler.postDelayed(recognizeRunnable, recognitionTimeout) + } + } + return true + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt new file mode 100644 index 000000000..63a12a414 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.net.Uri +import dalvik.system.DexClassLoader +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.utils.prefs +import java.io.File + +object HandwritingLoader { + private const val PLUGIN_FILENAME = "handwriting_plugin.apk" + private const val PLUGIN_CLASS_NAME = "helium314.keyboard.handwriting.plugin.HandwritingRecognizerImpl" + private const val PREF_HAS_PLUGIN = "pref_handwriting_has_plugin" + + private var activeRecognizer: HandwritingRecognizer? = null + + fun getRecognizer(context: Context): HandwritingRecognizer? { + if (activeRecognizer != null) return activeRecognizer + if (!hasPlugin(context)) return null + + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (!apkFile.exists()) { + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + return null + } + + try { + val classLoader = DexClassLoader( + apkFile.absolutePath, + context.codeCacheDir.absolutePath, + null, + context.classLoader + ) + val clazz = classLoader.loadClass(PLUGIN_CLASS_NAME) + val recognizer = clazz.getDeclaredConstructor().newInstance() as HandwritingRecognizer + recognizer.init(context) + activeRecognizer = recognizer + return recognizer + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to load handwriting plugin", e) + } + return null + } + + fun hasPlugin(context: Context): Boolean { + return context.prefs().getBoolean(PREF_HAS_PLUGIN, false) + } + + fun importPlugin(context: Context, uri: Uri): Boolean { + try { + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + context.contentResolver.openInputStream(uri)?.use { input -> + apkFile.outputStream().use { output -> + input.copyTo(output) + } + } + + // Verify the plugin loads successfully + val classLoader = DexClassLoader( + apkFile.absolutePath, + context.codeCacheDir.absolutePath, + null, + context.classLoader + ) + val clazz = classLoader.loadClass(PLUGIN_CLASS_NAME) + val recognizer = clazz.getDeclaredConstructor().newInstance() as HandwritingRecognizer + recognizer.init(context) + + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, true).apply() + activeRecognizer = recognizer + return true + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to import plugin APK", e) + // Cleanup on failure + try { + File(context.filesDir, PLUGIN_FILENAME).delete() + } catch (_: Exception) {} + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + activeRecognizer = null + } + return false + } + + fun removePlugin(context: Context) { + try { + File(context.filesDir, PLUGIN_FILENAME).delete() + } catch (_: Exception) {} + context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() + activeRecognizer = null + } +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt new file mode 100644 index 000000000..308a0ecdf --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingRecognizer.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context + +interface ModelDownloadListener { + fun onProgress(progress: Float) + fun onComplete(success: Boolean) +} + +interface HandwritingRecognizer { + fun init(context: Context) + fun setLanguage(language: String): Boolean + fun isLanguageReady(language: String): Boolean + fun downloadModel(language: String, listener: ModelDownloadListener) + fun recognize(strokes: List): List? +} diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt new file mode 100644 index 000000000..d8cbaa535 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.ImageButton +import android.widget.LinearLayout +import android.widget.TextView +import helium314.keyboard.keyboard.KeyboardActionListener +import helium314.keyboard.keyboard.KeyboardId +import helium314.keyboard.keyboard.KeyboardLayoutSet +import helium314.keyboard.keyboard.MainKeyboardView +import helium314.keyboard.keyboard.PointerTracker +import helium314.keyboard.latin.AudioAndHapticFeedbackManager +import helium314.keyboard.latin.R +import helium314.keyboard.latin.RichInputConnection +import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode +import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.settings.Settings +import helium314.keyboard.latin.utils.Log +import helium314.keyboard.latin.SuggestedWords +import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo +import helium314.keyboard.latin.dictionary.Dictionary +import android.view.inputmethod.EditorInfo +import helium314.keyboard.keyboard.KeyboardSwitcher +import helium314.keyboard.event.HapticEvent + +class HandwritingView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr), KeyboardActionListener { + + private lateinit var languageLabel: TextView + private lateinit var clearButton: ImageButton + private lateinit var canvas: HandwritingCanvas + private lateinit var bottomRowKeyboard: MainKeyboardView + + private var keyboardActionListener: KeyboardActionListener? = null + private var editorInfo: EditorInfo? = null + private var currentLanguage: String = "" + + private var currentComposingText = "" + + override fun onFinishInflate() { + super.onFinishInflate() + languageLabel = findViewById(R.id.handwriting_language_label) + clearButton = findViewById(R.id.handwriting_clear_button) + canvas = findViewById(R.id.handwriting_canvas) + bottomRowKeyboard = findViewById(R.id.handwriting_bottom_row_keyboard) + + clearButton.setOnClickListener { + clearCanvasAndComposition() + } + + canvas.onStrokeStarted = { + commitCurrentComposition() + canvas.clear() + } + + canvas.onRecognitionTriggered = { strokes -> + performRecognition(strokes) + } + } + + fun startHandwriting( + editorInfo: EditorInfo, + keyboardActionListener: KeyboardActionListener, + language: String + ) { + this.editorInfo = editorInfo + this.keyboardActionListener = keyboardActionListener + this.currentLanguage = language + + languageLabel.text = language + + // Setup bottom row keyboard + bottomRowKeyboard.setKeyPreviewPopupEnabled(Settings.getValues().mKeyPreviewPopupOn) + bottomRowKeyboard.setKeyboardActionListener(this) + + try { + PointerTracker.switchTo(bottomRowKeyboard) + val kls = KeyboardLayoutSet.Builder.buildEmojiClipBottomRow(context, editorInfo) + val keyboard = kls.getKeyboard(KeyboardId.ELEMENT_EMOJI_BOTTOM_ROW) + bottomRowKeyboard.setKeyboard(keyboard) + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to setup bottom row keyboard", e) + } + + clearCanvasAndComposition() + } + + fun stopHandwriting() { + commitCurrentComposition() + canvas.clear() + bottomRowKeyboard.closing() + } + + fun setHardwareAcceleratedDrawingEnabled(enabled: Boolean) { + if (enabled) { + setLayerType(LAYER_TYPE_HARDWARE, null) + } + } + + fun commitCurrentComposition() { + if (currentComposingText.isNotEmpty()) { + val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return + val ic = latinIME.currentInputConnection ?: return + ic.finishComposingText() + currentComposingText = "" + latinIME.setSuggestions(SuggestedWords.getEmptyInstance()) + } + } + + fun clearCanvasAndComposition() { + canvas.clear() + currentComposingText = "" + val latinIME = KeyboardSwitcher.getInstance().latinIME + if (latinIME != null) { + val ic = latinIME.currentInputConnection + if (ic != null) { + ic.finishComposingText() + } + latinIME.setSuggestions(SuggestedWords.getEmptyInstance()) + } + } + + private fun performRecognition(strokes: List) { + if (strokes.isEmpty()) return + val recognizer = HandwritingLoader.getRecognizer(context) ?: return + + // Ensure language is set + recognizer.setLanguage(currentLanguage) + + val results = recognizer.recognize(strokes) + if (results.isNullOrEmpty()) return + + val mainCandidate = results[0] + currentComposingText = mainCandidate + + val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return + val ic = latinIME.currentInputConnection ?: return + + // Update composing text + ic.setComposingText(mainCandidate, 1) + + // Populate suggestion strip with alternative candidates + val suggestionInfos = ArrayList() + for (word in results) { + suggestionInfos.add( + SuggestedWordInfo( + word, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + ) + } + + val typedWordInfo = SuggestedWordInfo( + mainCandidate, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + + val suggestedWords = SuggestedWords( + suggestionInfos, + null, + typedWordInfo, + false, + false, + false, + SuggestedWords.INPUT_STYLE_TYPING, + SuggestedWords.NOT_A_SEQUENCE_NUMBER + ) + latinIME.setSuggestions(suggestedWords) + } + + // Intercept KeyboardActionListener events for the bottom row + override fun onCodeInput(primaryCode: Int, x: Int, y: Int, isKeyRepeat: Boolean) { + if (primaryCode == KeyCode.ALPHA) { + // Close handwriting mode + KeyboardSwitcher.getInstance().setAlphabetKeyboard() + return + } + + // For other keys, commit the composition first when relevant + if (primaryCode == Constants.CODE_SPACE || primaryCode == Constants.CODE_ENTER) { + commitCurrentComposition() + } + + keyboardActionListener?.onCodeInput(primaryCode, x, y, isKeyRepeat) + } + + override fun onTextInput(text: String) { + commitCurrentComposition() + keyboardActionListener?.onTextInput(text) + } + + override fun onImageSelected(imageUri: String?) { + keyboardActionListener?.onImageSelected(imageUri) + } + + override fun onPressKey(primaryCode: Int, repeatCount: Int, isSinglePointer: Boolean, hapticEvent: HapticEvent?) { + keyboardActionListener?.onPressKey(primaryCode, repeatCount, isSinglePointer, hapticEvent) + } + + override fun onReleaseKey(primaryCode: Int, withSliding: Boolean) { + keyboardActionListener?.onReleaseKey(primaryCode, withSliding) + } + + override fun onLongPressKey(primaryCode: Int) { + keyboardActionListener?.onLongPressKey(primaryCode) + } + + override fun onKeyDown(keyCode: Int, keyEvent: android.view.KeyEvent?): Boolean { + return keyboardActionListener?.onKeyDown(keyCode, keyEvent) ?: false + } + + override fun onKeyUp(keyCode: Int, keyEvent: android.view.KeyEvent?): Boolean { + return keyboardActionListener?.onKeyUp(keyCode, keyEvent) ?: false + } + + override fun onStartBatchInput() { keyboardActionListener?.onStartBatchInput() } + override fun onUpdateBatchInput(p: helium314.keyboard.latin.common.InputPointers?) { keyboardActionListener?.onUpdateBatchInput(p) } + override fun onEndBatchInput(p: helium314.keyboard.latin.common.InputPointers?) { keyboardActionListener?.onEndBatchInput(p) } + override fun onCancelBatchInput() { keyboardActionListener?.onCancelBatchInput() } + override fun onCancelInput() { keyboardActionListener?.onCancelInput() } + override fun onFinishSlidingInput() { keyboardActionListener?.onFinishSlidingInput() } + override fun onCustomRequest(requestCode: Int): Boolean { return keyboardActionListener?.onCustomRequest(requestCode) ?: false } + override fun onHorizontalSpaceSwipe(steps: Int): Boolean { return keyboardActionListener?.onHorizontalSpaceSwipe(steps) ?: false } + override fun onVerticalSpaceSwipe(steps: Int): Boolean { return keyboardActionListener?.onVerticalSpaceSwipe(steps) ?: false } + override fun onEndSpaceSwipe() { keyboardActionListener?.onEndSpaceSwipe() } + override fun toggleNumpad(w: Boolean, f: Boolean): Boolean { return keyboardActionListener?.toggleNumpad(w, f) ?: false } + override fun onMoveDeletePointer(steps: Int) { keyboardActionListener?.onMoveDeletePointer(steps) } + override fun onUpWithDeletePointerActive() { keyboardActionListener?.onUpWithDeletePointerActive() } + override fun resetMetaState() { keyboardActionListener?.resetMetaState() } +} diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 8432abc22..363ff4cff 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -192,6 +192,7 @@ fun getCodeForToolbarKey(key: ToolbarKey) = Settings.getInstance().getCustomTool CLIPBOARD -> KeyCode.CLIPBOARD CLIPBOARD_SEARCH -> KeyCode.CLIPBOARD_SEARCH NUMPAD -> KeyCode.NUMPAD + HANDWRITING -> KeyCode.HANDWRITING UNDO -> KeyCode.UNDO REDO -> KeyCode.REDO SETTINGS -> KeyCode.SETTINGS @@ -257,7 +258,7 @@ fun getCodeForToolbarKeyLongClick(key: ToolbarKey) = Settings.getInstance().getC // names need to be aligned with resources strings (using lowercase of key.name) enum class ToolbarKey { - VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, + VOICE, CLIPBOARD, CLIPBOARD_SEARCH, NUMPAD, HANDWRITING, UNDO, REDO, SETTINGS, SELECT_ALL, SELECT_WORD, COPY, CUT, PASTE, ONE_HANDED, SPLIT, FLOATING, INCOGNITO, TOUCHPAD, AUTOCORRECT, CLEAR_CLIPBOARD, CLOSE_HISTORY, EMOJI, LEFT, RIGHT, UP, DOWN, WORD_LEFT, WORD_RIGHT, PAGE_UP, PAGE_DOWN, FULL_LEFT, FULL_RIGHT, PAGE_START, PAGE_END, PROOFREAD, TRANSLATE, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, @@ -283,9 +284,9 @@ private val excludedKeys by lazy { val defaultToolbarPref by lazy { val default = when (helium314.keyboard.latin.BuildConfig.FLAVOR) { - "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) - "offlinelite" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE) - else -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) + "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) + "offlinelite" -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, UNDO, INCOGNITO, COPY, PASTE) + else -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) } val others = entries.filterNot { it in default || it in excludedKeys } diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt new file mode 100644 index 000000000..73f5431a2 --- /dev/null +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-only +package helium314.keyboard.settings.preferences + +import android.content.Intent +import android.widget.Toast +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import helium314.keyboard.latin.R +import helium314.keyboard.latin.handwriting.HandwritingLoader +import helium314.keyboard.settings.FeedbackManager +import helium314.keyboard.settings.dialogs.ConfirmationDialog +import helium314.keyboard.settings.filePicker +import java.io.File +import androidx.annotation.DrawableRes + +@Composable +fun LoadHandwritingPluginPreference( + title: String, + summary: String? = null, + @DrawableRes icon: Int? = null, + onSuccess: (() -> Unit)? = null, +) { + var showDialog by rememberSaveable { mutableStateOf(false) } + val ctx = LocalContext.current + + val hasPlugin = HandwritingLoader.hasPlugin(ctx) + + val launcher = filePicker { uri -> + val success = HandwritingLoader.importPlugin(ctx, uri) + showDialog = false + if (success) { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_success) + onSuccess?.invoke() + } else { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_failed) + } + } + + Preference( + name = title, + description = summary, + icon = icon, + onClick = { showDialog = true } + ) + + if (showDialog) { + ConfirmationDialog( + onDismissRequest = { showDialog = false }, + onConfirmed = { + if (hasPlugin) { + HandwritingLoader.removePlugin(ctx) + FeedbackManager.message(ctx, "Handwriting plugin removed") + onSuccess?.invoke() + showDialog = false + } + }, + confirmButtonText = if (hasPlugin) stringResource(R.string.load_handwriting_plugin_button_delete) else "", + title = { Text(stringResource(R.string.load_handwriting_plugin)) }, + content = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text(stringResource(R.string.load_handwriting_plugin_message)) + } + }, + neutralButtonText = if (!hasPlugin) stringResource(R.string.load_handwriting_plugin_button_load) else null, + onNeutral = { + if (!hasPlugin) { + showDialog = false + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("application/vnd.android.package-archive") + try { + launcher.launch(intent) + } catch (e: Exception) { + try { + intent.type = "*/*" + launcher.launch(intent) + } catch (_: Exception) {} + } + } + } + ) + } +} diff --git a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt index d5fc23b69..4e110f276 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt @@ -27,9 +27,15 @@ import helium314.keyboard.settings.NextScreenIcon import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.preferences.LoadGestureLibPreference import helium314.keyboard.settings.preferences.LoadEmojiLibPreference +import helium314.keyboard.settings.preferences.LoadHandwritingPluginPreference +import helium314.keyboard.latin.handwriting.HandwritingLoader import helium314.keyboard.latin.common.Links import helium314.keyboard.settings.preferences.Preference import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf @Composable fun LibrariesHubScreen( @@ -88,6 +94,15 @@ fun LibrariesHubScreen( icon = R.drawable.ic_emoji_smileys_emotion ) + // Handwriting Input Plugin + var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } + LoadHandwritingPluginPreference( + title = stringResource(R.string.libraries_hub_handwriting_title), + summary = if (handwritingInstalled) stringResource(R.string.libraries_status_active) else stringResource(R.string.libraries_status_not_installed), + icon = R.drawable.ic_edit, + onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) } + ) + // Documentation & Features val uriHandler = LocalUriHandler.current Preference( diff --git a/app/src/main/res/layout/handwriting_view.xml b/app/src/main/res/layout/handwriting_view.xml new file mode 100644 index 000000000..4c6d41baa --- /dev/null +++ b/app/src/main/res/layout/handwriting_view.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_keyboard_frame.xml b/app/src/main/res/layout/main_keyboard_frame.xml index f4ef48811..5ce124c2e 100644 --- a/app/src/main/res/layout/main_keyboard_frame.xml +++ b/app/src/main/res/layout/main_keyboard_frame.xml @@ -35,6 +35,9 @@ + Download complete! Restarting… Download failed: %s + + Load handwriting plugin + Provide an APK plugin to enable handwriting input + Provide a handwriting plugin APK.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust. + Load plugin APK + Delete plugin + Handwriting plugin imported successfully + Failed to load handwriting plugin APK Autospace after punctuation @@ -1030,6 +1038,7 @@ New dictionary: Gesture Typing Library Main Dictionaries Emoji Libraries + Handwriting Input Plugin Dictionary Loading Guide To add a new dictionary:\n1. Obtain a .dict file for your language.\n2. Tap \"Manage Dictionaries\".\n3. Use the plus (+) button to import the file. Active From abd247e4644685adfe023b87bd6b38a499e7466b Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 01:39:27 +0530 Subject: [PATCH 105/118] docs: update 3.8.6 changelog --- fastlane/metadata/android/en-US/changelogs/3860.txt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/fastlane/metadata/android/en-US/changelogs/3860.txt b/fastlane/metadata/android/en-US/changelogs/3860.txt index 331d64987..713f261d9 100644 --- a/fastlane/metadata/android/en-US/changelogs/3860.txt +++ b/fastlane/metadata/android/en-US/changelogs/3860.txt @@ -6,3 +6,9 @@ - Add Blocked Words screen with case-insensitive and regex blacklist support - Align Arabic diacritics spacing and update layout popup options - Fix dictionary crashes and emoji popup preview issues +- Add support for dynamic handwriting input via plugin APK +- Add selective backup and restore options +- Automatically strip spaces before punctuation marks +- Allow personal dictionary shortcut entries for the same word +- Add network settings toggle for insecure HTTP/self-signed AI endpoints +- Add auto-repeat support when holding toolbar arrow keys From dafb6598fc81686be618238af136c36cab206b15 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 01:59:02 +0530 Subject: [PATCH 106/118] fix: use wildcard mime type for file picker to avoid waydroid crash --- .../preferences/LoadHandwritingPluginPreference.kt | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt index 73f5431a2..6c94b779e 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt @@ -75,14 +75,11 @@ fun LoadHandwritingPluginPreference( showDialog = false val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) - .setType("application/vnd.android.package-archive") + .setType("*/*") try { launcher.launch(intent) } catch (e: Exception) { - try { - intent.type = "*/*" - launcher.launch(intent) - } catch (_: Exception) {} + // ignore } } } From 66a1fdd12ea68d320f0ea55a57f3a608815db1b6 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 02:08:33 +0530 Subject: [PATCH 107/118] fix: clear code cache directory on plugin import/remove --- .../keyboard/latin/handwriting/HandwritingLoader.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt index 63a12a414..64c611013 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -49,6 +49,10 @@ object HandwritingLoader { fun importPlugin(context: Context, uri: Uri): Boolean { try { + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} + val apkFile = File(context.filesDir, PLUGIN_FILENAME) context.contentResolver.openInputStream(uri)?.use { input -> apkFile.outputStream().use { output -> @@ -76,6 +80,9 @@ object HandwritingLoader { try { File(context.filesDir, PLUGIN_FILENAME).delete() } catch (_: Exception) {} + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() activeRecognizer = null } @@ -86,6 +93,9 @@ object HandwritingLoader { try { File(context.filesDir, PLUGIN_FILENAME).delete() } catch (_: Exception) {} + try { + context.codeCacheDir.deleteRecursively() + } catch (_: Exception) {} context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() activeRecognizer = null } From 096a7a51b9a726eb142a1373a079129a7491ca9b Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 02:11:14 +0530 Subject: [PATCH 108/118] chore: add MD5 hash and size logging for loaded plugin --- .../keyboard/latin/handwriting/HandwritingLoader.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt index 64c611013..3663ea5ae 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -25,6 +25,15 @@ object HandwritingLoader { return null } + try { + val md5 = java.security.MessageDigest.getInstance("MD5") + val bytes = apkFile.readBytes() + val hash = md5.digest(bytes).joinToString("") { "%02x".format(it) } + Log.i("HandwritingLoader", "Loaded plugin APK path: ${apkFile.absolutePath}, size: ${bytes.size}, md5: $hash") + } catch (e: Exception) { + Log.e("HandwritingLoader", "Failed to calculate MD5", e) + } + try { val classLoader = DexClassLoader( apkFile.absolutePath, From 751b0c6d8d62142c5088a16b9be7dec90cb5476a Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 13:51:23 +0530 Subject: [PATCH 109/118] feat(handwriting): fix crash and dynamic model downloading Move model readiness checks to background thread to prevent main thread blocking exceptions. Add ML Kit client dependencies to standard build flavor for native library alignment. Auto-upgrade toolbar preferences to discover new keys without factory resets. --- app/build.gradle.kts | 11 ++ app/src/main/AndroidManifest.xml | 30 +++- .../keyboard/keyboard/KeyboardSwitcher.java | 2 +- .../main/java/helium314/keyboard/latin/App.kt | 10 +- .../latin/handwriting/HandwritingLoader.kt | 5 + .../latin/handwriting/HandwritingView.kt | 147 ++++++++++++------ .../keyboard/latin/utils/ToolbarUtils.kt | 10 +- .../settings/screens/ToolbarScreen.kt | 10 +- app/src/main/res/values/strings.xml | 1 + .../handwriting/HandwritingLoaderTest.kt | 47 ++++++ 10 files changed, 215 insertions(+), 58 deletions(-) create mode 100644 app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 54b536ac2..d76b29be7 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -241,6 +241,17 @@ dependencies { // Force 16 KB page-aligned version of graphics-path implementation("androidx.graphics:graphics-path:1.1.0") + // WorkManager — required by ML Kit Digital Ink plugin (loaded via DexClassLoader). + // ML Kit internally calls WorkManager.getInstance(context) using the host app context, + // so the host app must have WorkManagerInitializer registered in its manifest. + implementation("androidx.work:work-runtime-ktx:2.10.1") + + // ML Kit Digital Ink Recognition — required by the handwriting plugin. + // ML Kit's internal asset manager and native library loader use the host app context, + // so the host app must compile and include the client library resources/libraries. + "standardImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") + "standardOptimisedImplementation"("com.google.mlkit:digital-ink-recognition:19.0.0") + // test testImplementation(kotlin("test")) testImplementation("junit:junit:4.13.2") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a882c3dc9..579fb4cd7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,7 +8,7 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only - + @@ -120,6 +120,34 @@ SPDX-License-Identifier: Apache-2.0 AND GPL-3.0-only android:resource="@xml/provider_paths" /> + + + + + + + + + + + diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 2fb3d3dbd..4178ac695 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -457,7 +457,7 @@ public void setHandwritingKeyboard() { if (mHandwritingView != null) { final RichInputMethodSubtype subtype = mRichImm.getCurrentSubtype(); - final String language = subtype.getLocale().getLanguage(); + final String language = subtype.getLocale().toLanguageTag(); mHandwritingView.startHandwriting( mLatinIME.getCurrentInputEditorInfo(), mLatinIME.mKeyboardActionListener, diff --git a/app/src/main/java/helium314/keyboard/latin/App.kt b/app/src/main/java/helium314/keyboard/latin/App.kt index 18e6bcbb5..86ad69460 100644 --- a/app/src/main/java/helium314/keyboard/latin/App.kt +++ b/app/src/main/java/helium314/keyboard/latin/App.kt @@ -2,6 +2,7 @@ package helium314.keyboard.latin import android.app.Application +import androidx.work.Configuration import helium314.keyboard.keyboard.emoji.SupportedEmojis import helium314.keyboard.latin.define.DebugFlags import helium314.keyboard.latin.settings.Defaults @@ -10,7 +11,14 @@ import helium314.keyboard.latin.utils.LayoutUtilsCustom import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.utils.SubtypeSettings -class App : Application() { +class App : Application(), Configuration.Provider { + + // WorkManager Configuration.Provider — required for ML Kit Digital Ink plugin. + // The plugin is loaded via DexClassLoader and calls WorkManager.getInstance(context) + // internally. This ensures WorkManager can self-initialize via the Application. + override val workManagerConfiguration: Configuration + get() = Configuration.Builder().build() + override fun onCreate() { super.onCreate() DebugFlags.init(this) diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt index 3663ea5ae..049949852 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -24,6 +24,7 @@ object HandwritingLoader { context.prefs().edit().putBoolean(PREF_HAS_PLUGIN, false).apply() return null } + apkFile.setReadOnly() try { val md5 = java.security.MessageDigest.getInstance("MD5") @@ -63,11 +64,15 @@ object HandwritingLoader { } catch (_: Exception) {} val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (apkFile.exists()) { + apkFile.delete() + } context.contentResolver.openInputStream(uri)?.use { input -> apkFile.outputStream().use { output -> input.copyTo(output) } } + apkFile.setReadOnly() // Verify the plugin loads successfully val classLoader = DexClassLoader( diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt index d8cbaa535..9486c821b 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -89,6 +89,41 @@ class HandwritingView @JvmOverloads constructor( } clearCanvasAndComposition() + + val recognizer = HandwritingLoader.getRecognizer(context) + if (recognizer != null) { + recognizer.setLanguage(language) + recognitionExecutor.execute { + val isReady = recognizer.isLanguageReady(language) + mainHandler.post { + if (!isReady) { + languageLabel.text = "$language (Downloading...)" + recognizer.downloadModel(language, object : ModelDownloadListener { + override fun onProgress(progress: Float) { + mainHandler.post { + languageLabel.text = "$language (Downloading ${"%.0f".format(progress * 100)}%)" + } + } + override fun onComplete(success: Boolean) { + mainHandler.post { + if (success) { + languageLabel.text = language + android.widget.Toast.makeText(context, "Handwriting model downloaded", android.widget.Toast.LENGTH_SHORT).show() + } else { + languageLabel.text = "$language (Download failed)" + android.widget.Toast.makeText(context, "Failed to download handwriting model", android.widget.Toast.LENGTH_LONG).show() + } + } + } + }) + } else { + languageLabel.text = language + } + } + } + } else { + android.widget.Toast.makeText(context, "Please load handwriting plugin in Settings -> Libraries hub", android.widget.Toast.LENGTH_LONG).show() + } } fun stopHandwriting() { @@ -126,62 +161,74 @@ class HandwritingView @JvmOverloads constructor( } } + private val recognitionExecutor = java.util.concurrent.Executors.newSingleThreadExecutor() + private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + private fun performRecognition(strokes: List) { if (strokes.isEmpty()) return val recognizer = HandwritingLoader.getRecognizer(context) ?: return - - // Ensure language is set - recognizer.setLanguage(currentLanguage) - - val results = recognizer.recognize(strokes) - if (results.isNullOrEmpty()) return - val mainCandidate = results[0] - currentComposingText = mainCandidate + // setLanguage is fast (no blocking I/O), safe on main thread + recognizer.setLanguage(currentLanguage) - val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return - val ic = latinIME.currentInputConnection ?: return - - // Update composing text - ic.setComposingText(mainCandidate, 1) - - // Populate suggestion strip with alternative candidates - val suggestionInfos = ArrayList() - for (word in results) { - suggestionInfos.add( - SuggestedWordInfo( - word, - "", - SuggestedWordInfo.MAX_SCORE, - SuggestedWordInfo.KIND_TYPED, - Dictionary.DICTIONARY_USER_TYPED, - SuggestedWordInfo.NOT_AN_INDEX, - SuggestedWordInfo.NOT_A_CONFIDENCE - ) - ) + // recognize() uses Tasks.await() which must not run on main thread + recognitionExecutor.execute { + try { + val results = recognizer.recognize(strokes) + if (results.isNullOrEmpty()) return@execute + + mainHandler.post { + val mainCandidate = results[0] + currentComposingText = mainCandidate + + val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return@post + val ic = latinIME.currentInputConnection ?: return@post + + // Update composing text + ic.setComposingText(mainCandidate, 1) + + // Populate suggestion strip with alternative candidates + val suggestionInfos = ArrayList() + for (word in results) { + suggestionInfos.add( + SuggestedWordInfo( + word, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + ) + } + + val typedWordInfo = SuggestedWordInfo( + mainCandidate, + "", + SuggestedWordInfo.MAX_SCORE, + SuggestedWordInfo.KIND_TYPED, + Dictionary.DICTIONARY_USER_TYPED, + SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWordInfo.NOT_A_CONFIDENCE + ) + + val suggestedWords = SuggestedWords( + suggestionInfos, + null, + typedWordInfo, + false, + false, + false, + SuggestedWords.INPUT_STYLE_TYPING, + SuggestedWords.NOT_A_SEQUENCE_NUMBER + ) + latinIME.setSuggestions(suggestedWords) + } + } catch (e: Exception) { + Log.e("HandwritingView", "Error during recognition", e) + } } - - val typedWordInfo = SuggestedWordInfo( - mainCandidate, - "", - SuggestedWordInfo.MAX_SCORE, - SuggestedWordInfo.KIND_TYPED, - Dictionary.DICTIONARY_USER_TYPED, - SuggestedWordInfo.NOT_AN_INDEX, - SuggestedWordInfo.NOT_A_CONFIDENCE - ) - - val suggestedWords = SuggestedWords( - suggestionInfos, - null, - typedWordInfo, - false, - false, - false, - SuggestedWords.INPUT_STYLE_TYPING, - SuggestedWords.NOT_A_SEQUENCE_NUMBER - ) - latinIME.setSuggestions(suggestedWords) } // Intercept KeyboardActionListener events for the bottom row diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index 363ff4cff..e8585f53e 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -321,12 +321,13 @@ fun upgradeToolbarPrefs(prefs: SharedPreferences) { private fun upgradeToolbarPref(prefs: SharedPreferences, pref: String, default: String) { if (!prefs.contains(pref)) return - val list = prefs.getString(pref, default)!!.split(Separators.ENTRY).toMutableList() + val originalString = prefs.getString(pref, default)!! + val list = originalString.split(Separators.ENTRY).toMutableList() val splitDefault = default.split(Separators.ENTRY) splitDefault.forEach { entry -> val keyWithSeparator = entry.substringBefore(Separators.KV) + Separators.KV if (list.none { it.startsWith(keyWithSeparator) }) - list.add("${keyWithSeparator}false") + list.add(entry) } // likely not needed, but better prepare for possibility of key removal list.removeAll { @@ -337,7 +338,10 @@ private fun upgradeToolbarPref(prefs: SharedPreferences, pref: String, default: true } } - prefs.edit { putString(pref, list.joinToString(Separators.ENTRY)) } + val newString = list.joinToString(Separators.ENTRY) + if (newString != originalString) { + prefs.edit { putString(pref, newString) } + } } fun getEnabledToolbarKeys(prefs: SharedPreferences) = getEnabledToolbarKeys(prefs, Settings.PREF_TOOLBAR_KEYS, defaultToolbarPref) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index e86fe7958..eb7aca6c2 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -38,6 +39,7 @@ import helium314.keyboard.latin.utils.dpToPx import helium314.keyboard.latin.utils.getActivity import helium314.keyboard.latin.utils.getStringResourceOrName import helium314.keyboard.latin.utils.prefs +import helium314.keyboard.latin.utils.upgradeToolbarPrefs import helium314.keyboard.settings.SearchSettingsScreen import helium314.keyboard.settings.Setting import helium314.keyboard.settings.SettingsActivity @@ -54,8 +56,12 @@ import helium314.keyboard.settings.previewDark fun ToolbarScreen( onClickBack: () -> Unit, ) { - val prefs = LocalContext.current.prefs() - val b = (LocalContext.current.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState() + val context = LocalContext.current + val prefs = context.prefs() + LaunchedEffect(Unit) { + upgradeToolbarPrefs(prefs) + } + val b = (context.getActivity() as? SettingsActivity)?.prefChanged?.collectAsState() if ((b?.value ?: 0) < 0) Log.v("irrelevant", "stupid way to trigger recomposition on preference change") val toolbarMode = Settings.readToolbarMode(prefs) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0e978242e..2a13a9906 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -299,6 +299,7 @@ Delete plugin Handwriting plugin imported successfully Failed to load handwriting plugin APK + Handwriting Autospace after punctuation diff --git a/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt b/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt new file mode 100644 index 000000000..f6fd34416 --- /dev/null +++ b/app/src/test/java/helium314/keyboard/latin/handwriting/HandwritingLoaderTest.kt @@ -0,0 +1,47 @@ +package helium314.keyboard.latin.handwriting + +import android.content.Context +import android.net.Uri +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +class HandwritingLoaderTest { + + private lateinit var context: Context + private lateinit var testApk: File + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + testApk = File(context.cacheDir, "test_plugin.apk") + testApk.writeText("dummy content for handwriting plugin") + } + + @After + fun tearDown() { + testApk.delete() + HandwritingLoader.removePlugin(context) + } + + @Test + fun testImportCleanupOnInvalidApk() { + // Initially no plugin + assertFalse(HandwritingLoader.hasPlugin(context)) + + // Import invalid apk + val uri = Uri.fromFile(testApk) + val result = HandwritingLoader.importPlugin(context, uri) + assertFalse(result) // Must fail because dummy text is not a valid DEX/APK with the class + + // Verify the file was cleaned up on failure + val apkFile = File(context.filesDir, "handwriting_plugin.apk") + assertFalse(apkFile.exists()) + } +} From c41288a0e4b953fbcc628a0b3d9b1110ab25edfe Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 21:28:39 +0530 Subject: [PATCH 110/118] feat: fix handwriting layout, theming and logic --- .../handwriting_bottom_row.json | 8 ++++ .../handwriting_bottom_row_with_action.json | 9 +++++ .../keyboard/keyboard/KeyboardId.java | 6 ++- .../keyboard/keyboard/KeyboardSwitcher.java | 2 + .../keyboard/internal/KeyboardIconsSet.kt | 1 + .../keyboard_parser/KeyboardParser.kt | 1 + .../keyboard_parser/floris/KeyCode.kt | 3 +- .../keyboard_parser/floris/KeyLabel.kt | 2 + .../latin/handwriting/HandwritingView.kt | 40 ++++++++++++++++++- .../keyboard/latin/settings/Defaults.kt | 1 + .../keyboard/latin/utils/LayoutType.kt | 3 +- app/src/main/res/layout/handwriting_view.xml | 8 +++- 12 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json create mode 100644 app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json diff --git a/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json new file mode 100644 index 000000000..8cb127cfb --- /dev/null +++ b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row.json @@ -0,0 +1,8 @@ +[ + [ + { "label": "alpha", "width": 0.15 }, + { "label": "clear_handwriting", "width": 0.15 }, + { "label": "space", "width": -1 }, + { "label": "delete", "width": 0.15 } + ] +] diff --git a/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json new file mode 100644 index 000000000..f83af57a2 --- /dev/null +++ b/app/src/main/assets/layouts/handwriting_bottom/handwriting_bottom_row_with_action.json @@ -0,0 +1,9 @@ +[ + [ + { "label": "alpha", "width": 0.15 }, + { "label": "clear_handwriting", "width": 0.15 }, + { "label": "space", "width": -1 }, + { "label": "delete", "width": 0.15 }, + { "label": "action", "width": 0.15 } + ] +] diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java index 016fe5a2e..7429f5d01 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardId.java @@ -67,6 +67,7 @@ public final class KeyboardId { public static final int ELEMENT_NUMPAD = 28; public static final int ELEMENT_EMOJI_BOTTOM_ROW = 29; public static final int ELEMENT_CLIPBOARD_BOTTOM_ROW = 30; + public static final int ELEMENT_HANDWRITING_BOTTOM_ROW = 31; public final RichInputMethodSubtype mSubtype; public final int mWidth; @@ -208,7 +209,7 @@ public boolean isEmojiKeyboard() { } public boolean isEmojiClipBottomRow() { - return mElementId == ELEMENT_CLIPBOARD_BOTTOM_ROW || mElementId == ELEMENT_EMOJI_BOTTOM_ROW; + return mElementId == ELEMENT_CLIPBOARD_BOTTOM_ROW || mElementId == ELEMENT_EMOJI_BOTTOM_ROW || mElementId == ELEMENT_HANDWRITING_BOTTOM_ROW; } public int imeAction() { @@ -290,6 +291,9 @@ public static String elementIdToName(final int elementId) { case ELEMENT_EMOJI_CATEGORY16 -> "emojiCategory16"; case ELEMENT_CLIPBOARD -> "clipboard"; case ELEMENT_NUMPAD -> "numpad"; + case ELEMENT_EMOJI_BOTTOM_ROW -> "emojiBottomRow"; + case ELEMENT_CLIPBOARD_BOTTOM_ROW -> "clipboardBottomRow"; + case ELEMENT_HANDWRITING_BOTTOM_ROW -> "handwritingBottomRow"; default -> null; }; } diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 4178ac695..4a71e87ea 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -816,6 +816,8 @@ public View getVisibleKeyboardView() { return mEmojiPalettesView; } else if (isShowingClipboardHistory()) { return mClipboardHistoryView; + } else if (isHandwritingShowing()) { + return mHandwritingView; } return mKeyboardView; } diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt index df440958b..eaae6d7f7 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/KeyboardIconsSet.kt @@ -44,6 +44,7 @@ class KeyboardIconsSet private constructor() { } val baseIds = defaultIds.toMutableMap().apply { put(ToolbarKey.CLEAR_CLIPBOARD.name.lowercase(Locale.US), clearClipboardResId) + put("clear_handwriting", R.drawable.ic_close) } val overrideIds = customIconIds(context, prefs) val ids = if (overrideIds.isEmpty()) baseIds else baseIds + overrideIds diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt index 083abb1ae..40d4a91c1 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/KeyboardParser.kt @@ -57,6 +57,7 @@ class KeyboardParser(private val params: KeyboardParams, private val context: Co LayoutType.NUMPAD_LANDSCAPE else LayoutType.NUMPAD KeyboardId.ELEMENT_EMOJI_BOTTOM_ROW -> LayoutType.EMOJI_BOTTOM KeyboardId.ELEMENT_CLIPBOARD_BOTTOM_ROW -> LayoutType.CLIPBOARD_BOTTOM + KeyboardId.ELEMENT_HANDWRITING_BOTTOM_ROW -> LayoutType.HANDWRITING_BOTTOM else -> LayoutType.MAIN } val baseKeys = LayoutParser.parseLayout(layoutType, params, context) diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt index e308a8cf5..6a97766bb 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyCode.kt @@ -194,6 +194,7 @@ object KeyCode { const val CUSTOM_AI_10 = -10070 const val CLIPBOARD_SEARCH = -10071 const val HANDWRITING = -10074 + const val CLEAR_HANDWRITING = -10075 // Intents @@ -219,7 +220,7 @@ object KeyCode { TIMESTAMP, CTRL_LEFT, CTRL_RIGHT, ALT_LEFT, ALT_RIGHT, META_LEFT, META_RIGHT, SEND_INTENT_ONE, SEND_INTENT_TWO, SEND_INTENT_THREE, INLINE_EMOJI_SEARCH_DONE, META_LOCK, PROOFREAD, TRANSLATE, SHOW_TRANSLATE_LANGUAGES, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, CUSTOM_AI_4, CUSTOM_AI_5, - CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, HANDWRITING + CUSTOM_AI_6, CUSTOM_AI_7, CUSTOM_AI_8, CUSTOM_AI_9, CUSTOM_AI_10, CLIPBOARD_SEARCH, TOGGLE_FLOATING_KEYBOARD, TOGGLE_TOUCHPAD_MODE, HANDWRITING, CLEAR_HANDWRITING -> this // conversion diff --git a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt index a473255c8..9b1e8a242 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt +++ b/app/src/main/java/helium314/keyboard/keyboard/internal/keyboard_parser/floris/KeyLabel.kt @@ -89,6 +89,7 @@ object KeyLabel { fun keyLabelToActualLabel(label: String, params: KeyboardParams): String { val newLabel = when (label) { + "clear_handwriting" -> "!icon/clear_handwriting" SYMBOL_ALPHA -> if (params.mId.isAlphabetKeyboard) params.mLocaleKeyboardInfos.labelSymbol else params.mLocaleKeyboardInfos.labelAlphabet SYMBOL -> params.mLocaleKeyboardInfos.labelSymbol ALPHA -> params.mLocaleKeyboardInfos.labelAlphabet @@ -115,6 +116,7 @@ object KeyLabel { else label } val code = when (label) { // maybe a bit lazy to not assemble the entire string above + "clear_handwriting" -> KeyCode.CLEAR_HANDWRITING SYMBOL_ALPHA -> KeyCode.SYMBOL_ALPHA SYMBOL -> KeyCode.SYMBOL ALPHA -> KeyCode.ALPHA diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt index 9486c821b..7024b43eb 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -17,6 +17,7 @@ import helium314.keyboard.latin.R import helium314.keyboard.latin.RichInputConnection import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode import helium314.keyboard.latin.common.Constants +import helium314.keyboard.latin.common.ColorType import helium314.keyboard.latin.settings.Settings import helium314.keyboard.latin.utils.Log import helium314.keyboard.latin.SuggestedWords @@ -25,6 +26,8 @@ import helium314.keyboard.latin.dictionary.Dictionary import android.view.inputmethod.EditorInfo import helium314.keyboard.keyboard.KeyboardSwitcher import helium314.keyboard.event.HapticEvent +import helium314.keyboard.latin.RichInputMethodManager +import helium314.keyboard.latin.utils.LanguageOnSpacebarUtils class HandwritingView @JvmOverloads constructor( context: Context, @@ -61,6 +64,7 @@ class HandwritingView @JvmOverloads constructor( canvas.onRecognitionTriggered = { strokes -> performRecognition(strokes) + canvas.clear() } } @@ -73,6 +77,17 @@ class HandwritingView @JvmOverloads constructor( this.keyboardActionListener = keyboardActionListener this.currentLanguage = language + val colors = Settings.getValues().mColors + val toolbar = findViewById(R.id.handwriting_toolbar) + if (toolbar != null) { + colors.setBackground(toolbar, ColorType.MAIN_BACKGROUND) + } + colors.setBackground(canvas, ColorType.MAIN_BACKGROUND) + + languageLabel.setTextColor(colors.get(ColorType.KEY_TEXT)) + colors.setColor(clearButton, ColorType.KEY_ICON) + canvas.setStrokeColor(colors.get(ColorType.KEY_TEXT)) + languageLabel.text = language // Setup bottom row keyboard @@ -82,8 +97,16 @@ class HandwritingView @JvmOverloads constructor( try { PointerTracker.switchTo(bottomRowKeyboard) val kls = KeyboardLayoutSet.Builder.buildEmojiClipBottomRow(context, editorInfo) - val keyboard = kls.getKeyboard(KeyboardId.ELEMENT_EMOJI_BOTTOM_ROW) + val keyboard = kls.getKeyboard(KeyboardId.ELEMENT_HANDWRITING_BOTTOM_ROW) bottomRowKeyboard.setKeyboard(keyboard) + + val languageOnSpacebarFormatType = LanguageOnSpacebarUtils.getLanguageOnSpacebarFormatType(keyboard.mId.mSubtype) + val hasMultipleEnabledIMEsOrSubtypes = RichInputMethodManager.getInstance().hasMultipleEnabledIMEsOrSubtypes(true) + bottomRowKeyboard.startDisplayLanguageOnSpacebar( + true, + languageOnSpacebarFormatType, + hasMultipleEnabledIMEsOrSubtypes + ) } catch (e: Exception) { Log.e("HandwritingView", "Failed to setup bottom row keyboard", e) } @@ -179,11 +202,20 @@ class HandwritingView @JvmOverloads constructor( mainHandler.post { val mainCandidate = results[0] - currentComposingText = mainCandidate val latinIME = KeyboardSwitcher.getInstance().latinIME ?: return@post val ic = latinIME.currentInputConnection ?: return@post + if (currentComposingText.isNotEmpty()) { + ic.finishComposingText() + val textBefore = ic.getTextBeforeCursor(1, 0) + if (textBefore != null && textBefore.isNotEmpty() && textBefore != " " && textBefore != "\n") { + ic.commitText(" ", 1) + } + } + + currentComposingText = mainCandidate + // Update composing text ic.setComposingText(mainCandidate, 1) @@ -238,6 +270,10 @@ class HandwritingView @JvmOverloads constructor( KeyboardSwitcher.getInstance().setAlphabetKeyboard() return } + if (primaryCode == KeyCode.CLEAR_HANDWRITING) { + clearCanvasAndComposition() + return + } // For other keys, commit the composition first when relevant if (primaryCode == Constants.CODE_SPACE || primaryCode == Constants.CODE_ENTER) { diff --git a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt index 6b1e77ba9..198fbf648 100644 --- a/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt +++ b/app/src/main/java/helium314/keyboard/latin/settings/Defaults.kt @@ -38,6 +38,7 @@ object Defaults { LayoutType.PHONE_SYMBOLS -> "phone_symbols" LayoutType.EMOJI_BOTTOM -> "emoji_bottom_row" LayoutType.CLIPBOARD_BOTTOM -> "clip_bottom_row" + LayoutType.HANDWRITING_BOTTOM -> "handwriting_bottom_row" } const val PREF_SPLIT_TOOLBAR = false diff --git a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt index 00db72c87..6f0173899 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/LayoutType.kt @@ -8,7 +8,7 @@ import java.util.EnumMap enum class LayoutType { MAIN, SYMBOLS, MORE_SYMBOLS, FUNCTIONAL, NUMBER, NUMBER_ROW, NUMPAD, - NUMPAD_LANDSCAPE, PHONE, PHONE_SYMBOLS, EMOJI_BOTTOM, CLIPBOARD_BOTTOM; + NUMPAD_LANDSCAPE, PHONE, PHONE_SYMBOLS, EMOJI_BOTTOM, CLIPBOARD_BOTTOM, HANDWRITING_BOTTOM; companion object { fun EnumMap.toExtraValue() = map { it.key.name + Separators.KV + it.value }.joinToString(Separators.ENTRY) @@ -37,6 +37,7 @@ enum class LayoutType { PHONE_SYMBOLS -> R.string.layout_phone_symbols EMOJI_BOTTOM -> R.string.layout_emoji_bottom_row CLIPBOARD_BOTTOM -> R.string.layout_clip_bottom_row + HANDWRITING_BOTTOM -> R.string.layout_emoji_bottom_row } fun getMainLayoutFromExtraValue(extraValue: String): String? { diff --git a/app/src/main/res/layout/handwriting_view.xml b/app/src/main/res/layout/handwriting_view.xml index 4c6d41baa..e2482d316 100644 --- a/app/src/main/res/layout/handwriting_view.xml +++ b/app/src/main/res/layout/handwriting_view.xml @@ -10,12 +10,15 @@ From 714d679b0071fb8305eb09b651bc9c4780c619cf Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 21:56:54 +0530 Subject: [PATCH 111/118] style: change handwriting toolbar icon color to white --- app/src/main/res/drawable/ic_edit.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/drawable/ic_edit.xml b/app/src/main/res/drawable/ic_edit.xml index b88909504..64aa3961a 100644 --- a/app/src/main/res/drawable/ic_edit.xml +++ b/app/src/main/res/drawable/ic_edit.xml @@ -8,6 +8,6 @@ android:viewportWidth="24" android:viewportHeight="24"> \ No newline at end of file From 57f47cba4e8f74f744a9f0d7ef5481cedb052c47 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Tue, 16 Jun 2026 22:18:36 +0530 Subject: [PATCH 112/118] feat: show shortcut overlay on handwriting canvas when plugin missing --- .../latin/handwriting/HandwritingView.kt | 43 +++++++++++- app/src/main/res/layout/handwriting_view.xml | 70 +++++++++++++++++-- app/src/main/res/values/strings.xml | 3 + 3 files changed, 110 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt index 7024b43eb..39a6227e3 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingView.kt @@ -5,8 +5,10 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.ImageButton +import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView +import android.graphics.drawable.GradientDrawable import helium314.keyboard.keyboard.KeyboardActionListener import helium314.keyboard.keyboard.KeyboardId import helium314.keyboard.keyboard.KeyboardLayoutSet @@ -113,6 +115,45 @@ class HandwritingView @JvmOverloads constructor( clearCanvasAndComposition() + val hasPlugin = HandwritingLoader.hasPlugin(context) + val overlay = findViewById(R.id.handwriting_plugin_overlay) + if (!hasPlugin) { + overlay?.visibility = View.VISIBLE + val titleText = findViewById(R.id.handwriting_plugin_title) + val summaryText = findViewById(R.id.handwriting_plugin_summary) + val iconView = findViewById(R.id.handwriting_plugin_icon) + val button = findViewById(R.id.handwriting_plugin_button) + + if (titleText != null) titleText.setTextColor(colors.get(ColorType.KEY_TEXT)) + if (summaryText != null) summaryText.setTextColor(colors.get(ColorType.KEY_HINT_TEXT)) + if (iconView != null) colors.setColor(iconView, ColorType.KEY_ICON) + + if (button != null) { + val btnBackground = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = 8f * context.resources.displayMetrics.density + setColor(colors.get(ColorType.ACTION_KEY_BACKGROUND)) + } + button.background = btnBackground + button.setTextColor(colors.get(ColorType.KEY_TEXT)) + + button.setOnClickListener { + val intent = android.content.Intent() + intent.setClass(context, helium314.keyboard.settings.SettingsActivity2::class.java) + intent.putExtra("screen", helium314.keyboard.settings.SettingsDestination.Libraries) + intent.flags = android.content.Intent.FLAG_ACTIVITY_NEW_TASK or android.content.Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED or android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP + try { + context.startActivity(intent) + } catch (e: Exception) { + Log.e("HandwritingView", "Failed to start settings activity", e) + } + KeyboardSwitcher.getInstance().latinIME?.requestHideSelf(0) + } + } + } else { + overlay?.visibility = View.GONE + } + val recognizer = HandwritingLoader.getRecognizer(context) if (recognizer != null) { recognizer.setLanguage(language) @@ -144,8 +185,6 @@ class HandwritingView @JvmOverloads constructor( } } } - } else { - android.widget.Toast.makeText(context, "Please load handwriting plugin in Settings -> Libraries hub", android.widget.Toast.LENGTH_LONG).show() } } diff --git a/app/src/main/res/layout/handwriting_view.xml b/app/src/main/res/layout/handwriting_view.xml index e2482d316..86b475c83 100644 --- a/app/src/main/res/layout/handwriting_view.xml +++ b/app/src/main/res/layout/handwriting_view.xml @@ -41,12 +41,74 @@ android:contentDescription="Clear" /> - + android:layout_weight="1"> + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2a13a9906..e1913b6b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -300,6 +300,9 @@ Handwriting plugin imported successfully Failed to load handwriting plugin APK Handwriting + Handwriting plugin required + Please load the handwriting plugin library to enable drawing recognition. + Load Plugin Autospace after punctuation From 7b04398940f6324a26908318497672f589d7dbee Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 20 Jun 2026 19:14:45 +0530 Subject: [PATCH 113/118] fix(ai): resolve offline custom key token loss Prevent token loss and hallucination in local models due to formatting and JNI bugs. --- .../keyboard/emoji/EmojiPalettesView.java | 1 + .../latin/DictionaryFacilitatorImpl.kt | 19 ++++++++- .../helium314/keyboard/latin/LatinIME.java | 4 +- .../keyboard/latin/inputlogic/InputLogic.java | 40 +++++++++++-------- .../keyboard/latin/utils/ToolbarUtils.kt | 13 +++--- .../settings/screens/AIIntegrationScreen.kt | 1 + .../settings/screens/AdvancedScreen.kt | 2 +- .../settings/screens/LibrariesHubScreen.kt | 17 ++++---- .../settings/screens/ToolbarScreen.kt | 3 +- .../keyboard/latin/utils/ProofreadService.kt | 11 ++++- 10 files changed, 76 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java index 1cab99930..a7bd00aef 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java +++ b/app/src/main/java/helium314/keyboard/keyboard/emoji/EmojiPalettesView.java @@ -311,6 +311,7 @@ public void initialize() { // needs to be delayed for access to EmojiTabStrip, w androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL, false)); mSearchAdapter = new EmojiSearchAdapter(emoji -> { mKeyboardActionListener.onTextInput(emoji); + addRecentKey(emoji); // Optionally close search or keep it open for multiple inputs? // restore standard behavior: stop search stopSearchMode(); diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index bcf618162..920d4abd8 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -62,6 +62,7 @@ import java.util.concurrent.TimeUnit @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class DictionaryFacilitatorImpl : DictionaryFacilitator { private var mPrefs: SharedPreferences? = null + private var mContext: Context? = null private var mEnabledDictionariesState: Map = emptyMap() private var dictionaryGroups = listOf(DictionaryGroup()) @@ -78,6 +79,9 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { private var changeFrom = "" private var changeTo = "" + private var mLoadedSuggestEmojis: Boolean = false + private var mLoadedEmojiDictExists: Boolean = false + private val SPELLING_DICTIONARY_TYPES = arrayOf( Dictionary.TYPE_MAIN, Dictionary.TYPE_CONTACTS, @@ -144,6 +148,12 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { return false } } + val ctx = mContext ?: return false + val currentSuggestEmojis = Settings.getValues().mSuggestEmojis + val currentEmojiDictExists = locales.any { helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(it, Dictionary.TYPE_EMOJI, ctx) != null } + if (currentSuggestEmojis != mLoadedSuggestEmojis || currentEmojiDictExists != mLoadedEmojiDictExists) { + return false + } val dictGroup = dictionaryGroups[0] // settings are the same for all groups return contacts == dictGroup.hasDict(Dictionary.TYPE_CONTACTS) && apps == dictGroup.hasDict(Dictionary.TYPE_APPS) @@ -165,6 +175,7 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { listener: DictionaryInitializationListener? ) { Log.i(TAG, "resetDictionaries, force reloading main dictionary: $forceReloadMainDictionary") + mContext = context.applicationContext val prefs = context.prefs() mPrefs = prefs mEnabledDictionariesState = prefs.all.filterKeys { it.startsWith("pref_dict_enabled_") } @@ -237,7 +248,11 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { // create new or re-use already loaded main dict val mainDict: Dictionary? - if (forceReload || oldDictGroupForLocale == null + val currentSuggestEmojis = Settings.getValues().mSuggestEmojis + val currentEmojiDictExists = helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(locale, Dictionary.TYPE_EMOJI, context) != null + val forceReloadMain = forceReload || (currentSuggestEmojis != mLoadedSuggestEmojis) || (currentEmojiDictExists != mLoadedEmojiDictExists) + + if (forceReloadMain || oldDictGroupForLocale == null || !oldDictGroupForLocale.hasDict(Dictionary.TYPE_MAIN) ) { mainDict = null // null main dicts will be loaded later in asyncReloadUninitializedMainDictionaries @@ -276,6 +291,8 @@ class DictionaryFacilitatorImpl : DictionaryFacilitator { scope.launch { try { val useEmojiDict = Settings.getValues().mSuggestEmojis + mLoadedSuggestEmojis = useEmojiDict + mLoadedEmojiDictExists = locales.any { helium314.keyboard.latin.utils.DictionaryInfoUtils.getCachedDictForLocaleAndType(it, Dictionary.TYPE_EMOJI, context) != null } val dictGroupsWithNewMainDict = locales.mapNotNull { val dictionaryGroup = findDictionaryGroupWithLocale(dictionaryGroups, it) if (dictionaryGroup == null) { diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 3a320ea9f..2cda903c3 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1714,8 +1714,8 @@ public void pickSuggestionManually(final SuggestedWordInfo suggestionInfo) { mKeyboardSwitcher.clearHandwritingCanvas(); } - if (suggestionInfo.mSourceDict != null && helium314.keyboard.latin.dictionary.Dictionary.TYPE_EMOJI - .equals(suggestionInfo.mSourceDict.mDictType)) { + if (suggestionInfo.isEmoji() || (suggestionInfo.mSourceDict != null && helium314.keyboard.latin.dictionary.Dictionary.TYPE_EMOJI + .equals(suggestionInfo.mSourceDict.mDictType))) { final helium314.keyboard.keyboard.emoji.EmojiPalettesView emojiView = mKeyboardSwitcher .getEmojiPalettesView(); if (emojiView != null) { diff --git a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java index 5e1ba07f2..f26a08a92 100644 --- a/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java +++ b/app/src/main/java/helium314/keyboard/latin/inputlogic/InputLogic.java @@ -3489,35 +3489,43 @@ private void handleCustomAIKey(int index) { final android.content.SharedPreferences prefs = helium314.keyboard.latin.utils.DeviceProtectedUtils .getSharedPreferences(mLatinIME); String prompt = prefs.getString("pref_custom_ai_prompt_" + index, ""); - String systemInstruction = ""; + StringBuilder systemInstructionBuilder = new StringBuilder(); boolean shouldAppend = false; // Keyword parsing for system instructions / personas if (prompt.contains("#editor")) { - systemInstruction = " You are a text editor tool. Output ONLY the edited text. Do not add any conversational filler."; + systemInstructionBuilder.append(" You are a text editor tool. Output ONLY the edited text. Do not add any conversational filler."); prompt = prompt.replace("#editor", "").trim(); - } else if (prompt.contains("#outputonly")) { - systemInstruction = " Output ONLY the result. Do not add introductions or explanations."; + } + if (prompt.contains("#outputonly")) { + systemInstructionBuilder.append(" Output ONLY the result. Do not add introductions or explanations."); prompt = prompt.replace("#outputonly", "").trim(); - } else if (prompt.contains("#proofread")) { - systemInstruction = " You are a proofreader. Fix grammar and spelling errors. Output ONLY the fixed text."; + } + if (prompt.contains("#proofread")) { + systemInstructionBuilder.append(" You are a proofreader. Fix grammar and spelling errors. Output ONLY the fixed text."); prompt = prompt.replace("#proofread", "").trim(); - } else if (prompt.contains("#paraphrase")) { - systemInstruction = " You are a paraphrasing tool. Rewrite the text using different words while keeping the meaning. Output ONLY the result."; + } + if (prompt.contains("#paraphrase")) { + systemInstructionBuilder.append(" You are a paraphrasing tool. Rewrite the text using different words while keeping the meaning. Output ONLY the result."); prompt = prompt.replace("#paraphrase", "").trim(); - } else if (prompt.contains("#summarize")) { - systemInstruction = " You are a summarizer. Provide a concise summary of the text. Output ONLY the summary."; + } + if (prompt.contains("#summarize")) { + systemInstructionBuilder.append(" You are a summarizer. Provide a concise summary of the text. Output ONLY the summary."); prompt = prompt.replace("#summarize", "").trim(); - } else if (prompt.contains("#expand")) { - systemInstruction = " You are a creative writing assistant. Expand on the text with more details. Output ONLY the result."; + } + if (prompt.contains("#expand")) { + systemInstructionBuilder.append(" You are a creative writing assistant. Expand on the text with more details. Output ONLY the result."); prompt = prompt.replace("#expand", "").trim(); - } else if (prompt.contains("#toneshift")) { - systemInstruction = " You are a tone modifier. Adjust the tone as requested. Output ONLY the result."; + } + if (prompt.contains("#toneshift")) { + systemInstructionBuilder.append(" You are a tone modifier. Adjust the tone as requested. Output ONLY the result."); prompt = prompt.replace("#toneshift", "").trim(); - } else if (prompt.contains("#generate")) { - systemInstruction = " You are a creative content generator. Output ONLY the generated content."; + } + if (prompt.contains("#generate")) { + systemInstructionBuilder.append(" You are a creative content generator. Output ONLY the generated content."); prompt = prompt.replace("#generate", "").trim(); } + String systemInstruction = systemInstructionBuilder.toString(); // Input handling keywords if (prompt.contains("#append")) { diff --git a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt index e8585f53e..380e16781 100644 --- a/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt +++ b/app/src/main/java/helium314/keyboard/latin/utils/ToolbarUtils.kt @@ -272,11 +272,13 @@ enum class ToolbarMode { val toolbarKeyStrings = entries.associateWithTo(EnumMap(ToolbarKey::class.java)) { it.toString().lowercase(Locale.US) } private val excludedKeys by lazy { - val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised") + val customAiKeys = if (BuildConfig.FLAVOR != "standard" && BuildConfig.FLAVOR != "standardOptimised" && BuildConfig.FLAVOR != "offline") ToolbarKey.entries.filter { it.name.startsWith("CUSTOM_AI_") } else emptyList() val otherKeys = if (BuildConfig.FLAVOR == "offlinelite") - listOf(CLOSE_HISTORY, PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH) + listOf(CLOSE_HISTORY, PROOFREAD, TRANSLATE, CLIPBOARD_SEARCH, HANDWRITING) + else if (BuildConfig.FLAVOR == "offline") + listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH, HANDWRITING) else listOf(CLOSE_HISTORY, CLIPBOARD_SEARCH) customAiKeys + otherKeys @@ -284,8 +286,8 @@ private val excludedKeys by lazy { val defaultToolbarPref by lazy { val default = when (helium314.keyboard.latin.BuildConfig.FLAVOR) { - "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) - "offlinelite" -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, UNDO, INCOGNITO, COPY, PASTE) + "offline" -> listOf(SETTINGS, VOICE, CLIPBOARD, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, INCOGNITO, COPY, PASTE, PROOFREAD, TRANSLATE) + "offlinelite" -> listOf(SETTINGS, VOICE, CLIPBOARD, UNDO, INCOGNITO, COPY, PASTE) else -> listOf(SETTINGS, VOICE, CLIPBOARD, HANDWRITING, CUSTOM_AI_1, CUSTOM_AI_2, CUSTOM_AI_3, UNDO, PROOFREAD, TRANSLATE, INCOGNITO, TOUCHPAD, FLOATING, NUMPAD, COPY, PASTE, SELECT_ALL) } @@ -377,7 +379,8 @@ private fun getEnabledToolbarKeys(prefs: SharedPreferences, pref: String, defaul val split = it.split(Separators.KV) if (split.last() == "true") { try { - ToolbarKey.valueOf(split.first()) + val key = ToolbarKey.valueOf(split.first()) + if (key in excludedKeys) null else key } catch (_: IllegalArgumentException) { null } diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt index a296b26d6..6ad5ac436 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AIIntegrationScreen.kt @@ -96,6 +96,7 @@ private fun StandardAIIntegrationScreen(onClickBack: () -> Unit) { @Composable private fun OfflineAIIntegrationScreen(onClickBack: () -> Unit) { val items = listOf( + SettingsWithoutKey.CUSTOM_AI_KEYS, SettingsWithoutKey.OFFLINE_MODEL_PATH, SettingsWithoutKey.OFFLINE_KEEP_MODEL_LOADED ) diff --git a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt index 232eb907f..a70949c5c 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/AdvancedScreen.kt @@ -537,7 +537,7 @@ fun createAdvancedSettings(context: Context) = listOfNotNull( ) } }, - if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline") Setting(context, SettingsWithoutKey.CUSTOM_AI_KEYS, R.string.custom_ai_keys_title, R.string.custom_ai_keys_summary) { Preference( name = it.title, description = it.description, diff --git a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt index 4e110f276..3c0d9fef8 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/LibrariesHubScreen.kt @@ -29,6 +29,7 @@ import helium314.keyboard.settings.preferences.LoadGestureLibPreference import helium314.keyboard.settings.preferences.LoadEmojiLibPreference import helium314.keyboard.settings.preferences.LoadHandwritingPluginPreference import helium314.keyboard.latin.handwriting.HandwritingLoader +import helium314.keyboard.latin.BuildConfig import helium314.keyboard.latin.common.Links import helium314.keyboard.settings.preferences.Preference import androidx.compose.ui.platform.LocalUriHandler @@ -95,13 +96,15 @@ fun LibrariesHubScreen( ) // Handwriting Input Plugin - var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } - LoadHandwritingPluginPreference( - title = stringResource(R.string.libraries_hub_handwriting_title), - summary = if (handwritingInstalled) stringResource(R.string.libraries_status_active) else stringResource(R.string.libraries_status_not_installed), - icon = R.drawable.ic_edit, - onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) } - ) + if (BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised") { + var handwritingInstalled by remember { mutableStateOf(HandwritingLoader.hasPlugin(context)) } + LoadHandwritingPluginPreference( + title = stringResource(R.string.libraries_hub_handwriting_title), + summary = if (handwritingInstalled) stringResource(R.string.libraries_status_active) else stringResource(R.string.libraries_status_not_installed), + icon = R.drawable.ic_edit, + onSuccess = { handwritingInstalled = HandwritingLoader.hasPlugin(context) } + ) + } // Documentation & Features val uriHandler = LocalUriHandler.current diff --git a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt index eb7aca6c2..6419c66dc 100644 --- a/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt +++ b/app/src/main/java/helium314/keyboard/settings/screens/ToolbarScreen.kt @@ -97,7 +97,8 @@ fun createToolbarSettings(context: Context): List { val filter = { name: String -> val lowerName = name.lowercase() when { - lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" + lowerName.startsWith("custom_ai_") -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" || BuildConfig.FLAVOR == "offline" + lowerName == "handwriting" -> BuildConfig.FLAVOR == "standard" || BuildConfig.FLAVOR == "standardOptimised" lowerName in listOf("proofread", "translate", "clipboard_search") -> BuildConfig.FLAVOR != "offlinelite" else -> true } diff --git a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt index a08c0b518..c46cd2a22 100644 --- a/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt +++ b/app/src/offline/java/helium314/keyboard/latin/utils/ProofreadService.kt @@ -381,7 +381,7 @@ class ProofreadService(private val context: Context) { builder += "Input: $text\nOutput:" builder } else { - "Instruction: ${systemPrompt.trim()}\nInput: $text\nOutput:" + "Instruction: ${systemPrompt.trim()}\n\nInput: $text\nOutput:" } } else { // Default proofreading with few-shot examples for better local model guidance @@ -456,6 +456,13 @@ class ProofreadService(private val context: Context) { } } + // Also truncate at any newline followed by a potential template header (e.g., "\nDraft email:", "\nCorrection:") + val headerRegex = Regex("\\n[a-zA-Z0-9 ]+:") + val match = headerRegex.find(cleanedOutput) + if (match != null) { + cleanedOutput = cleanedOutput.substring(0, match.range.first).trim() + } + // Also strip common prefixes that the model might generate or echo val prefixesToStrip = listOf( "Output:", "Corrected:", "Translation:", "Response:", "Result:", @@ -544,7 +551,7 @@ class ProofreadService(private val context: Context) { // Build parameters map val params = mutableMapOf( "prompt" to prompt, - "emit_partial_completion" to showThinking, + "emit_partial_completion" to true, "temperature" to temp.toDouble(), "top_p" to topP.toDouble(), "top_k" to topK, From d14cb17ea9b6bd481b2d26c47a440f9fdb044589 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 20 Jun 2026 21:01:23 +0530 Subject: [PATCH 114/118] feat(handwriting): add plugin downloader and refine blacklist --- .../latin/DictionaryFacilitatorImpl.kt | 54 ++++- .../helium314/keyboard/latin/LatinIME.java | 1 + .../latin/handwriting/HandwritingLoader.kt | 12 ++ .../LoadHandwritingPluginPreference.kt | 186 ++++++++++++++++-- 4 files changed, 234 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt index 920d4abd8..df341fede 100644 --- a/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt +++ b/app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt @@ -902,11 +902,22 @@ private class DictionaryGroup( private val blacklist = hashSetOf().apply { val file = blacklistFile - if (file?.isFile != true) return@apply + if (file == null) return@apply scope.launch { synchronized(blacklistLock) { try { - addAll(file.readLines().map { it.lowercase(locale) }) + val loadedWords = mutableSetOf() + if (file.isFile) { + loadedWords.addAll(file.readLines().map { it.lowercase(locale) }) + } + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isFile) { + loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) }) + } + } + addAll(loadedWords) rebuildCompiledPatterns() } catch (e: IOException) { Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) @@ -932,6 +943,12 @@ private class DictionaryGroup( try { if (file.isDirectory) file.delete() file.appendText("$lowercase\n") + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isDirectory) baseFile.delete() + baseFile.appendText("$lowercase\n") + } } catch (e: IOException) { Log.e(TAG, "Exception while trying to add word \"$lowercase\" to blacklist ${file.name}", e) } @@ -949,10 +966,22 @@ private class DictionaryGroup( scope.launch { synchronized(blacklistLock) { try { - val newLines = file.readLines().filterNot { it.lowercase(locale) == lowercase } - file.writeText(newLines.joinToString("\n")) + val files = mutableListOf(file) + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + files.add(File(file.parentFile, "${locale.language}.txt")) + } + for (f in files) { + if (f.isFile) { + val lines = f.readLines() + val newLines = lines.filterNot { it.lowercase(locale) == lowercase } + if (newLines.size != lines.size) { + f.writeText(newLines.joinToString("\n") + if (newLines.isEmpty()) "" else "\n") + } + } + } } catch (e: IOException) { - Log.e(TAG, "Exception while trying to remove word \"$word\" to blacklist ${file.name}", e) + Log.e(TAG, "Exception while trying to remove word \"$word\" from blacklist ${file.name}", e) } } } @@ -960,7 +989,7 @@ private class DictionaryGroup( fun reloadBlacklist() { val file = blacklistFile - if (file == null || !file.isFile) { + if (file == null) { synchronized(blacklistLock) { blacklist.clear() rebuildCompiledPatterns() @@ -971,7 +1000,18 @@ private class DictionaryGroup( synchronized(blacklistLock) { try { blacklist.clear() - blacklist.addAll(file.readLines().map { it.lowercase(locale) }) + val loadedWords = mutableSetOf() + if (file.isFile) { + loadedWords.addAll(file.readLines().map { it.lowercase(locale) }) + } + val langTag = locale.toLanguageTag() + if (locale.language.isNotEmpty() && locale.language != langTag) { + val baseFile = File(file.parentFile, "${locale.language}.txt") + if (baseFile.isFile) { + loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) }) + } + } + blacklist.addAll(loadedWords) rebuildCompiledPatterns() } catch (e: IOException) { Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e) diff --git a/app/src/main/java/helium314/keyboard/latin/LatinIME.java b/app/src/main/java/helium314/keyboard/latin/LatinIME.java index 2cda903c3..58843f480 100644 --- a/app/src/main/java/helium314/keyboard/latin/LatinIME.java +++ b/app/src/main/java/helium314/keyboard/latin/LatinIME.java @@ -1797,6 +1797,7 @@ public void showTranslateLanguageSelector() { @Override public void removeSuggestion(final String word) { mDictionaryFacilitator.removeWord(word); + mInputLogic.getSuggest().clearNextWordSuggestionsCache(); } public DictionaryFacilitator getDictionaryFacilitator() { diff --git a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt index 049949852..9731bd67d 100644 --- a/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt +++ b/app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt @@ -57,6 +57,18 @@ object HandwritingLoader { return context.prefs().getBoolean(PREF_HAS_PLUGIN, false) } + fun getPluginVersion(context: Context): String? { + val apkFile = File(context.filesDir, PLUGIN_FILENAME) + if (!apkFile.exists()) return null + return try { + val info = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0) + info?.versionName + } catch (e: Exception) { + null + } + } + + fun importPlugin(context: Context, uri: Uri): Boolean { try { try { diff --git a/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt index 6c94b779e..b6a4c454e 100644 --- a/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt +++ b/app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt @@ -2,24 +2,40 @@ package helium314.keyboard.settings.preferences import android.content.Intent +import android.net.Uri import android.widget.Toast +import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import helium314.keyboard.latin.R import helium314.keyboard.latin.handwriting.HandwritingLoader import helium314.keyboard.settings.FeedbackManager import helium314.keyboard.settings.dialogs.ConfirmationDialog import helium314.keyboard.settings.filePicker +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.io.File -import androidx.annotation.DrawableRes +import java.io.FileOutputStream +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL @Composable fun LoadHandwritingPluginPreference( @@ -29,9 +45,44 @@ fun LoadHandwritingPluginPreference( onSuccess: (() -> Unit)? = null, ) { var showDialog by rememberSaveable { mutableStateOf(false) } + var isDownloading by rememberSaveable { mutableStateOf(false) } + var remoteVersion by remember { mutableStateOf(null) } + var updateAvailable by remember { mutableStateOf(false) } + var isCheckingUpdate by remember { mutableStateOf(false) } + val ctx = LocalContext.current - + val scope = rememberCoroutineScope() + val hasPlugin = HandwritingLoader.hasPlugin(ctx) + val localVersion = remember(hasPlugin) { HandwritingLoader.getPluginVersion(ctx) } + + LaunchedEffect(hasPlugin) { + isCheckingUpdate = true + scope.launch(Dispatchers.IO) { + try { + val url = URL("https://api.github.com/repos/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest") + val conn = url.openConnection() as HttpURLConnection + conn.setRequestProperty("User-Agent", "HeliboardL") + conn.connect() + if (conn.responseCode == 200) { + val response = conn.inputStream.bufferedReader().use { it.readText() } + val regex = "\"tag_name\"\\s*:\\s*\"([^\"]+)\"".toRegex() + val match = regex.find(response) + if (match != null) { + val tag = match.groupValues[1] + remoteVersion = tag + if (hasPlugin && localVersion != null) { + updateAvailable = isUpdateAvailable(localVersion, tag) + } + } + } + } catch (e: Exception) { + // ignore network errors + } finally { + isCheckingUpdate = false + } + } + } val launcher = filePicker { uri -> val success = HandwritingLoader.importPlugin(ctx, uri) @@ -44,6 +95,70 @@ fun LoadHandwritingPluginPreference( } } + fun startDownload() { + isDownloading = true + scope.launch(Dispatchers.IO) { + try { + val tag = remoteVersion ?: "latest" + val urlStr = if (tag == "latest") { + "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest/download/handwriting_plugin.apk" + } else { + "https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/download/$tag/handwriting_plugin.apk" + } + var url = URL(urlStr) + var conn = url.openConnection() as HttpURLConnection + conn.instanceFollowRedirects = true + conn.setRequestProperty("User-Agent", "HeliboardL") + conn.connect() + + var redirectConn = conn + var status = redirectConn.responseCode + var redirectCount = 0 + while ((status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) && redirectCount < 5) { + val newUrl = redirectConn.getHeaderField("Location") + redirectConn.disconnect() + val nextUrl = URL(newUrl) + redirectConn = nextUrl.openConnection() as HttpURLConnection + redirectConn.setRequestProperty("User-Agent", "HeliboardL") + redirectConn.connect() + status = redirectConn.responseCode + redirectCount++ + } + + if (status != HttpURLConnection.HTTP_OK) { + throw IOException("Server returned HTTP $status") + } + + val tempFile = File(ctx.cacheDir, "temp_handwriting_plugin.apk") + redirectConn.inputStream.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + redirectConn.disconnect() + + val success = HandwritingLoader.importPlugin(ctx, Uri.fromFile(tempFile)) + tempFile.delete() + + withContext(Dispatchers.Main) { + isDownloading = false + if (success) { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_success) + onSuccess?.invoke() + showDialog = false + } else { + FeedbackManager.message(ctx, R.string.load_handwriting_plugin_failed) + } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + isDownloading = false + Toast.makeText(ctx, "Download failed: ${e.localizedMessage}", Toast.LENGTH_LONG).show() + } + } + } + } + Preference( name = title, description = summary, @@ -53,25 +168,54 @@ fun LoadHandwritingPluginPreference( if (showDialog) { ConfirmationDialog( - onDismissRequest = { showDialog = false }, + onDismissRequest = { if (!isDownloading) showDialog = false }, onConfirmed = { - if (hasPlugin) { - HandwritingLoader.removePlugin(ctx) - FeedbackManager.message(ctx, "Handwriting plugin removed") - onSuccess?.invoke() - showDialog = false + if (!isDownloading) { + if (hasPlugin && !updateAvailable) { + HandwritingLoader.removePlugin(ctx) + FeedbackManager.message(ctx, "Handwriting plugin removed") + onSuccess?.invoke() + showDialog = false + } else { + startDownload() + } } }, - confirmButtonText = if (hasPlugin) stringResource(R.string.load_handwriting_plugin_button_delete) else "", + confirmButtonText = when { + isDownloading -> "Downloading..." + hasPlugin && !updateAvailable -> stringResource(R.string.load_handwriting_plugin_button_delete) + hasPlugin && updateAvailable -> "Update" + else -> "Download" + }, title = { Text(stringResource(R.string.load_handwriting_plugin)) }, content = { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text(stringResource(R.string.load_handwriting_plugin_message)) + val message = when { + hasPlugin && updateAvailable -> "An update is available for the handwriting plugin!\nLocal version: $localVersion\nLatest version: $remoteVersion\n\nDo you want to download and update?" + hasPlugin -> "Handwriting plugin is active (version $localVersion).\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + remoteVersion != null -> "Download the latest handwriting plugin (version $remoteVersion) from GitHub, or load an APK from local storage.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + else -> "Download the handwriting plugin from GitHub, or load an APK from local storage.\n\nWarning: loading external code can be a security risk. Only use a plugin from a source you trust." + } + Text(message) + if (isDownloading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator() + } } }, - neutralButtonText = if (!hasPlugin) stringResource(R.string.load_handwriting_plugin_button_load) else null, + neutralButtonText = when { + isDownloading -> null + hasPlugin && updateAvailable -> "Delete" + hasPlugin -> null + else -> "Load from file" + }, onNeutral = { - if (!hasPlugin) { + if (hasPlugin) { + HandwritingLoader.removePlugin(ctx) + FeedbackManager.message(ctx, "Handwriting plugin removed") + onSuccess?.invoke() + showDialog = false + } else { showDialog = false val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) .addCategory(Intent.CATEGORY_OPENABLE) @@ -86,3 +230,21 @@ fun LoadHandwritingPluginPreference( ) } } + +private fun isUpdateAvailable(local: String, remote: String): Boolean { + val cleanLocal = local.removePrefix("v").trim() + val cleanRemote = remote.removePrefix("v").trim() + if (cleanLocal == cleanRemote) return false + + val localParts = cleanLocal.split(".").mapNotNull { it.toIntOrNull() } + val remoteParts = cleanRemote.split(".").mapNotNull { it.toIntOrNull() } + + val maxLength = maxOf(localParts.size, remoteParts.size) + for (i in 0 until maxLength) { + val localPart = localParts.getOrElse(i) { 0 } + val remotePart = remoteParts.getOrElse(i) { 0 } + if (remotePart > localPart) return true + if (localPart > remotePart) return false + } + return false +} From 5835d632604a95587fd51ed1dc52dcf4b8dad435 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 20 Jun 2026 22:45:48 +0530 Subject: [PATCH 115/118] build: limit abi filters to arm64-v8a --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d76b29be7..2e2f54ebb 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -28,7 +28,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") ndk { - abiFilters.addAll(arrayOf("armeabi-v7a", "arm64-v8a")) + abiFilters.addAll(arrayOf("arm64-v8a")) } } From 3fc71c9bde558d3c779e1fca6c6a689dc72ac445 Mon Sep 17 00:00:00 2001 From: LeanBitLab Date: Sat, 20 Jun 2026 23:10:33 +0530 Subject: [PATCH 116/118] docs: document handwriting and gguf features --- README.md | 15 ++++++++------- docs/FEATURES.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 439ff4644..85e50069d 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ This fork adds **optional AI-powered features** using Gemini, Groq, and OpenAI-c ## What's New in LeanType - **[🤖 Multi-Provider AI](docs/FEATURES.md#supported-ai-providers)** - Proofread using **Gemini**, **Groq** (Llama 3, Mixtral), or **OpenAI-compatible** providers. Supports dynamic fetching of latest models directly from providers. -- **[🛡️ Offline AI](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using ONNX models (Offline build only). +- **[🛡️ Offline AI (GGUF)](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** - Private, on-device proofreading and translation using local **GGUF models** powered by `llama.cpp` (Offline build only). - **🌐 AI Translation** - Translate selected text directly using your chosen AI provider, with a separate model selector. +- **[✍️ Handwriting Input](docs/FEATURES.md#8-handwriting-input)** - Draw characters directly on a handwriting recognition canvas (Standard version, requires [Leantype-Handwriting-Plugin](https://github.com/LeanBitLab/Leantype-Handwriting-Plugin)). - **[🧠 Custom AI Keys](docs/FEATURES.md#4-custom-ai-keys--keywords)** - Assign custom prompts, personas (#editor, #proofread), and custom text labels/tags (showing as themed capsules) to 10 customizable toolbar keys. - **📝 Text Expander** - Built-in expansion tool supporting custom shortcuts and dynamic template variables (date, time, clipboard, custom placeholders). - **🪟 Floating Keyboard** - Detach the keyboard into a draggable window for seamless multitasking. Includes a persistent mode option to keep the keyboard floating. @@ -31,7 +32,7 @@ This fork adds **optional AI-powered features** using Gemini, Groq, and OpenAI-c - **🔍 Clipboard Search & Undo** - Search through your clipboard history directly from the toolbar, undo accidental item deletions, and fold/collapse pinned items by default to save space. - **📸 Screenshot Suggestion & Clipboard** - Suggests recently taken screenshots for quick sharing via the suggestion strip and saves them to your clipboard history. - **🔎 Emoji Search** - Search for emojis by name. *Requires loading an Emoji Dictionary.* -- **🔒 Privacy Choices** - Choose **Standard** (Opt-in AI), **Offline** (Hard-disabled network, offline model load), or **Offline Lite** (Minimalist, no AI) versions. +- **🔒 Privacy Choices** - Choose **Standard** (Opt-in AI, Handwriting), **Offline** (Hard-disabled network, offline GGUF model load), or **Offline Lite** (Minimalist, no AI) versions. @@ -76,17 +77,17 @@ This fork adds **optional AI-powered features** using Gemini, Groq, and OpenAI-c ### 📦 Choose Your Version #### 1. Standard Version (`-standard-release.apk`) -* **Features:** Full suite including **AI Proofreading**, **AI Translation**, and **Gesture Library Downloader**. -* **Permissions:** Request `INTERNET` permission (used *only* when you explicitly use AI features). -* **Setup:** Use the built-in downloader for Gesture Typing. Configure AI keys in Settings. +* **Features:** Full suite including **AI Proofreading**, **AI Translation**, **Handwriting Input**, and **Gesture Library Downloader**. +* **Permissions:** Request `INTERNET` permission (used *only* when you explicitly use AI features, download plugins, or update libraries). +* **Setup:** Use the built-in downloader for Gesture Typing and Handwriting Input. Configure AI keys in Settings. #### 2. Offline Version (`-offline-release.apk`) -* **Features:** All UI/UX enhancements and **Offline Neural Proofreading** (ONNX). +* **Features:** All UI/UX enhancements and **Offline Neural Proofreading** (via `llama.cpp` using local **GGUF models**). * **Permissions:** **NO INTERNET PERMISSION**. Guaranteed at OS level. * **Best For:** Privacy purists. * **Manual Setup Required:** * **Gesture Typing:** [Download library manually](https://github.com/erkserkserks/openboard/tree/46fdf2b550035ca69299ce312fa158e7ade36967/app/src/main/jniLibs) and load via *Settings > Gesture typing*. - * **Offline AI:** Download ONNX models and load via *Settings > AI Integration*. 👉 **[See Offline Setup Instructions](docs/FEATURES.md#3-offline-proofreading-privacy-focused)** + * **Offline AI:** Download GGUF models and load via *Settings > Advanced > GGUF Model (.gguf)*. 👉 **[See Offline Setup Instructions](docs/FEATURES.md#5-offline-proofreading-privacy-focused)** #### 3. Offline Lite Version (`-offlinelite-release.apk`) * **Features:** All UI/UX enhancements but **NO AI FEATURES**. diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4c840ac06..99629a12d 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -15,6 +15,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | 🛡️ **[Offline Proofreading](#5-offline-proofreading-privacy-focused)** | Privacy-first, on-device AI. | | 📝 **[Text Expander](#6-text-expander)** | Custom text shortcut expansion. | | 🖱️ **[Touchpad Mode](#7-touchpad-mode)** | Full-screen touchpad gestures and controls. | +| ✍️ **[Handwriting Input](#8-handwriting-input)** | Use handwriting recognition to draw letters directly on a canvas. | ## Summary of New Features @@ -22,6 +23,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | :--- | :--- | :--- | | **Multi-Provider AI** | Uses Gemini, Groq, or OpenAI to proofread/rewrite text. Fetch latest models dynamically. | `AI Integration > Set AI Provider` | | **Offline Proofreading** | Private, on-device AI for grammar (requires downloads). | `AI Integration > Offline Proofreading` | +| **GGUF Model Support** | Load and run highly quantized, compact GGUF models on-device for offline proofreading/translation. | `Advanced > GGUF Model (.gguf)` | | **Custom AI Keys** | 10 toolbar keys with custom prompts, tags (themed capsules), and toggle settings (supports hashtags). | `AI Integration > Custom Keys` | | **AI Translation** | Translates selected text via your configured AI provider (includes separate model selector). | Toolbar > Translate Icon | | **Floating Keyboard** | Detach the keyboard into a draggable window with a persistent mode option. | Toolbar > Floating Keyboard | @@ -37,6 +39,7 @@ LeanType integrates with AI providers to offer advanced proofreading and transla | **Screenshot on Clipboard** | Automatically saves taken screenshots to your clipboard history. | *Automatic (when enabled)* | | **Clipboard Undo** | Undo swipe-to-delete on clipboard items with a timed undo bar. | *Automatic (on swipe delete)* | | **Text Expander** | Expand custom shortcuts using dynamic template variables (date, time, clipboard, custom placeholders). | `Text correction > Text Expander` | +| **Handwriting Input** | Draw letters or words directly on the screen keyboard space to type (standard variant, requires plugin). | `Libraries > Handwriting Input Plugin` | --- @@ -340,3 +343,33 @@ Touchpad Mode replaces the keyboard with a laptop-style touchpad overlay to cont * **Double Tap**: Copies selected text (or Pastes clipboard contents if no selection exists). * **Triple Tap**: Cuts selected text (or Selects All if no selection exists). * **Press & Hold (Long Press)**: Deletes (backspaces) selection / word to the left. Repeats automatically if held. + +--- + +## 8. Handwriting Input + +> [!NOTE] +> **Availability**: This feature is only available in the **Standard** (`-standard-release.apk`) and **Standard Optimised** build flavors. It is excluded from the **Offline** and **Offline Lite** variants. + +LeanType integrates a handwriting recognition canvas that allows you to write characters directly on the keyboard using your finger or a stylus. + +### Setup Instructions + +1. **Install the Plugin**: + * Go to **Settings > Libraries**. + * Under **Handwriting Input Plugin**, tap **Download** to pull the latest plugin APK from the [Leantype-Handwriting-Plugin](https://github.com/LeanBitLab/Leantype-Handwriting-Plugin) GitHub repository. + * Alternatively, you can tap to load a locally downloaded plugin APK file. + * Review the security warning and confirm the installation. The app will verify and register the plugin. + +2. **Accessing the Handwriting Key**: + * The **Handwriting** icon (represented by a pencil/edit icon) is placed on your keyboard toolbar by default in supported variants. + * If it is not showing, you can customize the toolbar under **Settings > Preferences > Keyboard toolbar** to enable it. + +### How to Use + +1. Tap the **Handwriting** icon in the toolbar. +2. The keyboard area will switch to a handwriting drawing canvas. +3. Draw characters, words, or punctuation symbols on the canvas. The keyboard will automatically inputs recognized characters. +4. Tap the **Clear (X)** button on the bottom row to clear the current drawing canvas. +5. Tap the **Handwriting** icon again to toggle back to the standard keyboard layout. + From baf42cd35761dba92032b24169e6caaddece7319 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Sun, 21 Jun 2026 11:02:25 +0300 Subject: [PATCH 117/118] chore(release): bump to 3.10.0 (4000) + changelog --- CHANGELOG.md | 2 ++ app/build.gradle.kts | 4 ++-- fastlane/metadata/android/en-US/changelogs/4000.txt | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/4000.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 74435857c..566105ea0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) ## [Unreleased] +## [3.10.0] - 2026-06-20 + ### Added - **Handwriting input** (Standard builds) — write characters on a recognition canvas using a downloadable plugin, with a dedicated bottom-row layout and a toolbar key. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2a0c5494..8da584599 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,8 +22,8 @@ android { applicationId = "com.asafmah.leantypedual" minSdk = 21 targetSdk = 35 - versionCode = 3910 - versionName = "3.9.1" + versionCode = 4000 + versionName = "3.10.0" proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") diff --git a/fastlane/metadata/android/en-US/changelogs/4000.txt b/fastlane/metadata/android/en-US/changelogs/4000.txt new file mode 100644 index 000000000..910f2974a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/4000.txt @@ -0,0 +1,5 @@ +Handwriting input (Standard) — write on a recognition canvas using a downloadable plugin. +Offline AI now runs on-device GGUF models via llama.cpp, with adjustable sampling. +Richer touchpad gestures: word select, word-by-word navigation, clipboard, undo/redo, hold-to-backspace. +Auto-read one-time codes from an incoming SMS as a tappable suggestion. +Regex shortcuts in Text Expander. From ce866735ffcc9f58c5178c7393a1ebee6a4c1f91 Mon Sep 17 00:00:00 2001 From: Asaf Mahlev Date: Mon, 22 Jun 2026 07:39:09 +0300 Subject: [PATCH 118/118] fix(handwriting): don't cancel active keys when hiding inactive handwriting --- .../java/helium314/keyboard/keyboard/KeyboardSwitcher.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java index 9d965abc3..a5490480d 100644 --- a/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java +++ b/app/src/main/java/helium314/keyboard/keyboard/KeyboardSwitcher.java @@ -362,8 +362,10 @@ private void setMainKeyboardFrame( mClipboardHistoryView.setVisibility(View.GONE); mClipboardHistoryView.stopClipboardHistory(); if (mHandwritingView != null) { + if (mHandwritingView.isShown()) { + mHandwritingView.stopHandwriting(); + } mHandwritingView.setVisibility(View.GONE); - mHandwritingView.stopHandwriting(); } if (PointerTracker.sPersistentTouchpadModeActive) {