Add model downloader activity for voice input

This commit is contained in:
Aleksandras Kostarevas 2023-09-04 17:30:18 +03:00
parent 94c606718d
commit 2088909f88
9 changed files with 475 additions and 99 deletions

View File

@ -163,6 +163,8 @@ dependencies {
implementation 'ch.acra:acra-http:5.11.1'
implementation 'ch.acra:acra-dialog:5.11.1'
implementation 'com.squareup.okhttp3:okhttp:4.11.0'
implementation project(":voiceinput-shared")
debugImplementation 'androidx.compose.ui:ui-tooling'

View File

@ -100,6 +100,14 @@
</intent-filter>
</activity>
<activity
android:name=".uix.voiceinput.downloader.DownloadActivity"
android:exported="false"
android:label="@string/model_downloader"
android:clearTaskOnLaunch="true"
android:launchMode="singleTask"
android:theme="@style/Theme.AppCompat.DayNight.NoActionBar" />
<activity android:name=".permissions.PermissionsActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:exported="false"

View File

@ -11,4 +11,14 @@
<string name="dynamic_system_theme_name">Dynamic System</string>
<string name="dynamic_light_theme_name">Dynamic Light</string>
<string name="dynamic_dark_theme_name">Dynamic Dark</string>
<string name="download_required">Voice Input Model Downloader</string>
<string name="download_required_body">Download of some model files is necessary for voice input functionality. This may incur data fees if you are using Mobile Data instead of Wi-Fi.</string>
<string name="continue_">Continue</string>
<string name="download_progress">Voice Input Model Download Progress</string>
<string name="download_failed">Download of one or more resources has failed. Please make sure you\'re connected to a network, the app has network permission, or try again later.</string>
<string name="download_in_progress">Downloading models…</string>
<string name="model_downloader">Model Downloader</string>
</resources>

View File

@ -85,12 +85,12 @@ import org.futo.inputmethod.latin.inputlogic.InputLogic;
import org.futo.inputmethod.latin.permissions.PermissionsManager;
import org.futo.inputmethod.latin.personalization.PersonalizationHelper;
import org.futo.inputmethod.latin.settings.Settings;
import org.futo.inputmethod.latin.settings.SettingsActivity;
import org.futo.inputmethod.latin.settings.SettingsValues;
import org.futo.inputmethod.latin.suggestions.SuggestionStripView;
import org.futo.inputmethod.latin.suggestions.SuggestionStripViewAccessor;
import org.futo.inputmethod.latin.touchinputconsumer.GestureConsumer;
import org.futo.inputmethod.latin.uix.DynamicThemeProviderOwner;
import org.futo.inputmethod.latin.uix.settings.SettingsActivity;
import org.futo.inputmethod.latin.utils.ApplicationUtils;
import org.futo.inputmethod.latin.utils.DialogUtils;
import org.futo.inputmethod.latin.utils.ImportantNoticeUtils;
@ -1803,8 +1803,8 @@ public class LatinIMELegacy implements KeyboardActionListener,
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtra(SettingsActivity.EXTRA_SHOW_HOME_AS_UP, false);
intent.putExtra(SettingsActivity.EXTRA_ENTRY_KEY, extraEntryValue);
//intent.putExtra(SettingsActivity.EXTRA_SHOW_HOME_AS_UP, false);
//intent.putExtra(SettingsActivity.EXTRA_ENTRY_KEY, extraEntryValue);
startActivityOnTheSameDisplay(intent);
}
@ -1832,7 +1832,7 @@ public class LatinIMELegacy implements KeyboardActionListener,
startActivityOnTheSameDisplay(intent);
break;
case 1:
launchSettings(SettingsActivity.EXTRA_ENTRY_VALUE_LONG_PRESS_COMMA);
launchSettings("");
break;
}
}

View File

