Add automatic update checking service

This commit is contained in:
Aleksandras Kostarevas 2024-01-15 17:38:46 +02:00
parent fda0052c55
commit 74c74c7ba7
10 changed files with 311 additions and 14 deletions

View File

@ -22,7 +22,8 @@ android {
defaultConfig {
minSdk 24
targetSdk 34
versionName "1.0"
versionName "0.1"
versionCode 31
applicationId 'org.futo.inputmethod.latin'
testApplicationId 'org.futo.inputmethod.latin.tests'

View File

@ -15,12 +15,12 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.futo.inputmethod.latin"
android:versionCode="30">
package="org.futo.inputmethod.latin">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.READ_PROFILE"/>
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS"/>
@ -74,6 +74,12 @@
android:resource="@xml/method"/>
</service>
<service
android:name="org.futo.inputmethod.updates.UpdateCheckingService"
android:label="@string/update_checking_service"
android:permission="android.permission.BIND_JOB_SERVICE" >
</service>
<service android:name=".spellcheck.AndroidSpellCheckerService"
android:label="@string/spell_checker_service_name"
android:permission="android.permission.BIND_TEXT_SERVICE"

View File

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="voice_input_action_title">Voice Input</string>
<string name="theme_switcher_action_title">Theme Switcher</string>
<string name="emoji_action_title">Emojis</string>
@ -26,4 +26,8 @@
<string name="download_in_progress">Downloading models…</string>
<string name="model_downloader">Model Downloader</string>
<string name="update_checking_service">Update Checking service</string>
<string name="update_available">Update Available</string>
<string name="update_available_notification">An update is available (<xliff:g name="versionDiff" example="v1 -> v2">%s</xliff:g>). Tap to download</string>
</resources>

View File

@ -53,6 +53,7 @@ import org.futo.inputmethod.latin.uix.theme.ThemeOption
import org.futo.inputmethod.latin.uix.theme.ThemeOptions
import org.futo.inputmethod.latin.uix.theme.presets.VoiceInputTheme
import org.futo.inputmethod.latin.xlm.LanguageModelFacilitator
import org.futo.inputmethod.updates.scheduleUpdateCheckingJob
class LatinIME : InputMethodService(), LifecycleOwner, ViewModelStoreOwner, SavedStateRegistryOwner,
LatinIMELegacy.SuggestionStripController, DynamicThemeProviderOwner {
@ -210,6 +211,8 @@ class LatinIME : InputMethodService(), LifecycleOwner, ViewModelStoreOwner, Save
languageModelFacilitator.launchProcessor()
languageModelFacilitator.loadHistoryLog()
scheduleUpdateCheckingJob(this)
}
override fun onDestroy() {

View File

@ -1,29 +1,38 @@
package org.futo.inputmethod.latin.uix.settings.pages
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import org.futo.inputmethod.latin.BuildConfig
import org.futo.inputmethod.latin.R
import org.futo.inputmethod.latin.uix.settings.NavigationItem
import org.futo.inputmethod.latin.uix.settings.NavigationItemStyle
import org.futo.inputmethod.latin.uix.settings.ScreenTitle
import org.futo.inputmethod.latin.uix.settings.ScrollableList
import org.futo.inputmethod.latin.uix.settings.openLanguageSettings
import org.futo.inputmethod.latin.uix.theme.Typography
import org.futo.inputmethod.updates.ConditionalUpdate
@Preview
@Preview(showBackground = true)
@Composable
fun HomeScreen(navController: NavHostController = rememberNavController()) {
val context = LocalContext.current
ScrollableList {
Spacer(modifier = Modifier.height(24.dp))
ScreenTitle("FUTO Keyboard Settings")
ConditionalUpdate()
NavigationItem(
title = "Languages",
style = NavigationItemStyle.HomePrimary,
@ -59,13 +68,14 @@ fun HomeScreen(navController: NavHostController = rememberNavController()) {
icon = painterResource(id = R.drawable.eye)
)
/*
NavigationItem(
title = "Advanced",
style = NavigationItemStyle.Misc,
navigate = { },
icon = painterResource(id = R.drawable.delete)
Spacer(modifier = Modifier.height(32.dp))
Text(
"v${BuildConfig.VERSION_NAME}",
style = Typography.labelSmall,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center
)
*/
Spacer(modifier = Modifier.height(32.dp))
}
}

View File

@ -0,0 +1,58 @@
package org.futo.inputmethod.updates
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.widget.Toast
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.tooling.preview.Preview
import androidx.datastore.preferences.core.stringPreferencesKey
import org.futo.inputmethod.latin.uix.settings.SettingItem
import org.futo.inputmethod.latin.uix.settings.useDataStore
val LAST_UPDATE_CHECK_RESULT = stringPreferencesKey("last_update_check_result")
fun Context.openURI(uri: String, newTask: Boolean = false) {
try {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))
if (newTask) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
startActivity(intent)
} catch(e: ActivityNotFoundException) {
Toast.makeText(this, e.localizedMessage, Toast.LENGTH_SHORT).show()
}
}
@Composable
@Preview
fun ConditionalUpdate() {
val (updateInfo, _) = useDataStore(key = LAST_UPDATE_CHECK_RESULT, default = "")
val lastUpdateResult = if(!LocalInspectionMode.current){
UpdateResult.fromString(updateInfo)
} else {
UpdateResult(123, "abc", "1.2.3")
}
val context = LocalContext.current
if(lastUpdateResult != null && lastUpdateResult.isNewer()) {
SettingItem(
title = "Update Available",
subtitle = "${UpdateResult.currentVersionString()} -> ${lastUpdateResult.nextVersionString}",
onClick = {
context.openURI(lastUpdateResult.apkUrl)
}
) {
Icon(Icons.Default.ArrowForward, contentDescription = "Go")
}
}
}

View File

@ -0,0 +1,101 @@
package org.futo.inputmethod.updates
import android.app.job.JobInfo
import android.app.job.JobScheduler
import android.content.ComponentName
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.internal.closeQuietly
import org.futo.inputmethod.latin.uix.dataStore
import org.futo.inputmethod.latin.uix.getSetting
import java.lang.Exception
const val UPDATE_URL = "https://voiceinput.futo.org/SuperSecretKeyboard/keyboard_version"
suspend fun checkForUpdate(): UpdateResult? {
return withContext(Dispatchers.IO) {
val httpClient = OkHttpClient()
val request = Request.Builder().method("GET", null).url(UPDATE_URL).build()
try {
val response = httpClient.newCall(request).execute()
val body = response.body
val result = if (body != null) {
val data = body.string().lines()
body.closeQuietly()
val latestVersion = data[0].toInt()
val latestVersionUrl = data[1]
val latestVersionString = data[2]
if(latestVersionUrl.startsWith("https://voiceinput.futo.org/") || latestVersionUrl.startsWith("https://keyboard.futo.org/")){
UpdateResult(
nextVersion = latestVersion,
apkUrl = latestVersionUrl,
nextVersionString = latestVersionString
)
} else {
null
}
} else {
null
}
response.closeQuietly()
result
} catch (e: Exception) {
null
}
}
}
suspend fun checkForUpdateAndSaveToPreferences(context: Context): Boolean {
val updateResult = checkForUpdate()
if(updateResult != null) {
withContext(Dispatchers.IO) {
context.dataStore.edit {
it[LAST_UPDATE_CHECK_RESULT] = Json.encodeToString(updateResult)
}
}
return true
}
return false
}
suspend fun retrieveSavedLastUpdateCheckResult(context: Context): UpdateResult? {
return UpdateResult.fromString(context.getSetting(LAST_UPDATE_CHECK_RESULT, ""))
}
const val JOB_ID: Int = 15782788
fun scheduleUpdateCheckingJob(context: Context) {
val jobScheduler = context.getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
if(jobScheduler.getPendingJob(JOB_ID) != null) {
Log.i("UpdateChecking", "Job already scheduled, no need to do anything")
return
}
var jobInfoBuilder = JobInfo.Builder(JOB_ID, ComponentName(context, UpdateCheckingService::class.java))
.setPeriodic(1000 * 60 * 60 * 24 * 2) // every two days
.setRequiredNetworkType(JobInfo.NETWORK_TYPE_UNMETERED) // on unmetered Wi-Fi
.setPersisted(true) // persist after reboots
// Update checking has minimum priority
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
jobInfoBuilder = jobInfoBuilder.setPriority(JobInfo.PRIORITY_MIN)
}
jobScheduler.schedule(jobInfoBuilder.build())
}

View File

@ -0,0 +1,74 @@
package org.futo.inputmethod.updates
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.futo.inputmethod.latin.R
const val CHANNEL_ID = "UPDATES"
const val NOTIFICATION_ID = 1
class UpdateCheckingService : JobService() {
private var job: Job? = null
override fun onStartJob(params: JobParameters?): Boolean {
job = CoroutineScope(Dispatchers.IO).launch {
if(checkForUpdateAndSaveToPreferences(applicationContext)) {
val updateResult = retrieveSavedLastUpdateCheckResult(applicationContext)
if(updateResult != null && updateResult.isNewer()) {
// Show a notification : "Update available"
val manager = applicationContext.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Update Notifications",
NotificationManager.IMPORTANCE_MIN
)
manager.createNotificationChannel(channel)
}
val contentIntent = PendingIntent.getActivity(
applicationContext,
0,
Intent(Intent.ACTION_VIEW, Uri.parse(updateResult.apkUrl)),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID)
.setContentTitle(getString(R.string.update_available))
.setContentText(getString(R.string.update_available_notification,"${UpdateResult.currentVersionString()} -> ${updateResult.nextVersionString}"))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentIntent(contentIntent)
manager.notify(NOTIFICATION_ID, notification.build())
}
} else {
Log.i("UpdateCheckingService", "no update available, or failed to check")
}
jobFinished(params, false)
}
return true
}
override fun onStopJob(params: JobParameters?): Boolean {
job?.cancel()
return false
}
}

View File

@ -0,0 +1,41 @@
package org.futo.inputmethod.updates
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.futo.inputmethod.latin.BuildConfig
@Serializable
data class UpdateResult(
val nextVersion: Int,
val apkUrl: String,
val nextVersionString: String
) {
fun isNewer(): Boolean {
return nextVersion > currentVersion()
}
companion object {
fun currentVersion(): Int {
return BuildConfig.VERSION_CODE
}
fun currentVersionString(): String {
return BuildConfig.VERSION_NAME
}
fun fromString(value: String): UpdateResult? {
if(value.isEmpty()) {
return null
}
try {
return Json.decodeFromString<UpdateResult>(value)
} catch(e: SerializationException) {
return null
} catch(e: IllegalArgumentException) {
return null
}
}
}
}

View File

@ -15,8 +15,7 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="org.futo.inputmethod.latin.tests"
android:versionCode="30">
package="org.futo.inputmethod.latin.tests">
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" />