Add update important notice, download and install update from within the app

This commit is contained in:
Aleksandras Kostarevas 2024-02-27 12:06:51 +02:00
parent 5888f87fd9
commit f351a61d42
13 changed files with 451 additions and 24 deletions

View File

@ -37,6 +37,7 @@
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- A signature-protected permission to ask AOSP Keyboard to close the software keyboard.
To use this, add the following line into calling application's AndroidManifest.xml
@ -169,6 +170,8 @@
</intent-filter>
</receiver>
<receiver android:name="org.futo.inputmethod.updates.InstallReceiver" />
<!-- Content providers -->
<provider android:name="org.futo.inputmethod.dictionarypack.DictionaryProvider"
android:grantUriPermissions="true"

View File

@ -35,4 +35,5 @@
<string name="model_import_failed">Model import failed</string>
<string name="failed_to_import_the_selected_model">Failed to import the selected model</string>
<string name="dismiss">Dismiss</string>
<string name="update">Update</string>
</resources>

View File

@ -38,6 +38,7 @@ import androidx.savedstate.SavedStateRegistryOwner
import androidx.savedstate.findViewTreeSavedStateRegistryOwner
import androidx.savedstate.setViewTreeSavedStateRegistryOwner
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.futo.inputmethod.latin.uix.BasicThemeProvider
import org.futo.inputmethod.latin.uix.DynamicThemeProvider
@ -213,6 +214,7 @@ class LatinIME : InputMethodService(), LifecycleOwner, ViewModelStoreOwner, Save
languageModelFacilitator.loadHistoryLog()
scheduleUpdateCheckingJob(this)
lifecycleScope.launch { uixManager.showUpdateNoticeIfNeeded() }
}
override fun onDestroy() {

View File

@ -1,5 +1,6 @@
package org.futo.inputmethod.latin.uix
import android.content.Context
import android.os.Build
import android.view.View
import androidx.annotation.RequiresApi
@ -105,6 +106,12 @@ import kotlin.math.roundToInt
* TODO: Will need to make RTL languages work
*/
interface ImportantNotice {
@Composable fun getText(): String
fun onDismiss(context: Context)
fun onOpen(context: Context)
}
val suggestionStylePrimary = TextStyle(
fontFamily = FontFamily.SansSerif,
@ -386,6 +393,45 @@ fun ExpandActionsButton(isActionsOpen: Boolean, onClick: () -> Unit) {
}
}
@Composable
fun ImportantNoticeView(
importantNotice: ImportantNotice
) {
val context = LocalContext.current
Row {
TextButton(
onClick = { importantNotice.onOpen(context) },
modifier = Modifier
.weight(1.0f)
.fillMaxHeight(),
shape = RectangleShape,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.onBackground),
enabled = true
) {
AutoFitText(importantNotice.getText(), style = suggestionStylePrimary.copy(color = MaterialTheme.colorScheme.onBackground))
}
val color = MaterialTheme.colorScheme.primary
IconButton(
onClick = { importantNotice.onDismiss(context) },
modifier = Modifier
.width(42.dp)
.fillMaxHeight()
.drawBehind {
drawCircle(color = color, radius = size.width / 3.0f + 1.0f)
},
colors = IconButtonDefaults.iconButtonColors(contentColor = MaterialTheme.colorScheme.onPrimary)
) {
Icon(
painter = painterResource(id = R.drawable.close),
contentDescription = "Close"
)
}
}
}
@Composable
fun ActionBar(
words: SuggestedWords?,
@ -393,7 +439,9 @@ fun ActionBar(
onActionActivated: (Action) -> Unit,
inlineSuggestions: List<MutableState<View?>>,
forceOpenActionsInitially: Boolean = false,
importantNotice: ImportantNotice? = null
) {
val context = LocalContext.current
val isActionsOpen = remember { mutableStateOf(forceOpenActionsInitially) }
Surface(modifier = Modifier
@ -401,28 +449,38 @@ fun ActionBar(
.height(40.dp), color = MaterialTheme.colorScheme.background)
{
Row {
ExpandActionsButton(isActionsOpen.value) { isActionsOpen.value = !isActionsOpen.value }
if(isActionsOpen.value) {
LazyRow {
item {
ActionItems(onActionActivated)
}
ExpandActionsButton(isActionsOpen.value) {
isActionsOpen.value = !isActionsOpen.value
if(isActionsOpen.value && importantNotice != null) {
importantNotice.onDismiss(context)
}
} else if(inlineSuggestions.isNotEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
InlineSuggestions(inlineSuggestions)
} else if(words != null) {
SuggestionItems(words) {
suggestionStripListener.pickSuggestionManually(
words.getInfo(it)
)
}
} else {
Spacer(modifier = Modifier.weight(1.0f))
}
if(!isActionsOpen.value) {
ActionItemSmall(VoiceInputAction, onActionActivated)
if(importantNotice != null && !isActionsOpen.value) {
ImportantNoticeView(importantNotice)
}else {
if (isActionsOpen.value) {
LazyRow {
item {
ActionItems(onActionActivated)
}
}
} else if (inlineSuggestions.isNotEmpty() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
InlineSuggestions(inlineSuggestions)
} else if (words != null) {
SuggestionItems(words) {
suggestionStripListener.pickSuggestionManually(
words.getInfo(it)
)
}
} else {
Spacer(modifier = Modifier.weight(1.0f))
}
if (!isActionsOpen.value) {
ActionItemSmall(VoiceInputAction, onActionActivated)
}
}
}
}
@ -589,6 +647,34 @@ fun PreviewActionBarWithSuggestions(colorScheme: ColorScheme = DarkColorScheme)
}
}
@Composable
@Preview
fun PreviewActionBarWithNotice(colorScheme: ColorScheme = DarkColorScheme) {
UixThemeWrapper(colorScheme) {
ActionBar(
words = exampleSuggestedWords,
suggestionStripListener = ExampleListener(),
onActionActivated = { },
inlineSuggestions = listOf(),
importantNotice = object : ImportantNotice {
@Composable
override fun getText(): String {
return "Update available: v1.2.3"
}
override fun onDismiss(context: Context) {
TODO("Not yet implemented")
}
override fun onOpen(context: Context) {
TODO("Not yet implemented")
}
}
)
}
}
@Composable
@Preview
fun PreviewActionBarWithEmptySuggestions(colorScheme: ColorScheme = DarkColorScheme) {

View File

@ -1,6 +1,8 @@
package org.futo.inputmethod.latin.uix
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Build
import android.view.View
import android.view.inputmethod.InlineSuggestionsResponse
@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.ComposeView
@ -27,8 +30,12 @@ import org.futo.inputmethod.latin.common.Constants
import org.futo.inputmethod.latin.inputlogic.InputLogic
import org.futo.inputmethod.latin.suggestions.SuggestionStripView
import org.futo.inputmethod.latin.uix.actions.EmojiAction
import org.futo.inputmethod.latin.uix.settings.SettingsActivity
import org.futo.inputmethod.latin.uix.theme.ThemeOption
import org.futo.inputmethod.latin.uix.theme.UixThemeWrapper
import org.futo.inputmethod.latin.uix.voiceinput.downloader.DownloadActivity
import org.futo.inputmethod.updates.checkForUpdateAndSaveToPreferences
import org.futo.inputmethod.updates.retrieveSavedLastUpdateCheckResult
private class LatinIMEActionInputTransaction(
private val inputLogic: InputLogic,
@ -141,6 +148,7 @@ class UixManager(private val latinIME: LatinIME) {
private val keyboardManagerForAction = UixActionKeyboardManager(this, latinIME)
private var mainKeyboardHidden = false
private var currentNotice: MutableState<ImportantNotice?> = mutableStateOf(null)
var currWindowActionWindow: ActionWindow? = null
@ -172,7 +180,8 @@ class UixManager(private val latinIME: LatinIME) {
suggestedWordsOrNull,
latinIME.latinIMELegacy as SuggestionStripView.Listener,
inlineSuggestions = inlineSuggestions,
onActionActivated = { onActionActivated(it) }
onActionActivated = { onActionActivated(it) },
importantNotice = currentNotice.value
)
}
}
@ -286,6 +295,35 @@ class UixManager(private val latinIME: LatinIME) {
}
}
suspend fun showUpdateNoticeIfNeeded() {
val updateInfo = retrieveSavedLastUpdateCheckResult(latinIME)
if(updateInfo != null && updateInfo.isNewer()) {
currentNotice.value = object : ImportantNotice {
@Composable
override fun getText(): String {
return "Update available: ${updateInfo.nextVersionString}"
}
override fun onDismiss(context: Context) {
currentNotice.value = null
}
override fun onOpen(context: Context) {
currentNotice.value = null
val intent = Intent(context, SettingsActivity::class.java)
intent.putExtra("navDest", "update")
if(context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
}
}
}
fun createComposeView(): View {
if(composeView != null) {
composeView = null

View File

@ -167,6 +167,17 @@ class SettingsActivity : ComponentActivity() {
this.themeOption.value = themeOption
}
val intent = intent
if(intent != null) {
val destination = intent.getStringExtra("navDest")
if(destination != null) {
lifecycleScope.launch {
delay(1000L)
navController.navigate(destination)
}
}
}
}
override fun onResume() {

View File

@ -57,6 +57,9 @@ fun SettingsNavigator(
navController
)
}
dialog("update") {
UpdateDialog(navController = navController)
}
addModelManagerNavigation(navController)
}
}

View File

@ -0,0 +1,215 @@
package org.futo.inputmethod.latin.uix.settings
import android.app.PendingIntent.FLAG_MUTABLE
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.app.PendingIntent.getBroadcast
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.futo.inputmethod.latin.R
import org.futo.inputmethod.latin.uix.InfoDialog
import org.futo.inputmethod.latin.uix.getSetting
import org.futo.inputmethod.updates.InstallReceiver
import org.futo.inputmethod.updates.LAST_UPDATE_CHECK_RESULT
import org.futo.inputmethod.updates.UpdateResult
import java.io.InputStream
import java.io.OutputStream
private fun InputStream.copyToOutputStream(inputStreamLength: Long, outputStream: OutputStream, onProgress: (Float) -> Unit) {
val buffer = ByteArray(16384);
var n: Int;
var total = 0;
val inputStreamLengthFloat = inputStreamLength.toFloat();
while (read(buffer).also { n = it } >= 0) {
total += n;
outputStream.write(buffer, 0, n);
onProgress.invoke(total.toFloat() / inputStreamLengthFloat);
}
}
private suspend fun install(scope: CoroutineScope, context: Context, inputStream: InputStream, dataLength: Long, updateStatusText: (String) -> Unit) {
var lastProgressText = "";
var session: PackageInstaller.Session? = null;
try {
//Log.i("UpdateScreen", "Hooked InstallReceiver.onReceiveResult.")
InstallReceiver.onReceiveResult.onEach { message -> updateStatusText("Fatal error: $message") }.launchIn(scope)
val packageInstaller: PackageInstaller = context.packageManager.packageInstaller;
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
val sessionId = packageInstaller.createSession(params);
session = packageInstaller.openSession(sessionId)
session.openWrite("package", 0, dataLength).use { sessionStream ->
inputStream.copyToOutputStream(dataLength, sessionStream) { progress ->
val progressText = "${(progress * 100.0f).toInt()}%";
if (lastProgressText != progressText) {
lastProgressText = progressText;
//TODO: Use proper scope
//GlobalScope.launch(Dispatchers.Main) {
//_textProgress.text = progressText;
//};
}
}
session.fsync(sessionStream);
};
val intent = Intent(context, InstallReceiver::class.java);
val pendingIntent = getBroadcast(context, 0, intent, FLAG_MUTABLE or FLAG_UPDATE_CURRENT);
val statusReceiver = pendingIntent.intentSender;
session.commit(statusReceiver);
session.close();
withContext(Dispatchers.Main) {
updateStatusText("Installing update")
//_textProgress.text = "";
//_text.text = context.resources.getText(R.string.installing_update);
}
} catch (e: Throwable) {
Log.w("UpdateScreen", "Exception thrown while downloading and installing latest version of app.", e);
session?.abandon();
withContext(Dispatchers.Main) {
updateStatusText("Failed to install update")
}
} finally {
withContext(Dispatchers.Main) {
Log.i("UpdateScreen", "Keep screen on unset install")
//window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
}
@DelicateCoroutinesApi
private suspend fun downloadAndInstall(scope: CoroutineScope, context: Context, updateResult: UpdateResult, updateStatusText: (String) -> Unit) = GlobalScope.launch(Dispatchers.IO) {
var inputStream: InputStream? = null;
try {
val httpClient = OkHttpClient()
val request = Request.Builder().method("GET", null).url(updateResult.apkUrl).build()
val response = httpClient.newCall(request).execute()
val body = response.body
if (response.isSuccessful && body != null) {
inputStream = body.byteStream();
val dataLength = body.contentLength();
install(scope, context, inputStream, dataLength, updateStatusText)
} else {
throw Exception("Failed to download latest version of app.");
}
} catch (e: Throwable) {
Log.w("UpdateScreen", "Exception thrown while downloading and installing latest version of app.", e);
withContext(Dispatchers.Main) {
updateStatusText("Failed to download update: ${e.message}");
}
} finally {
inputStream?.close();
}
}
@Composable
fun UpdateDialog(navController: NavHostController) {
val scope = LocalLifecycleOwner.current
val context = LocalContext.current
val updateInfo = remember { runBlocking {
context.getSetting(LAST_UPDATE_CHECK_RESULT, "")
} }
val lastUpdateResult = if(!LocalInspectionMode.current){
remember { UpdateResult.fromString(updateInfo) }
} else {
UpdateResult(123, "abc", "1.2.3")
}
val isDownloading = remember { mutableStateOf(false) }
val showSpinner = remember { mutableStateOf(true) }
val statusText = remember { mutableStateOf("Downloading ${lastUpdateResult?.nextVersionString}") }
if(lastUpdateResult == null || !lastUpdateResult.isNewer()) {
InfoDialog(title = "Up-to-date", body = "As of the last update check, the app is up to date.")
} else {
AlertDialog(
icon = {
Icon(Icons.Filled.Info, contentDescription = "Info")
},
title = {
Text(text = "Update available")
},
text = {
if(isDownloading.value) {
Column(modifier = Modifier.fillMaxWidth()) {
if(showSpinner.value) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.CenterHorizontally),
color = MaterialTheme.colorScheme.onSurface
)
}
Text(text = statusText.value)
}
} else {
Text(text = "A new version ${lastUpdateResult.nextVersionString} is available, would you like to update?")
}
},
onDismissRequest = {
if(!isDownloading.value) {
navController.navigateUp()
}
},
confirmButton = {
TextButton(onClick = {
isDownloading.value = true
GlobalScope.launch { downloadAndInstall(scope.lifecycleScope, context, lastUpdateResult) {
statusText.value = it
showSpinner.value = false
} }
}, enabled = !isDownloading.value) {
Text(stringResource(R.string.update))
}
},
dismissButton = {
TextButton(onClick = {
navController.navigateUp()
}, enabled = !isDownloading.value) {
Text(stringResource(R.string.dismiss))
}
}
)
}
}

View File

@ -31,7 +31,7 @@ fun HomeScreen(navController: NavHostController = rememberNavController()) {
Spacer(modifier = Modifier.height(24.dp))
ScreenTitle("FUTO Keyboard Settings")
ConditionalUpdate()
ConditionalUpdate(navController)
NavigationItem(
title = "Languages",

View File

@ -0,0 +1,57 @@
package org.futo.inputmethod.updates
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.util.Log
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
class InstallReceiver : BroadcastReceiver() {
private val TAG = "InstallReceiver"
companion object {
val onReceiveResult = MutableSharedFlow<String>(0)
}
override fun onReceive(context: Context, intent: Intent) {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
Log.i(TAG, "Received status $status.")
GlobalScope.launch {
when (status) {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
val activityIntent: Intent? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
if (activityIntent == null) {
Log.w(TAG, "Received STATUS_PENDING_USER_ACTION and activity intent is null.")
return@launch
}
context.startActivity(activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
}
PackageInstaller.STATUS_SUCCESS -> onReceiveResult.emit("Success!")
PackageInstaller.STATUS_FAILURE -> onReceiveResult.emit("General failure")
PackageInstaller.STATUS_FAILURE_ABORTED -> onReceiveResult.emit("The operation failed because it was actively aborted")
PackageInstaller.STATUS_FAILURE_BLOCKED -> onReceiveResult.emit("The operation failed because it was blocked")
PackageInstaller.STATUS_FAILURE_CONFLICT -> onReceiveResult.emit("The operation failed because it conflicts (or is inconsistent with) with another package already installed on the device")
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> onReceiveResult.emit("The operation failed because it is fundamentally incompatible with this device")
PackageInstaller.STATUS_FAILURE_INVALID -> onReceiveResult.emit("The operation failed because one or more of the APKs was invalid")
PackageInstaller.STATUS_FAILURE_STORAGE -> onReceiveResult.emit("The operation failed because of storage issues")
else -> {
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
if(msg != null) {
onReceiveResult.emit(msg)
}
}
}
}
}
}

View File

@ -13,6 +13,7 @@ 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 androidx.navigation.NavHostController
import org.futo.inputmethod.latin.uix.settings.SettingItem
import org.futo.inputmethod.latin.uix.settings.useDataStore
@ -33,7 +34,7 @@ fun Context.openURI(uri: String, newTask: Boolean = false) {
@Composable
@Preview
fun ConditionalUpdate() {
fun ConditionalUpdate(navController: NavHostController) {
val (updateInfo, _) = useDataStore(key = LAST_UPDATE_CHECK_RESULT, default = "")
val lastUpdateResult = if(!LocalInspectionMode.current){
@ -48,7 +49,8 @@ fun ConditionalUpdate() {
title = "Update Available",
subtitle = "${UpdateResult.currentVersionString()} -> ${lastUpdateResult.nextVersionString}",
onClick = {
context.openURI(lastUpdateResult.apkUrl)
navController.navigate("update")
//context.openURI(lastUpdateResult.apkUrl)
}
) {
Icon(Icons.Default.ArrowForward, contentDescription = "Go")

View File

@ -30,7 +30,6 @@ suspend fun checkForUpdate(): UpdateResult? {
val response = httpClient.newCall(request).execute()
val body = response.body
val result = if (body != null) {
val data = body.string().lines()
body.closeQuietly()
@ -39,15 +38,18 @@ suspend fun checkForUpdate(): UpdateResult? {
val latestVersionUrl = data[1]
val latestVersionString = data[2]
if(latestVersionUrl.startsWith("https://voiceinput.futo.org/") || latestVersionUrl.startsWith("https://keyboard.futo.org/")){
Log.d("UpdateChecking", "Retrieved update for version ${latestVersionString}")
UpdateResult(
nextVersion = latestVersion,
apkUrl = latestVersionUrl,
nextVersionString = latestVersionString
)
} else {
Log.e("UpdateChecking", "Update URL contains unknown prefix: ${latestVersionUrl}")
null
}
} else {
Log.e("UpdateChecking", "Body of result is null")
null
}
@ -55,6 +57,8 @@ suspend fun checkForUpdate(): UpdateResult? {
result
} catch (e: Exception) {
Log.e("UpdateChecking", "Checking update failed with exception")
e.printStackTrace()
null
}
}

View File

@ -1,5 +1,6 @@
package org.futo.inputmethod.updates
import android.util.Log
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
@ -32,8 +33,12 @@ data class UpdateResult(
try {
return Json.decodeFromString<UpdateResult>(value)
} catch(e: SerializationException) {
Log.e("UpdateResult", "Failed to deserialize UpdateResult value $value")
e.printStackTrace()
return null
} catch(e: IllegalArgumentException) {
Log.e("UpdateResult", "Failed to deserialize UpdateResult value $value")
e.printStackTrace()
return null
}
}