@ -1,88 +0,0 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.futo.inputmethod.latin.settings;
import org.futo.inputmethod.latin.permissions.PermissionsManager;
import org.futo.inputmethod.latin.utils.FragmentUtils;
import org.futo.inputmethod.latin.utils.StatsUtils;
import org.futo.inputmethod.latin.utils.StatsUtilsManager;
import android.app.ActionBar;
import android.content.Intent;
import android.os.Bundle;
import android.preference.PreferenceActivity;
import androidx.core.app.ActivityCompat;
import android.view.MenuItem;
public final class SettingsActivity extends PreferenceActivity
implements ActivityCompat.OnRequestPermissionsResultCallback {
private static final String DEFAULT_FRAGMENT = SettingsFragment.class.getName();
public static final String EXTRA_SHOW_HOME_AS_UP = "show_home_as_up";
public static final String EXTRA_ENTRY_KEY = "entry";
public static final String EXTRA_ENTRY_VALUE_LONG_PRESS_COMMA = "long_press_comma";
public static final String EXTRA_ENTRY_VALUE_APP_ICON = "app_icon";
public static final String EXTRA_ENTRY_VALUE_NOTICE_DIALOG = "important_notice";
public static final String EXTRA_ENTRY_VALUE_SYSTEM_SETTINGS = "system_settings";
private boolean mShowHomeAsUp;
@Override
protected void onCreate(final Bundle savedState) {
super.onCreate(savedState);
final ActionBar actionBar = getActionBar();
final Intent intent = getIntent();
if (actionBar != null) {
mShowHomeAsUp = intent.getBooleanExtra(EXTRA_SHOW_HOME_AS_UP, true);
actionBar.setDisplayHomeAsUpEnabled(mShowHomeAsUp);
actionBar.setHomeButtonEnabled(mShowHomeAsUp);
}
StatsUtils.onSettingsActivity(
intent.hasExtra(EXTRA_ENTRY_KEY) ? intent.getStringExtra(EXTRA_ENTRY_KEY)
: EXTRA_ENTRY_VALUE_SYSTEM_SETTINGS);
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (mShowHomeAsUp && item.getItemId() == android.R.id.home) {
finish();
return true;
}
return super.onOptionsItemSelected(item);
}
@Override
public Intent getIntent() {
final Intent intent = super.getIntent();
final String fragment = intent.getStringExtra(EXTRA_SHOW_FRAGMENT);
if (fragment == null) {
intent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT);
}
intent.putExtra(EXTRA_NO_HEADERS, true);
return intent;
}
@Override
public boolean isValidFragment(final String fragmentName) {
return FragmentUtils.isValidFragment(fragmentName);
}
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
PermissionsManager.get(this).onRequestPermissionsResult(requestCode, permissions, grantResults);
}
}

View File

@ -29,6 +29,7 @@ import android.view.MenuItem;
import org.futo.inputmethod.latin.R;
import org.futo.inputmethod.latin.define.ProductionFlags;
import org.futo.inputmethod.latin.uix.settings.SettingsActivity;
import org.futo.inputmethod.latin.utils.ApplicationUtils;
import org.futo.inputmethod.latin.utils.FeedbackUtils;
import org.futo.inputmethodcommon.InputMethodSettingsFragment;

View File

@ -36,7 +36,7 @@ import android.widget.VideoView;
import org.futo.inputmethod.compat.TextViewCompatUtils;
import org.futo.inputmethod.compat.ViewCompatUtils;
import org.futo.inputmethod.latin.R;
import org.futo.inputmethod.latin.settings.SettingsActivity;
import org.futo.inputmethod.latin.uix.settings.SettingsActivity;
import org.futo.inputmethod.latin.utils.LeakGuardHandlerWrapper;
import org.futo.inputmethod.latin.utils.UncachedInputMethodManagerUtils;
@ -265,8 +265,8 @@ public final class SetupWizardActivity extends Activity implements View.OnClickL
intent.setClass(this, SettingsActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
| Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtra(SettingsActivity.EXTRA_ENTRY_KEY,
SettingsActivity.EXTRA_ENTRY_VALUE_APP_ICON);
//intent.putExtra(SettingsActivity.EXTRA_ENTRY_KEY,
// SettingsActivity.EXTRA_ENTRY_VALUE_APP_ICON);
startActivity(intent);
}

View File

