diff --git a/app/build.gradle b/app/build.gradle index 25dd77d..1e4afe3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -30,13 +30,13 @@ android { buildFeatures { viewBinding true } + namespace 'org.vosk.ime' } dependencies { implementation 'androidx.appcompat:appcompat:1.5.1' implementation 'net.java.dev.jna:jna:5.9.0@aar' implementation group: 'com.alphacephei', name: 'vosk-android', version: '0.3.32' - implementation project(':models') implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.google.android.material:material:1.6.1' implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 10721d9..fbfcf5c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,18 +1,19 @@ + xmlns:tools="http://schemas.android.com/tools"> + + + + \ No newline at end of file diff --git a/app/src/main/java/org/vosk/ime/Constants.java b/app/src/main/java/org/vosk/ime/Constants.java new file mode 100644 index 0000000..ac7c09a --- /dev/null +++ b/app/src/main/java/org/vosk/ime/Constants.java @@ -0,0 +1,26 @@ +package org.vosk.ime; + +import android.content.Context; + +import java.io.File; +import java.util.Locale; + +public class Constants { + public static File getTemporaryDownloadLocation(Context context){ + return new File(context.getCacheDir().getAbsolutePath(), "ModelZips"); + } + + public static File getTemporaryUnzipLocation(Context context) { + return new File(context.getCacheDir(), "TempUnzip"); + } + + public static File getModelsDirectory(Context context){ + return new File(context.getFilesDir().getAbsolutePath(), "Models"); + } + + public static File getDirectoryForModel(Context context, Locale locale){ + File dataFolder = Constants.getModelsDirectory(context); + String folderName = locale.toLanguageTag(); + return new File(dataFolder, folderName); + } +} diff --git a/app/src/main/java/org/vosk/ime/FileDownloadService.java b/app/src/main/java/org/vosk/ime/FileDownloadService.java index eebada3..9bdee67 100644 --- a/app/src/main/java/org/vosk/ime/FileDownloadService.java +++ b/app/src/main/java/org/vosk/ime/FileDownloadService.java @@ -113,16 +113,22 @@ protected void onHandleIntent(Intent intent) { if (downloadDetails.isRequiresUnzip()) { - String unzipDestination = downloadDetails.getUnzipAtFilePath(); - - if (unzipDestination == null) { + File unzipDestination; + if (downloadDetails.getUnzipAtFilePath() == null) { File file = new File(localPath); - unzipDestination = file.getParentFile().getAbsolutePath(); + unzipDestination = file.getParentFile(); + } else { + + unzipDestination = new File(downloadDetails.getUnzipAtFilePath()); } - unzip(localPath, unzipDestination); + + File unzipFolder = Constants.getTemporaryUnzipLocation(this); + File currentUnzipFolder = new File(unzipFolder, unzipDestination.getName()); + + unzip(localPath, currentUnzipFolder, unzipDestination); } downloadCompleted(resultReceiver); @@ -170,24 +176,35 @@ public void downloadFailed(ResultReceiver resultReceiver) { resultReceiver.send(STATUS_FAILED, progressBundle); } - private void unzip(String zipFilePath, String unzipAtLocation) throws Exception { - + private void unzip(String zipFilePath, File tempUnzipLocation, File unzipFinalDestination) throws IOException { File archive = new File(zipFilePath); - try { + if (tempUnzipLocation.exists()) { + Tools.deleteRecursive(tempUnzipLocation); + } - ZipFile zipfile = new ZipFile(archive); + ZipFile zipfile = new ZipFile(archive); - for (Enumeration e = zipfile.entries(); e.hasMoreElements(); ) { + for (Enumeration e = zipfile.entries(); e.hasMoreElements(); ) { - ZipEntry entry = (ZipEntry) e.nextElement(); + ZipEntry entry = (ZipEntry) e.nextElement(); - unzipEntry(zipfile, entry, unzipAtLocation); + unzipEntry(zipfile, entry, tempUnzipLocation.getAbsolutePath()); + } + boolean moveSuccess; + if (unzipFinalDestination.exists()) { + moveSuccess = true; + for (File f : tempUnzipLocation.listFiles()) { + moveSuccess = f.renameTo(new File(unzipFinalDestination, f.getName())); + if (!moveSuccess) break; } + tempUnzipLocation.delete(); + } else { + moveSuccess = tempUnzipLocation.renameTo(unzipFinalDestination); + } - } catch (Exception e) { - - Log.e("Unzip zip", "Unzip exception", e); + if (!moveSuccess) { + throw new IOException("Renaming temporary unzip directory failed"); } } @@ -206,27 +223,13 @@ private void unzipEntry(ZipFile zipfile, ZipEntry entry, String outputDir) throw Log.v("ZIP E", "Extracting: " + entry); InputStream zin = zipfile.getInputStream(entry); - BufferedInputStream inputStream = new BufferedInputStream(zin); - BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(outputFile)); - - try { - - //IOUtils.copy(inputStream, outputStream); - - try { - for (int c = inputStream.read(); c != -1; c = inputStream.read()) { - outputStream.write(c); - } - - } finally { - - outputStream.close(); + try (BufferedInputStream inputStream = new BufferedInputStream(zin); BufferedOutputStream outputStream = new BufferedOutputStream(new FileOutputStream(outputFile))) { + byte[] b = new byte[1024]; + int n; + while ((n = inputStream.read(b, 0, 1024)) >= 0) { + outputStream.write(b, 0, n); } - - } finally { - outputStream.close(); - inputStream.close(); } } diff --git a/app/src/main/java/org/vosk/ime/Model.java b/app/src/main/java/org/vosk/ime/Model.java index 1cc8df2..ea11b19 100644 --- a/app/src/main/java/org/vosk/ime/Model.java +++ b/app/src/main/java/org/vosk/ime/Model.java @@ -1,19 +1,17 @@ package org.vosk.ime; -import android.text.TextUtils; - import java.io.Serializable; import java.util.Locale; public class Model implements Serializable { public final String path; public final Locale locale; - public final String name; + public final String filename; - public Model(String path, Locale locale, String name) { + public Model(String path, Locale locale, String filename) { this.path = path; this.locale = locale; - this.name = name; + this.filename = filename; } public String serialize() { @@ -23,7 +21,7 @@ public String serialize() { public static String serialize(Model model) { return "[path:\"" + encode(model.path) + "\", locale:\"" + model.locale + - "\", name:\"" + encode(model.name) + "\"]"; + "\", name:\"" + encode(model.filename) + "\"]"; } public static Model deserialize(String serialized) { diff --git a/app/src/main/java/org/vosk/ime/ModelLinks.java b/app/src/main/java/org/vosk/ime/ModelLink.java similarity index 89% rename from app/src/main/java/org/vosk/ime/ModelLinks.java rename to app/src/main/java/org/vosk/ime/ModelLink.java index 7681614..ee82846 100644 --- a/app/src/main/java/org/vosk/ime/ModelLinks.java +++ b/app/src/main/java/org/vosk/ime/ModelLink.java @@ -1,17 +1,20 @@ package org.vosk.ime; -import android.os.LocaleList; +import android.content.Context; import androidx.annotation.StringRes; +import java.util.ArrayList; +import java.util.List; import java.util.Locale; +import java.util.Map; // Locale list available at: https://stackoverflow.com/questions/7973023/what-is-the-list-of-supported-languages-locales-on-android /** - * + * */ -public enum ModelLinks { +public enum ModelLink { ENGLISH_US("https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip", Locale.US, R.string.model_en_us), ENGLISH_IN("https://alphacephei.com/vosk/models/vosk-model-small-en-in-0.4.zip", new Locale("en", "IN"), R.string.model_en_in), CHINESE("https://alphacephei.com/vosk/models/vosk-model-small-cn-0.22.zip", Locale.CHINESE, R.string.model_cn), @@ -38,9 +41,13 @@ public enum ModelLinks { public final Locale locale; public final int name; - ModelLinks(String link, Locale locale, @StringRes int name) { + ModelLink(String link, Locale locale, @StringRes int name) { this.link = link; this.locale = locale; this.name = name; } + + public String getFilename() { + return link.substring(link.lastIndexOf('/') + 1, link.lastIndexOf('.')); + } } diff --git a/app/src/main/java/org/vosk/ime/Tools.java b/app/src/main/java/org/vosk/ime/Tools.java index 97c1ac6..2322c85 100644 --- a/app/src/main/java/org/vosk/ime/Tools.java +++ b/app/src/main/java/org/vosk/ime/Tools.java @@ -9,6 +9,15 @@ import androidx.core.content.ContextCompat; +import org.vosk.ime.settingsfragments.ModelsAdapter; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + public class Tools { public static boolean isMicrophonePermissionGranted(Activity activity) { @@ -26,4 +35,123 @@ public static boolean isIMEEnabled(Activity activity) { } return false; } + + public static void downloadModelFromLink(ModelLink model, FileDownloadService.OnDownloadStatusListener listener, Context context) { + String serverFilePath = model.link; + + File tempFolder = Constants.getTemporaryDownloadLocation(context); + if (!tempFolder.exists()) { + tempFolder.mkdirs(); + } + + String fileName = model.link.substring(model.link.lastIndexOf('/') + 1); // file name + File tempFile = new File(tempFolder, fileName); + + String localPath = tempFile.getAbsolutePath(); + + File modelFolder = Constants.getDirectoryForModel(context, model.locale); + + if (!modelFolder.exists()) { + modelFolder.mkdirs(); + } + + String unzipPath = modelFolder.getAbsolutePath(); + + FileDownloadService.DownloadRequest downloadRequest = new FileDownloadService.DownloadRequest(serverFilePath, localPath, true); + downloadRequest.setRequiresUnzip(true); + downloadRequest.setDeleteZipAfterExtract(true); + downloadRequest.setUnzipAtFilePath(unzipPath); + + FileDownloadService.FileDownloader downloader = FileDownloadService.FileDownloader.getInstance(downloadRequest, listener); + downloader.download(context); + } + + public static void deleteRecursive(File fileOrDirectory) { + if (fileOrDirectory.isDirectory()) + for (File child : fileOrDirectory.listFiles()) + deleteRecursive(child); + + fileOrDirectory.delete(); + } + + public static Map> getInstalledModelsMap(Context context) { + Map> localeMap = new HashMap<>(); + + File modelsDir = Constants.getModelsDirectory(context); + + if (!modelsDir.exists()) return localeMap; + + for (File localeFolder : modelsDir.listFiles()) { + if (!localeFolder.isDirectory()) continue; + Locale locale = Locale.forLanguageTag(localeFolder.getName()); + List models = new ArrayList<>(); + for (File modelFolder : localeFolder.listFiles()) { + if (!modelFolder.isDirectory()) continue; + String name = modelFolder.getName(); + Model model = new Model(modelFolder.getAbsolutePath(), locale, name); + models.add(model); + } + localeMap.put(locale, models); + } + return localeMap; + } + + public static List getInstalledModelsList(Context context){ + List models = new ArrayList<>(); + + File modelsDir = Constants.getModelsDirectory(context); + + if (!modelsDir.exists()) return models; + + for (File localeFolder : modelsDir.listFiles()) { + if (!localeFolder.isDirectory()) continue; + Locale locale = Locale.forLanguageTag(localeFolder.getName()); + for (File modelFolder : localeFolder.listFiles()) { + if (!modelFolder.isDirectory()) continue; + String name = modelFolder.getName(); + Model model = new Model(modelFolder.getAbsolutePath(), locale, name); + models.add(model); + } + } + return models; + } + + public static List getModelsData(Context context) { + List data = new ArrayList<>(); + Map> installedModels = getInstalledModelsMap(context); + for (ModelLink link : ModelLink.values()) { + boolean found = false; + if (installedModels.containsKey(link.locale)) { + List localeModels = installedModels.get(link.locale); + for (int i = 0; i < localeModels.size(); i++) { + Model model = localeModels.get(i); + if (model.filename.equals(link.getFilename())) { + data.add(new ModelsAdapter.Data(link, model)); + localeModels.remove(i); + found = true; + break; + } + } + } + if (!found) + data.add(new ModelsAdapter.Data(link)); + } + for (List models : installedModels.values()) { + for (Model model : models) { + data.add(new ModelsAdapter.Data(model)); + } + } + + return data; + } + + public static Model getModelForLink(ModelLink modelLink, Context context) { + File modelsDir = Constants.getModelsDirectory(context); + File localeDir = new File(modelsDir, modelLink.locale.toLanguageTag()); + File modelDir = new File(localeDir, modelLink.getFilename()); + if (!localeDir.exists() || modelDir.exists() || !modelDir.isDirectory()) { + return null; + } + return new Model(modelDir.getAbsolutePath(), modelLink.locale, modelLink.getFilename()); + } } diff --git a/app/src/main/java/org/vosk/ime/VoskIME.java b/app/src/main/java/org/vosk/ime/VoskIME.java index 1a383f0..ebb2a30 100644 --- a/app/src/main/java/org/vosk/ime/VoskIME.java +++ b/app/src/main/java/org/vosk/ime/VoskIME.java @@ -16,24 +16,28 @@ import android.Manifest; import android.app.Dialog; +import android.content.DialogInterface; import android.content.pm.PackageManager; import android.inputmethodservice.InputMethodService; import android.os.Build; +import android.os.Handler; import android.os.IBinder; +import android.os.Looper; import android.text.method.ScrollingMovementMethod; -import android.util.DisplayMetrics; import android.util.Log; +import android.view.KeyEvent; import android.view.MotionEvent; import android.view.View; -import android.view.ViewGroup; import android.view.Window; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.ExtractedText; import android.view.inputmethod.ExtractedTextRequest; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; +import android.widget.Button; import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; @@ -45,13 +49,18 @@ import org.vosk.android.RecognitionListener; import org.vosk.android.SpeechService; import org.vosk.android.SpeechStreamService; -import org.vosk.android.StorageService; import java.io.IOException; - +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.content.res.AppCompatResources; import androidx.constraintlayout.widget.ConstraintLayout; -import androidx.constraintlayout.widget.Constraints; import androidx.core.content.ContextCompat; public class VoskIME extends InputMethodService implements @@ -69,19 +78,39 @@ public class VoskIME extends InputMethodService implements private Model model; private SpeechService speechService; private SpeechStreamService speechStreamService; + private EditorInfo editorInfo; + + private int enterAction = EditorInfo.IME_ACTION_UNSPECIFIED; + private TextView resultView; + private int currentState = STATE_START; + private String currentErrorMessage = ""; + private String loadedModel = ""; + private ConstraintLayout overlayView; private ImageButton micButton; private ImageView fabAnimation; + private Button modelButton; private InputMethodManager mInputMethodManager; + private List models; + private int currentModelIndex = 0; + @Override public void onCreate() { super.onCreate(); - initModel(); + + models = Tools.getInstalledModelsList(this); + + if (models.size() == 0) { + setErrorState("No Model installed!"); + } else { + currentModelIndex = 0; + initModel(models.get(0)); + } mInputMethodManager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE); } @@ -109,13 +138,37 @@ public void onInitializeInterface() { } } - private void initModel() { - StorageService.unpack(this, "model-en-us", "model", - (model) -> { - this.model = model; - setUiState(STATE_READY); - }, - (exception) -> setErrorState("Failed to unpack the model" + exception.getMessage())); + private final Executor executor = Executors.newSingleThreadExecutor(); + + private void initModel(org.vosk.ime.Model myModel) { + loadedModel = myModel.locale.getDisplayName(); + if (modelButton != null) + modelButton.setText(loadedModel); + setUiState(STATE_START); + + Handler handler = new Handler(Looper.getMainLooper()); + executor.execute(() -> { + Model model = new Model(myModel.path); + handler.post(() -> { + this.model = model; + setUiState(STATE_READY); + }); + }); +// StorageService.unpack(this, "model-en-us", "model", +// (model) -> { +// this.model = model; +// setUiState(STATE_READY); +// }, +// (exception) -> setErrorState("Failed to unpack the model" + exception.getMessage())); + } + + private void loadNextModel() { + if (models.size() == 0) return; + + currentModelIndex++; + if (currentModelIndex >= models.size()) + currentModelIndex = 0; + initModel(models.get(currentModelIndex)); } @Override @@ -123,9 +176,26 @@ public void onBindInput() { // when user first clicks e.g. in text field } + private static final int[] editorActions = new int[] + { + EditorInfo.IME_ACTION_UNSPECIFIED, + EditorInfo.IME_ACTION_NONE, EditorInfo.IME_ACTION_GO, EditorInfo.IME_ACTION_SEARCH, + EditorInfo.IME_ACTION_SEND, EditorInfo.IME_ACTION_NEXT, EditorInfo.IME_ACTION_DONE, + EditorInfo.IME_ACTION_PREVIOUS, + }; + @Override public void onStartInputView(EditorInfo info, boolean restarting) { // text input has started + this.editorInfo = info; + + int action = editorInfo.imeOptions & EditorInfo.IME_MASK_ACTION; + for (int a : editorActions) { + if (action == a) { + enterAction = action; + break; + } + } } @Override @@ -140,6 +210,7 @@ public View onCreateInputView() { resultView = overlayView.findViewById(R.id.result_text); micButton = overlayView.findViewById(R.id.mic_button); + modelButton = overlayView.findViewById(R.id.model_button); overlayView.setMinHeight(convertDpToPixel(300)); @@ -147,7 +218,10 @@ public View onCreateInputView() { resultView.setMovementMethod(new ScrollingMovementMethod()); // Setup layout - setUiState(STATE_START); + setUiState(currentState); + if (!currentErrorMessage.isEmpty()) { + setErrorState(currentErrorMessage); + } // overlayView.findViewById(R.id.recognize_file).setOnClickListener(new View.OnClickListener() { // @Override @@ -159,7 +233,8 @@ public View onCreateInputView() { micButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { - recognizeMicrophone(); + if (model != null) + recognizeMicrophone(); } }); @@ -268,29 +343,61 @@ public boolean onTouch(View v, MotionEvent event) { // appendSpecial("."); // } // }); - overlayView.findViewById(R.id.return_button). + modelButton.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { +// showModelPicker(); + loadNextModel(); + } + }); + modelButton.setText(loadedModel); - setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - appendSpecial("\n"); - } - }); + overlayView.findViewById(R.id.return_button).setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + sendEnter(); + } + }); InputConnection ic = getCurrentInputConnection(); - ExtractedText et = ic.getExtractedText(new ExtractedTextRequest(), 0); - if (et != null) { - selectionStart = et.selectionStart; - selectionEnd = et.selectionEnd; - } else { - selectionStart = 0; - selectionEnd = 0; + if (ic != null) { + ExtractedText et = ic.getExtractedText(new ExtractedTextRequest(), 0); + if (et != null) { + selectionStart = et.selectionStart; + selectionEnd = et.selectionEnd; + } else { + selectionStart = 0; + selectionEnd = 0; + } } return overlayView; } + private void showModelPicker() { + // setup the alert builder + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.model_dialog_title); + + models = Tools.getInstalledModelsList(this); + + String[] names = new String[models.size()]; + for (int i = 0; i < models.size(); i++) { + names[i] = models.get(i).locale.getDisplayLanguage(); + } + + builder.setItems(names, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + initModel(models.get(which)); + } + }); + +// create and show the alert dialog + AlertDialog dialog = builder.show(); + } + @Override public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd, int candidatesStart, int candidatesEnd) { super.onUpdateSelection(oldSelStart, oldSelEnd, newSelStart, newSelEnd, candidatesStart, candidatesEnd); @@ -331,10 +438,21 @@ private void deleteLastChar() { } } + private void sendEnter() { + InputConnection ic = getCurrentInputConnection(); + if (ic == null) return; + + if (enterAction == EditorInfo.IME_ACTION_UNSPECIFIED) { + sendDownUpKeyEvents(KeyEvent.KEYCODE_ENTER); + } else { + ic.performEditorAction(enterAction); + } + } + private void appendSpecial(String text) { InputConnection ic = getCurrentInputConnection(); - if (ic != null) - ic.commitText(text, 1); + if (ic == null) return; + ic.commitText(text, 1); } /** @@ -494,14 +612,20 @@ private void setUiState(int state) { default: return; } - resultView.setText(text); - micButton.setImageDrawable(AppCompatResources.getDrawable(this, icon)); - micButton.setEnabled(enabled); + if (resultView != null) + resultView.setText(text); + if (micButton != null) { + micButton.setImageDrawable(AppCompatResources.getDrawable(this, icon)); + micButton.setEnabled(enabled); + } + currentState = state; } private void setErrorState(String message) { setUiState(STATE_ERROR); - resultView.setText(message); + if (resultView != null) + resultView.setText(message); + currentErrorMessage = message; } private void recognizeMicrophone() { diff --git a/app/src/main/java/org/vosk/ime/settingsfragments/ModelsAdapter.java b/app/src/main/java/org/vosk/ime/settingsfragments/ModelsAdapter.java new file mode 100644 index 0000000..e442034 --- /dev/null +++ b/app/src/main/java/org/vosk/ime/settingsfragments/ModelsAdapter.java @@ -0,0 +1,158 @@ +package org.vosk.ime.settingsfragments; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.TextView; + +import androidx.recyclerview.widget.RecyclerView; + +import org.vosk.ime.Model; +import org.vosk.ime.ModelLink; +import org.vosk.ime.R; + +import java.util.List; +import java.util.Locale; + +public class ModelsAdapter extends RecyclerView.Adapter { + + public static class Data { + private ModelLink modelLink; + private Model model; + private boolean installed; + + public Data(ModelLink modelLink) { + this.modelLink = modelLink; + this.model = null; + this.installed = false; + } + + public Data(Model model) { + this.modelLink = null; + this.model = model; + this.installed = true; + } + + public Data(ModelLink modelLink, Model model) { + this.modelLink = modelLink; + this.model = model; + this.installed = true; + } + + public String getFilename() { + if (modelLink != null) { + return modelLink.getFilename(); + } else { + return model.filename; + } + } + + public Locale getLocale() { + if (model != null) { + return model.locale; + } else { + return modelLink.locale; + } + } + + public boolean isInstalled() { + return installed; + } + + public void wasInstalled(Model model) { + this.model = model; + this.installed = true; + } + + public ModelLink getModelLink() { + return modelLink; + } + + public Model getModel() { + return model; + } + } + + private final Context context; + private final List mData; + private final LayoutInflater mInflater; + private ItemClickListener mClickListener; + + // data is passed into the constructor + ModelsAdapter(Context context, List data) { + this.mInflater = LayoutInflater.from(context); + this.context = context; + this.mData = data; + } + + // inflates the row layout from xml when needed + @Override + public ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view = mInflater.inflate(R.layout.fragment_models_entry, parent, false); + return new ViewHolder(view); + } + + // binds the data to the TextView in each row + @Override + public void onBindViewHolder(ViewHolder holder, int position) { + Data data = mData.get(position); + holder.titleTextView.setText(data.getLocale().getDisplayName()); + holder.subtitleTextView.setText(data.getFilename()); + holder.downloadButton.setVisibility(data.isInstalled() ? View.INVISIBLE : View.VISIBLE); + holder.data = data; + } + + // total number of rows + @Override + public int getItemCount() { + return mData.size(); + } + + + // stores and recycles views as they are scrolled off screen + public class ViewHolder extends RecyclerView.ViewHolder { + TextView titleTextView; + TextView subtitleTextView; + ImageButton downloadButton; + Data data; + + ViewHolder(View itemView) { + super(itemView); + titleTextView = itemView.findViewById(R.id.titleTextView); + subtitleTextView = itemView.findViewById(R.id.subtitleTextView); + downloadButton = itemView.findViewById(R.id.downloadButton); + + downloadButton.setOnClickListener(this::onButtonClick); + itemView.setOnClickListener(this::onClick); + } + + private void onClick(View view) { + if (mClickListener != null) + mClickListener.onItemClick(view, getAdapterPosition(), data); + } + + private void onButtonClick(View view) { + if (mClickListener != null) + mClickListener.onDownloadButtonClicked(view, getAdapterPosition(), data); + } + } + + // convenience method for getting data at click position + Data getItem(int id) { + return mData.get(id); + } + + // allows clicks events to be caught + void setClickListener(ItemClickListener itemClickListener) { + this.mClickListener = itemClickListener; + } + + // parent activity will implement this method to respond to click events + public interface ItemClickListener { + void onItemClick(View view, int position, Data data); + + void onDownloadButtonClicked(View view, int position, Data data); + } +} diff --git a/app/src/main/java/org/vosk/ime/settingsfragments/ModelsFragment.java b/app/src/main/java/org/vosk/ime/settingsfragments/ModelsFragment.java index 02fd010..7f98305 100644 --- a/app/src/main/java/org/vosk/ime/settingsfragments/ModelsFragment.java +++ b/app/src/main/java/org/vosk/ime/settingsfragments/ModelsFragment.java @@ -4,28 +4,48 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.ProgressBar; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import org.vosk.ime.FileDownloadService; +import org.vosk.ime.Model; +import org.vosk.ime.ModelLink; +import org.vosk.ime.Tools; import org.vosk.ime.databinding.FragmentModelsBinding; -public class ModelsFragment extends Fragment { +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class ModelsFragment extends Fragment implements ModelsAdapter.ItemClickListener { + private static final String TAG = "ModelsFragment"; private FragmentModelsBinding binding; + private ModelsAdapter adapter; + private boolean isDownloading = false; + private ProgressBar progressBar; + private RecyclerView recyclerView; - public View onCreateView(@NonNull LayoutInflater inflater, - ViewGroup container, Bundle savedInstanceState) { -// DashboardViewModel dashboardViewModel = -// new ViewModelProvider(this).get(DashboardViewModel.class); + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { binding = FragmentModelsBinding.inflate(inflater, container, false); View root = binding.getRoot(); -// final TextView textView = binding.textDashboard; -// dashboardViewModel.getText().observe(getViewLifecycleOwner(), textView::setText); + recyclerView = binding.recyclerView; + progressBar = binding.progressBar; + + recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); + adapter = new ModelsAdapter(getContext(), Tools.getModelsData(getContext())); + adapter.setClickListener(this); + recyclerView.setAdapter(adapter); + return root; } @@ -34,4 +54,45 @@ public void onDestroyView() { super.onDestroyView(); binding = null; } + + @Override + public void onItemClick(View view, int position, ModelsAdapter.Data data) { + } + + @Override + public void onDownloadButtonClicked(View view, int position, ModelsAdapter.Data data) { + if (data.isInstalled() || isDownloading) return; + isDownloading = true; + FileDownloadService.OnDownloadStatusListener listener = new FileDownloadService.OnDownloadStatusListener() { + @Override + public void onDownloadStarted() { + progressBar.setVisibility(View.VISIBLE); +// Toast.makeText(getContext(), "onDownloadStarted", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onDownloadCompleted() { + isDownloading = false; + progressBar.setVisibility(View.GONE); + Model model = Tools.getModelForLink(data.getModelLink(), getContext()); + if (model != null) data.wasInstalled(model); + adapter.notifyItemChanged(position); +// Toast.makeText(getContext(), "onDownloadCompleted", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onDownloadFailed() { + isDownloading = false; + progressBar.setVisibility(View.GONE); +// Toast.makeText(getContext(), "onDownloadFailed", Toast.LENGTH_SHORT).show(); + } + + @Override + public void onDownloadProgress(int progress) { + progressBar.setProgress(progress); +// Toast.makeText(getContext(), "download started", Toast.LENGTH_SHORT).show(); + } + }; + Tools.downloadModelFromLink(data.getModelLink(), listener, requireContext()); + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_cloud.xml b/app/src/main/res/drawable/ic_cloud.xml new file mode 100644 index 0000000..a860632 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_download.xml b/app/src/main/res/drawable/ic_download.xml new file mode 100644 index 0000000..1fc8106 --- /dev/null +++ b/app/src/main/res/drawable/ic_download.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_download_done.xml b/app/src/main/res/drawable/ic_download_done.xml new file mode 100644 index 0000000..0e3a229 --- /dev/null +++ b/app/src/main/res/drawable/ic_download_done.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml new file mode 100644 index 0000000..5e930a2 --- /dev/null +++ b/app/src/main/res/drawable/ic_folder.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_language.xml b/app/src/main/res/drawable/ic_language.xml new file mode 100644 index 0000000..4522f8f --- /dev/null +++ b/app/src/main/res/drawable/ic_language.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_mic_none.xml b/app/src/main/res/drawable/ic_mic_none.xml index 1d99d21..8029ea3 100644 --- a/app/src/main/res/drawable/ic_mic_none.xml +++ b/app/src/main/res/drawable/ic_mic_none.xml @@ -1,5 +1,10 @@ - - + + diff --git a/app/src/main/res/layout/fragment_models.xml b/app/src/main/res/layout/fragment_models.xml index 9be878c..4b024b4 100644 --- a/app/src/main/res/layout/fragment_models.xml +++ b/app/src/main/res/layout/fragment_models.xml @@ -6,18 +6,22 @@ android:layout_height="match_parent" tools:context=".settingsfragments.ModelsFragment"> - + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_models_entry.xml b/app/src/main/res/layout/fragment_models_entry.xml new file mode 100644 index 0000000..9c437eb --- /dev/null +++ b/app/src/main/res/layout/fragment_models_entry.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/ime.xml b/app/src/main/res/layout/ime.xml index 429339b..f163459 100644 --- a/app/src/main/res/layout/ime.xml +++ b/app/src/main/res/layout/ime.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:id="@+id/constraintLayout" android:layout_width="match_parent" - android:layout_height="0dp" + android:layout_height="match_parent" android:layout_alignParentBottom="true" android:background="@android:color/white" android:theme="@style/KeyboardTheme"> @@ -96,11 +96,23 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> +