22package helium314.keyboard.settings.preferences
33
44import android.content.Intent
5+ import android.net.Uri
56import android.widget.Toast
7+ import androidx.annotation.DrawableRes
68import 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
712import androidx.compose.material3.Text
813import androidx.compose.runtime.Composable
14+ import androidx.compose.runtime.LaunchedEffect
915import androidx.compose.runtime.getValue
1016import androidx.compose.runtime.mutableStateOf
17+ import androidx.compose.runtime.remember
18+ import androidx.compose.runtime.rememberCoroutineScope
1119import androidx.compose.runtime.saveable.rememberSaveable
1220import androidx.compose.runtime.setValue
1321import androidx.compose.ui.Alignment
22+ import androidx.compose.ui.Modifier
1423import androidx.compose.ui.platform.LocalContext
1524import androidx.compose.ui.res.stringResource
25+ import androidx.compose.ui.unit.dp
1626import helium314.keyboard.latin.R
1727import helium314.keyboard.latin.handwriting.HandwritingLoader
1828import helium314.keyboard.settings.FeedbackManager
1929import helium314.keyboard.settings.dialogs.ConfirmationDialog
2030import helium314.keyboard.settings.filePicker
31+ import kotlinx.coroutines.Dispatchers
32+ import kotlinx.coroutines.launch
33+ import kotlinx.coroutines.withContext
2134import 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
2541fun 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!\n Local version: $localVersion \n Latest version: $remoteVersion \n\n Do you want to download and update?"
195+ hasPlugin -> " Handwriting plugin is active (version $localVersion ).\n\n Warning: 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\n Warning: 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\n Warning: 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