Skip to content

Commit d14cb17

Browse files
committed
feat(handwriting): add plugin downloader and refine blacklist
1 parent 7b04398 commit d14cb17

4 files changed

Lines changed: 234 additions & 19 deletions

File tree

app/src/main/java/helium314/keyboard/latin/DictionaryFacilitatorImpl.kt

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -902,11 +902,22 @@ private class DictionaryGroup(
902902

903903
private val blacklist = hashSetOf<String>().apply {
904904
val file = blacklistFile
905-
if (file?.isFile != true) return@apply
905+
if (file == null) return@apply
906906
scope.launch {
907907
synchronized(blacklistLock) {
908908
try {
909-
addAll(file.readLines().map { it.lowercase(locale) })
909+
val loadedWords = mutableSetOf<String>()
910+
if (file.isFile) {
911+
loadedWords.addAll(file.readLines().map { it.lowercase(locale) })
912+
}
913+
val langTag = locale.toLanguageTag()
914+
if (locale.language.isNotEmpty() && locale.language != langTag) {
915+
val baseFile = File(file.parentFile, "${locale.language}.txt")
916+
if (baseFile.isFile) {
917+
loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) })
918+
}
919+
}
920+
addAll(loadedWords)
910921
rebuildCompiledPatterns()
911922
} catch (e: IOException) {
912923
Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e)
@@ -932,6 +943,12 @@ private class DictionaryGroup(
932943
try {
933944
if (file.isDirectory) file.delete()
934945
file.appendText("$lowercase\n")
946+
val langTag = locale.toLanguageTag()
947+
if (locale.language.isNotEmpty() && locale.language != langTag) {
948+
val baseFile = File(file.parentFile, "${locale.language}.txt")
949+
if (baseFile.isDirectory) baseFile.delete()
950+
baseFile.appendText("$lowercase\n")
951+
}
935952
} catch (e: IOException) {
936953
Log.e(TAG, "Exception while trying to add word \"$lowercase\" to blacklist ${file.name}", e)
937954
}
@@ -949,18 +966,30 @@ private class DictionaryGroup(
949966
scope.launch {
950967
synchronized(blacklistLock) {
951968
try {
952-
val newLines = file.readLines().filterNot { it.lowercase(locale) == lowercase }
953-
file.writeText(newLines.joinToString("\n"))
969+
val files = mutableListOf(file)
970+
val langTag = locale.toLanguageTag()
971+
if (locale.language.isNotEmpty() && locale.language != langTag) {
972+
files.add(File(file.parentFile, "${locale.language}.txt"))
973+
}
974+
for (f in files) {
975+
if (f.isFile) {
976+
val lines = f.readLines()
977+
val newLines = lines.filterNot { it.lowercase(locale) == lowercase }
978+
if (newLines.size != lines.size) {
979+
f.writeText(newLines.joinToString("\n") + if (newLines.isEmpty()) "" else "\n")
980+
}
981+
}
982+
}
954983
} catch (e: IOException) {
955-
Log.e(TAG, "Exception while trying to remove word \"$word\" to blacklist ${file.name}", e)
984+
Log.e(TAG, "Exception while trying to remove word \"$word\" from blacklist ${file.name}", e)
956985
}
957986
}
958987
}
959988
}
960989

961990
fun reloadBlacklist() {
962991
val file = blacklistFile
963-
if (file == null || !file.isFile) {
992+
if (file == null) {
964993
synchronized(blacklistLock) {
965994
blacklist.clear()
966995
rebuildCompiledPatterns()
@@ -971,7 +1000,18 @@ private class DictionaryGroup(
9711000
synchronized(blacklistLock) {
9721001
try {
9731002
blacklist.clear()
974-
blacklist.addAll(file.readLines().map { it.lowercase(locale) })
1003+
val loadedWords = mutableSetOf<String>()
1004+
if (file.isFile) {
1005+
loadedWords.addAll(file.readLines().map { it.lowercase(locale) })
1006+
}
1007+
val langTag = locale.toLanguageTag()
1008+
if (locale.language.isNotEmpty() && locale.language != langTag) {
1009+
val baseFile = File(file.parentFile, "${locale.language}.txt")
1010+
if (baseFile.isFile) {
1011+
loadedWords.addAll(baseFile.readLines().map { it.lowercase(locale) })
1012+
}
1013+
}
1014+
blacklist.addAll(loadedWords)
9751015
rebuildCompiledPatterns()
9761016
} catch (e: IOException) {
9771017
Log.e(TAG, "Exception while trying to read blacklist from ${file.name}", e)

app/src/main/java/helium314/keyboard/latin/LatinIME.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,7 @@ public void showTranslateLanguageSelector() {
17971797
@Override
17981798
public void removeSuggestion(final String word) {
17991799
mDictionaryFacilitator.removeWord(word);
1800+
mInputLogic.getSuggest().clearNextWordSuggestionsCache();
18001801
}
18011802

18021803
public DictionaryFacilitator getDictionaryFacilitator() {

app/src/main/java/helium314/keyboard/latin/handwriting/HandwritingLoader.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ object HandwritingLoader {
5757
return context.prefs().getBoolean(PREF_HAS_PLUGIN, false)
5858
}
5959

60+
fun getPluginVersion(context: Context): String? {
61+
val apkFile = File(context.filesDir, PLUGIN_FILENAME)
62+
if (!apkFile.exists()) return null
63+
return try {
64+
val info = context.packageManager.getPackageArchiveInfo(apkFile.absolutePath, 0)
65+
info?.versionName
66+
} catch (e: Exception) {
67+
null
68+
}
69+
}
70+
71+
6072
fun importPlugin(context: Context, uri: Uri): Boolean {
6173
try {
6274
try {

app/src/main/java/helium314/keyboard/settings/preferences/LoadHandwritingPluginPreference.kt

Lines changed: 174 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,40 @@
22
package helium314.keyboard.settings.preferences
33

44
import android.content.Intent
5+
import android.net.Uri
56
import android.widget.Toast
7+
import androidx.annotation.DrawableRes
68
import androidx.compose.foundation.layout.Column
9+
import androidx.compose.foundation.layout.Spacer
10+
import androidx.compose.foundation.layout.height
11+
import androidx.compose.material3.CircularProgressIndicator
712
import androidx.compose.material3.Text
813
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.LaunchedEffect
915
import androidx.compose.runtime.getValue
1016
import androidx.compose.runtime.mutableStateOf
17+
import androidx.compose.runtime.remember
18+
import androidx.compose.runtime.rememberCoroutineScope
1119
import androidx.compose.runtime.saveable.rememberSaveable
1220
import androidx.compose.runtime.setValue
1321
import androidx.compose.ui.Alignment
22+
import androidx.compose.ui.Modifier
1423
import androidx.compose.ui.platform.LocalContext
1524
import androidx.compose.ui.res.stringResource
25+
import androidx.compose.ui.unit.dp
1626
import helium314.keyboard.latin.R
1727
import helium314.keyboard.latin.handwriting.HandwritingLoader
1828
import helium314.keyboard.settings.FeedbackManager
1929
import helium314.keyboard.settings.dialogs.ConfirmationDialog
2030
import helium314.keyboard.settings.filePicker
31+
import kotlinx.coroutines.Dispatchers
32+
import kotlinx.coroutines.launch
33+
import kotlinx.coroutines.withContext
2134
import java.io.File
22-
import androidx.annotation.DrawableRes
35+
import java.io.FileOutputStream
36+
import java.io.IOException
37+
import java.net.HttpURLConnection
38+
import java.net.URL
2339

2440
@Composable
2541
fun LoadHandwritingPluginPreference(
@@ -29,9 +45,44 @@ fun LoadHandwritingPluginPreference(
2945
onSuccess: (() -> Unit)? = null,
3046
) {
3147
var showDialog by rememberSaveable { mutableStateOf(false) }
48+
var isDownloading by rememberSaveable { mutableStateOf(false) }
49+
var remoteVersion by remember { mutableStateOf<String?>(null) }
50+
var updateAvailable by remember { mutableStateOf(false) }
51+
var isCheckingUpdate by remember { mutableStateOf(false) }
52+
3253
val ctx = LocalContext.current
33-
54+
val scope = rememberCoroutineScope()
55+
3456
val hasPlugin = HandwritingLoader.hasPlugin(ctx)
57+
val localVersion = remember(hasPlugin) { HandwritingLoader.getPluginVersion(ctx) }
58+
59+
LaunchedEffect(hasPlugin) {
60+
isCheckingUpdate = true
61+
scope.launch(Dispatchers.IO) {
62+
try {
63+
val url = URL("https://api.github.com/repos/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest")
64+
val conn = url.openConnection() as HttpURLConnection
65+
conn.setRequestProperty("User-Agent", "HeliboardL")
66+
conn.connect()
67+
if (conn.responseCode == 200) {
68+
val response = conn.inputStream.bufferedReader().use { it.readText() }
69+
val regex = "\"tag_name\"\\s*:\\s*\"([^\"]+)\"".toRegex()
70+
val match = regex.find(response)
71+
if (match != null) {
72+
val tag = match.groupValues[1]
73+
remoteVersion = tag
74+
if (hasPlugin && localVersion != null) {
75+
updateAvailable = isUpdateAvailable(localVersion, tag)
76+
}
77+
}
78+
}
79+
} catch (e: Exception) {
80+
// ignore network errors
81+
} finally {
82+
isCheckingUpdate = false
83+
}
84+
}
85+
}
3586

3687
val launcher = filePicker { uri ->
3788
val success = HandwritingLoader.importPlugin(ctx, uri)
@@ -44,6 +95,70 @@ fun LoadHandwritingPluginPreference(
4495
}
4596
}
4697

98+
fun startDownload() {
99+
isDownloading = true
100+
scope.launch(Dispatchers.IO) {
101+
try {
102+
val tag = remoteVersion ?: "latest"
103+
val urlStr = if (tag == "latest") {
104+
"https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/latest/download/handwriting_plugin.apk"
105+
} else {
106+
"https://github.com/LeanBitLab/Leantype-Handwriting-Plugin/releases/download/$tag/handwriting_plugin.apk"
107+
}
108+
var url = URL(urlStr)
109+
var conn = url.openConnection() as HttpURLConnection
110+
conn.instanceFollowRedirects = true
111+
conn.setRequestProperty("User-Agent", "HeliboardL")
112+
conn.connect()
113+
114+
var redirectConn = conn
115+
var status = redirectConn.responseCode
116+
var redirectCount = 0
117+
while ((status == HttpURLConnection.HTTP_MOVED_TEMP || status == HttpURLConnection.HTTP_MOVED_PERM || status == HttpURLConnection.HTTP_SEE_OTHER) && redirectCount < 5) {
118+
val newUrl = redirectConn.getHeaderField("Location")
119+
redirectConn.disconnect()
120+
val nextUrl = URL(newUrl)
121+
redirectConn = nextUrl.openConnection() as HttpURLConnection
122+
redirectConn.setRequestProperty("User-Agent", "HeliboardL")
123+
redirectConn.connect()
124+
status = redirectConn.responseCode
125+
redirectCount++
126+
}
127+
128+
if (status != HttpURLConnection.HTTP_OK) {
129+
throw IOException("Server returned HTTP $status")
130+
}
131+
132+
val tempFile = File(ctx.cacheDir, "temp_handwriting_plugin.apk")
133+
redirectConn.inputStream.use { input ->
134+
FileOutputStream(tempFile).use { output ->
135+
input.copyTo(output)
136+
}
137+
}
138+
redirectConn.disconnect()
139+
140+
val success = HandwritingLoader.importPlugin(ctx, Uri.fromFile(tempFile))
141+
tempFile.delete()
142+
143+
withContext(Dispatchers.Main) {
144+
isDownloading = false
145+
if (success) {
146+
FeedbackManager.message(ctx, R.string.load_handwriting_plugin_success)
147+
onSuccess?.invoke()
148+
showDialog = false
149+
} else {
150+
FeedbackManager.message(ctx, R.string.load_handwriting_plugin_failed)
151+
}
152+
}
153+
} catch (e: Exception) {
154+
withContext(Dispatchers.Main) {
155+
isDownloading = false
156+
Toast.makeText(ctx, "Download failed: ${e.localizedMessage}", Toast.LENGTH_LONG).show()
157+
}
158+
}
159+
}
160+
}
161+
47162
Preference(
48163
name = title,
49164
description = summary,
@@ -53,25 +168,54 @@ fun LoadHandwritingPluginPreference(
53168

54169
if (showDialog) {
55170
ConfirmationDialog(
56-
onDismissRequest = { showDialog = false },
171+
onDismissRequest = { if (!isDownloading) showDialog = false },
57172
onConfirmed = {
58-
if (hasPlugin) {
59-
HandwritingLoader.removePlugin(ctx)
60-
FeedbackManager.message(ctx, "Handwriting plugin removed")
61-
onSuccess?.invoke()
62-
showDialog = false
173+
if (!isDownloading) {
174+
if (hasPlugin && !updateAvailable) {
175+
HandwritingLoader.removePlugin(ctx)
176+
FeedbackManager.message(ctx, "Handwriting plugin removed")
177+
onSuccess?.invoke()
178+
showDialog = false
179+
} else {
180+
startDownload()
181+
}
63182
}
64183
},
65-
confirmButtonText = if (hasPlugin) stringResource(R.string.load_handwriting_plugin_button_delete) else "",
184+
confirmButtonText = when {
185+
isDownloading -> "Downloading..."
186+
hasPlugin && !updateAvailable -> stringResource(R.string.load_handwriting_plugin_button_delete)
187+
hasPlugin && updateAvailable -> "Update"
188+
else -> "Download"
189+
},
66190
title = { Text(stringResource(R.string.load_handwriting_plugin)) },
67191
content = {
68192
Column(horizontalAlignment = Alignment.CenterHorizontally) {
69-
Text(stringResource(R.string.load_handwriting_plugin_message))
193+
val message = when {
194+
hasPlugin && updateAvailable -> "An update is available for the handwriting plugin!\nLocal version: $localVersion\nLatest version: $remoteVersion\n\nDo you want to download and update?"
195+
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."
196+
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."
197+
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."
198+
}
199+
Text(message)
200+
if (isDownloading) {
201+
Spacer(modifier = Modifier.height(16.dp))
202+
CircularProgressIndicator()
203+
}
70204
}
71205
},
72-
neutralButtonText = if (!hasPlugin) stringResource(R.string.load_handwriting_plugin_button_load) else null,
206+
neutralButtonText = when {
207+
isDownloading -> null
208+
hasPlugin && updateAvailable -> "Delete"
209+
hasPlugin -> null
210+
else -> "Load from file"
211+
},
73212
onNeutral = {
74-
if (!hasPlugin) {
213+
if (hasPlugin) {
214+
HandwritingLoader.removePlugin(ctx)
215+
FeedbackManager.message(ctx, "Handwriting plugin removed")
216+
onSuccess?.invoke()
217+
showDialog = false
218+
} else {
75219
showDialog = false
76220
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
77221
.addCategory(Intent.CATEGORY_OPENABLE)
@@ -86,3 +230,21 @@ fun LoadHandwritingPluginPreference(
86230
)
87231
}
88232
}
233+
234+
private fun isUpdateAvailable(local: String, remote: String): Boolean {
235+
val cleanLocal = local.removePrefix("v").trim()
236+
val cleanRemote = remote.removePrefix("v").trim()
237+
if (cleanLocal == cleanRemote) return false
238+
239+
val localParts = cleanLocal.split(".").mapNotNull { it.toIntOrNull() }
240+
val remoteParts = cleanRemote.split(".").mapNotNull { it.toIntOrNull() }
241+
242+
val maxLength = maxOf(localParts.size, remoteParts.size)
243+
for (i in 0 until maxLength) {
244+
val localPart = localParts.getOrElse(i) { 0 }
245+
val remotePart = remoteParts.getOrElse(i) { 0 }
246+
if (remotePart > localPart) return true
247+
if (localPart > remotePart) return false
248+
}
249+
return false
250+
}

0 commit comments

Comments
 (0)