@ -1,5 +1,7 @@
package org.futo.inputmethod.latin.uix.actions
import android.app.Activity
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@ -12,6 +14,7 @@ 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.res.stringResource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@ -34,6 +37,7 @@ import org.futo.inputmethod.latin.uix.MULTILINGUAL_MODEL_INDEX
import org.futo.inputmethod.latin.uix.PersistentActionState
import org.futo.inputmethod.latin.uix.VERBOSE_PROGRESS
import org.futo.inputmethod.latin.uix.getSetting
import org.futo.inputmethod.latin.uix.voiceinput.downloader.DownloadActivity
import org.futo.voiceinput.shared.ENGLISH_MODELS
import org.futo.voiceinput.shared.MULTILINGUAL_MODELS
import org.futo.voiceinput.shared.ModelDoesNotExistException
@ -155,10 +159,18 @@ private class VoiceInputActionWindow(
@Composable
private fun ModelDownloader(modelException: ModelDoesNotExistException) {
Column {
Text("Model Download Required")
Text("Not yet implemented")
// TODO
val context = LocalContext.current
Box(modifier = Modifier.fillMaxSize().clickable {
val intent = Intent(context, DownloadActivity::class.java)
intent.putStringArrayListExtra("models", ArrayList(modelException.models.map { model -> model.getRequiredDownloadList(context) }.flatten()))
if(context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}) {
Text("Tap to complete setup", modifier = Modifier.align(Alignment.Center))
}
}

View File

@ -0,0 +1,431 @@
package org.futo.inputmethod.latin.uix.voiceinput.downloader
// TODO: Rework this
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment.Companion.CenterVertically
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.futo.inputmethod.latin.R
import org.futo.inputmethod.latin.uix.THEME_KEY
import org.futo.inputmethod.latin.uix.deferGetSetting
import org.futo.inputmethod.latin.uix.settings.useDataStore
import org.futo.inputmethod.latin.uix.theme.ThemeOption
import org.futo.inputmethod.latin.uix.theme.ThemeOptions
import org.futo.inputmethod.latin.uix.theme.UixThemeWrapper
import org.futo.inputmethod.latin.uix.theme.presets.VoiceInputTheme
import org.futo.voiceinput.shared.ui.theme.Typography
import java.io.File
import java.io.IOException
data class ModelInfo(
val name: String,
val url: String,
var size: Long?,
var progress: Float = 0.0f,
var error: Boolean = false,
var finished: Boolean = false
)
val EXAMPLE_MODELS = listOf(
ModelInfo(
name = "tiny-encoder-xatn.tflite",
url = "example.com",
size = 56L * 1024L * 1024L,
progress = 0.5f,
error = true
),
ModelInfo(
name = "tiny-decoder.tflite",
url = "example.com",
size = 73L * 1024L * 1024L,
progress = 0.3f,
error = false
),
)
@Composable
fun ModelItem(model: ModelInfo, showProgress: Boolean) {
Column(modifier = Modifier.padding(4.dp)) {
val color = if (model.error) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.primaryContainer
}
Surface(modifier = Modifier, color = color, shape = RoundedCornerShape(4.dp)) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
if (model.error) {
Icon(
Icons.Default.Warning, contentDescription = "Failed", modifier = Modifier
.align(CenterVertically)
.padding(4.dp)
)
}
val size = if (model.size != null) {
"%.1f".format(model.size!!.toFloat() / 1000000.0f)
} else {
"?"
}
Column {
Text(model.name, style = Typography.bodyLarge)
Text(
"$size MB",
style = Typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
if (showProgress && !model.error) {
LinearProgressIndicator(
progress = model.progress, modifier = Modifier
.fillMaxWidth()
.padding(0.dp, 8.dp),
color = MaterialTheme.colorScheme.onPrimary
)
}
}
}
}
}
}
@Composable
fun ScreenTitle(title: String, showBack: Boolean = false, navController: NavHostController = rememberNavController()) {
val rowModifier = if(showBack) {
Modifier
.fillMaxWidth()
.clickable { navController.popBackStack() }
} else {
Modifier.fillMaxWidth()
}
Row(modifier = rowModifier) {
Spacer(modifier = Modifier.width(16.dp))
if(showBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back", modifier = Modifier.align(CenterVertically))
Spacer(modifier = Modifier.width(18.dp))
}
Text(title, style = Typography.titleLarge, modifier = Modifier
.align(CenterVertically)
.padding(0.dp, 16.dp))
}
}
@Composable
fun ScrollableList(content: @Composable () -> Unit) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
) {
content()
}
}
@Composable
@Preview
fun DownloadPrompt(
onContinue: () -> Unit = {},
onCancel: () -> Unit = {},
models: List<ModelInfo> = EXAMPLE_MODELS
) {
ScrollableList {
ScreenTitle(title = stringResource(R.string.download_required))
Text(
stringResource(R.string.download_required_body),
style = Typography.bodyMedium
)
Spacer(modifier = Modifier.height(8.dp))
for(model in models) {
ModelItem(model, showProgress = false)
}
Spacer(modifier = Modifier.height(8.dp))
Row {
Button(
onClick = onCancel, colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary,
contentColor = MaterialTheme.colorScheme.onSecondary
), modifier = Modifier
.padding(8.dp)
.weight(1.0f)
) {
Text(stringResource(R.string.cancel))
}
Button(
onClick = onContinue, modifier = Modifier
.padding(8.dp)
.weight(1.5f)
) {
Text(stringResource(R.string.continue_))
}
}
}
}
@Composable
@Preview
fun DownloadScreen(models: List<ModelInfo> = EXAMPLE_MODELS) {
ScrollableList {
ScreenTitle(stringResource(R.string.download_progress))
if (models.any { it.error }) {
Text(
stringResource(R.string.download_failed),
style = Typography.bodyMedium
)
} else {
Text(
stringResource(R.string.download_in_progress),
style = Typography.bodyMedium
)
}
Spacer(modifier = Modifier.height(8.dp))
for(model in models) {
ModelItem(model, showProgress = true)
}
}
}
fun Context.fileNeedsDownloading(file: String): Boolean {
return !File(this.filesDir, file).exists()
}
class DownloadActivity : ComponentActivity() {
private lateinit var modelsToDownload: List<ModelInfo>
private val httpClient = OkHttpClient()
private var isDownloading = false
private val themeOption: MutableState<ThemeOption?> = mutableStateOf(null)
private fun updateContent() {
setContent {
themeOption.value?.let { themeOption ->
val themeIdx = useDataStore(key = THEME_KEY.key, default = themeOption.key)
val theme: ThemeOption = ThemeOptions[themeIdx.value] ?: themeOption
UixThemeWrapper(theme.obtainColors(LocalContext.current)) {
// A surface container using the 'background' color from the theme
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
if (isDownloading) {
DownloadScreen(models = modelsToDownload)
} else {
DownloadPrompt(
onContinue = { startDownload() },
onCancel = { cancel() },
models = modelsToDownload
)
}
}
}
}
}
}
private fun startDownload() {
isDownloading = true
updateContent()
modelsToDownload.forEach {
val request = Request.Builder().method("GET", null).url(it.url).build()
httpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
it.error = true
updateContent()
}
override fun onResponse(call: Call, response: Response) {
response.body?.source()?.let { source ->
try {
it.size = response.headers["content-length"]!!.toLong()
} catch (e: Exception) {
println("url failed ${it.url}")
println(response.headers)
e.printStackTrace()
}
val fileName = it.name + ".download"
val file =
File.createTempFile(fileName, null, this@DownloadActivity.cacheDir)
val os = file.outputStream()
val buffer = ByteArray(128 * 1024)
var downloaded = 0
while (true) {
val read = source.read(buffer)
if (read == -1) {
break
}
os.write(buffer.sliceArray(0 until read))
downloaded += read
if (it.size != null) {
it.progress = downloaded.toFloat() / it.size!!.toFloat()
}
lifecycleScope.launch {
withContext(Dispatchers.Main) {
updateContent()
}
}
}
it.finished = true
it.progress = 1.0f
os.flush()
os.close()
assert(file.renameTo(File(this@DownloadActivity.filesDir, it.name)))
if (modelsToDownload.all { a -> a.finished }) {
downloadsFinished()
}
}
}
})
}
}
private fun cancel() {
val returnIntent = Intent()
setResult(RESULT_CANCELED, returnIntent)
finish()
}
private fun downloadsFinished() {
val returnIntent = Intent()
setResult(RESULT_OK, returnIntent)
finish()
}
private fun obtainModelSizes() {
modelsToDownload.forEach {
val request =
Request.Builder().method("HEAD", null).header("accept-encoding", "identity")
.url(it.url).build()
httpClient.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
it.error = true
updateContent()
}
override fun onResponse(call: Call, response: Response) {
try {
it.size = response.headers["content-length"]!!.toLong()
} catch (e: Exception) {
println("url failed ${it.url}")
println(response.headers)
e.printStackTrace()
it.error = true
}
if (response.code != 200) {
println("Bad response code ${response.code}")
it.error = true
}
updateContent()
}
})
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val models = intent.getStringArrayListExtra("models")
?: throw IllegalStateException("intent extra `models` must be specified for DownloadActivity")
modelsToDownload = models.distinct().filter { this.fileNeedsDownloading(it) }.map {
ModelInfo(
name = it,
url = "https://voiceinput.futo.org/VoiceInput/${it}",
size = null,
progress = 0.0f
)
}
if (modelsToDownload.isEmpty()) {
cancel()
}
isDownloading = false
deferGetSetting(THEME_KEY) {
val themeOptionFromSettings = ThemeOptions[it]
val themeOption = when {
themeOptionFromSettings == null -> VoiceInputTheme
!themeOptionFromSettings.available(this) -> VoiceInputTheme
else -> themeOptionFromSettings
}
this.themeOption.value = themeOption
}
updateContent()
obtainModelSizes()
}
}