diff --git a/android/KMAPro/kMAPro/build.gradle b/android/KMAPro/kMAPro/build.gradle index 458e78ba9ea..891bfda4470 100644 --- a/android/KMAPro/kMAPro/build.gradle +++ b/android/KMAPro/kMAPro/build.gradle @@ -172,8 +172,9 @@ dependencies { implementation 'com.stepstone.stepper:material-stepper:4.3.1' api(name: 'keyman-engine', ext: 'aar') implementation 'io.sentry:sentry-android:7.22.6' + implementation 'androidx.webkit:webkit:1.14.0' implementation 'androidx.preference:preference:1.2.1' - implementation "com.android.installreferrer:installreferrer:2.2" + implementation 'com.android.installreferrer:installreferrer:2.2' // Add dependency for generating QR Codes // (Even though it's embedded in KMEA, because we're manually copying keyman-engine.aar, diff --git a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/WebBrowserActivity.java b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/WebBrowserActivity.java index 3308caff9ac..4c24da2559d 100644 --- a/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/WebBrowserActivity.java +++ b/android/KMAPro/kMAPro/src/main/java/com/tavultesoft/kmapro/WebBrowserActivity.java @@ -1,9 +1,10 @@ -/** - * Copyright (C) 2017 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.tavultesoft.kmapro; +import java.io.File; import java.io.UnsupportedEncodingException; import java.net.MalformedURLException; import java.net.URL; @@ -386,7 +387,7 @@ protected void onResume() { super.onResume(); if (webView != null) { if (didFinishLoading) { - String fontFilename = KMManager.getKeyboardTextFontFilename(); + String fontFilename = getKeyboardTextFontFilenameOnly(); if (!loadedFont.equals(fontFilename)) { webView.reload(); } @@ -432,8 +433,19 @@ public void onBackPressed() { } } + /** + * Returns the filename without path of the display font of the current keyboard. + */ + private String getKeyboardTextFontFilenameOnly() { + String fontPath = KMManager.getKeyboardTextFontFilename(); + if (fontPath == null || fontPath.isEmpty()) { + return ""; + } + return new File(fontPath).getName(); + } + private void loadFont() { - String font = KMManager.getKeyboardTextFontFilename(); + String font = getKeyboardTextFontFilenameOnly(); if (!font.isEmpty()) { loadedFont = font; String fontUrl = String.format("%s%s", fontBaseUri, font); diff --git a/android/KMEA/app/build.gradle b/android/KMEA/app/build.gradle index 3577463ca4b..50851a16679 100644 --- a/android/KMEA/app/build.gradle +++ b/android/KMEA/app/build.gradle @@ -75,6 +75,7 @@ dependencies { implementation 'commons-io:commons-io:2.16.1' implementation 'io.sentry:sentry-android:7.22.6' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' // Robolectric testImplementation 'androidx.test.ext:junit:1.2.1' diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java index 40611000020..9f9895f5e60 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2017-2018 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -23,6 +23,7 @@ import com.keyman.engine.util.FileUtils; import com.keyman.engine.util.KMLog; import com.keyman.engine.util.KMString; +import com.keyman.engine.util.WebViewUtils; import android.annotation.SuppressLint; import android.content.Context; @@ -83,9 +84,8 @@ final class KMKeyboard extends WebView { */ protected static KMManager.BannerType currentBanner = KMManager.BannerType.HTML; - private static String txtFont = ""; - private static String oskFont = null; - private static String keyboardRoot = ""; + private static String txtFontPath = ""; + private static String oskFontPath = ""; private final String fontUndefined = "undefined"; private GestureDetector gestureDetector; private static ArrayList kbEventListeners = null; @@ -366,8 +366,7 @@ public void loadKeyboard() { } else { KMManager.SystemKeyboardWebViewClient.setKeyboardLoaded(false); } - - String htmlPath = "file://" + getContext().getDir("data", Context.MODE_PRIVATE) + "/" + KMManager.KMFilename_KeyboardHtml; + String htmlPath = WebViewUtils.buildAssetUrl(KMManager.KMFilename_KeyboardHtml); loadUrl(htmlPath); setBackgroundColor(0); } @@ -538,7 +537,7 @@ protected void toggleSuggestionBanner(HashMap associatedLexicalM * @return String */ public static String textFontFilename() { - return txtFont; + return txtFontPath; } /** @@ -546,9 +545,10 @@ public static String textFontFilename() { * @return String */ public static String oskFontFilename() { - return oskFont; + return oskFontPath; } + // REVIEW: this method seems to be unused and undocumented. Can we remove it? /** * Return the full path to the special OSK font, * which is with all the keyboard assets at the root app_data folder @@ -634,8 +634,6 @@ public boolean prepareKeyboardSwitch(String packageID, String keyboardID, String } String kbKey = KMString.format("%s_%s", languageID, keyboardID); - setKeyboardRoot(packageID); - // Escape single-quoted names for javascript call keyboardName = keyboardName.replaceAll("\'", "\\\\'"); // Double-escaped-backslash b/c regex. @@ -690,20 +688,18 @@ public boolean setKeyboard(String packageID, String keyboardID, String languageI KMManager.getLatestKeyboardFileVersion(getContext(), packageID, keyboardID) : null; } - setKeyboardRoot(packageID); - if(kOskFont == null || kOskFont.isEmpty()) kOskFont = kFont; - JSONObject jDisplayFont = makeFontPaths(kFont); - JSONObject jOskFont = makeFontPaths(kOskFont); + JSONObject jDisplayFont = makeFontObject(kFont, packageID); + JSONObject jOskFont = makeFontObject(kOskFont, packageID); - txtFont = getFontFilename(jDisplayFont); - oskFont = getFontFilename(jOskFont); + txtFontPath = getFontFilename(kFont, packageID); + oskFontPath = getFontFilename(kOskFont, packageID); String kbKey = KMString.format("%s_%s", languageID, keyboardID); - String keyboardPath = makeKeyboardPath(packageID, keyboardID, keyboardVersion); + String keyboardUrl = makeKeyboardUrl(packageID, keyboardID, keyboardVersion); JSONObject reg = new JSONObject(); try { @@ -711,7 +707,7 @@ public boolean setKeyboard(String packageID, String keyboardID, String languageI reg.put("KI", "Keyboard_" + keyboardID); reg.put("KLC", languageID); reg.put("KL", languageName); - reg.put("KF", keyboardPath); + reg.put("KF", keyboardUrl); reg.put("KP", packageID); if (jDisplayFont != null) reg.put("KFont", jDisplayFont); @@ -796,29 +792,46 @@ private void sendError(String packageID, String keyboardID, String languageID, b } } - // Set the base path of the keyboard depending on the package ID - private void setKeyboardRoot(String packageID) { + /** + * Return the root URL for the data folder. Even though this is a local + * location this returns a URL with a magic domain so that it can be + * loaded with fetch() in the webview. + */ + private String getDataRootUrl() { + return WebViewUtils.buildAssetUrl(""); + } + + /** + * Return the root path for the data folder as a file path. This should be + * used where the file is not loaded through the webview, but is instead + * used by the app directly. + */ + private String getDataRootPath() { + return context.getDir("data", Context.MODE_PRIVATE).toString() + File.separator; + } + + private String getPackageRootUrl(String packageID) { if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator + KMManager.KMDefault_UndefinedPackageID + File.separator); - } else { - this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator); + return getDataRootUrl() + KMManager.KMDefault_UndefinedPackageID + "/"; } + return getDataRootUrl() + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } - public String getKeyboardRoot() { - return this.keyboardRoot; + private String getPackageRootPath(String packageID) { + if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { + return getDataRootPath() + KMManager.KMDefault_UndefinedPackageID + File.separator; + } + return getDataRootPath() + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator; } - private String makeKeyboardPath(String packageID, String keyboardID, String keyboardVersion) { - String keyboardPath; + private String makeKeyboardUrl(String packageID, String keyboardID, String keyboardVersion) { + String keyboardUrl = getPackageRootUrl(packageID); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - keyboardPath = getKeyboardRoot() + keyboardID + "-" + keyboardVersion + ".js"; + keyboardUrl += keyboardID + "-" + keyboardVersion + ".js"; } else { - keyboardPath = getKeyboardRoot() + keyboardID + ".js"; + keyboardUrl += keyboardID + ".js"; } - return keyboardPath; + return keyboardUrl; } private void sendKMWError(int lineNumber, String sourceId, String message) { @@ -900,42 +913,23 @@ private void saveCurrentKeyboardIndex() { } /** - * getFontFilename - * Parse a Font JSON object and return the font filename (ending in .ttf or .otf) - * @param fontObj JSONObject - Font JSON object - * @return String - Filename for the font. If font is invalid, return "" + * Return the full path to the font file. If the font is invalid, return empty string. + * @param font String - Font filename + * @param packageID String - Package ID + * @return String - Full path to the font file. If font is invalid, return "". */ - private String getFontFilename(JSONObject fontObj) { - String font = ""; - if (fontObj == null) { - return font; + private String getFontFilename(String font, String packageID) { + if(font == null || font.equals("")) { + return ""; } - try { - JSONArray sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); - if (sourceArray != null) { - String fontFile; - int length = sourceArray.length(); - for (int i = 0; i < length; i++) { - fontFile = sourceArray.getString(i); - if (FileUtils.hasFontExtension(fontFile)) { - font = fontFile; - break; - } - } - } else { - String fontFile = fontObj.optString(KMManager.KMKey_FontFiles); - if (fontFile != null) { - if (FileUtils.hasFontExtension(fontFile)) { - font = fontFile; - } - } - } - } catch (JSONException e) { - KMLog.LogException(TAG, "", e); - font = ""; + + if (!FileUtils.hasFontExtension(font)) { + // QUESTION: do we log this? + return ""; } - return font; + String fontRoot = KMManager.isDefaultFont(font) ? getDataRootPath() : getPackageRootPath(packageID); + return fontRoot + font; } @SuppressLint("InflateParams") @@ -1030,13 +1024,28 @@ public void onDismiss() { } /** - * Take a font JSON object and adjust to pass to JS - * 1. Replace "source" keys for "files" keys - * 2. Create full font paths for .ttf or .svg - * @param font String font JSON object as a string - * @return JSONObject of modified font information with full paths. If font is invalid, return `null` + * Create a JSON object consisting of the font family and the URLs of the + * font files on the local device. + * + * The `font` parameter can either be the filename of the font (with an + * extension recognized as font), or a Font object or JSON string. + * In the former case a new JSON object is created with the font family + * derived from the filename, and the font filename prefixed with path + * to the fonts. + * In the latter case the legacy `sources` key is renamed to `files`. + * If `files` is a single string it will be prefixed with the path to the + * fonts. If `files` is an array, the array is iterated until finding + * the first file with a font extension which is then prefixed with the + * path to the fonts. + * + * @param font A string containing either the font filename or a font + * JSON object as a string + * @param packageID The package ID of the keyboard + * + * @return JSONObject of modified font information with full paths. If font + * is invalid, return `null`. */ - private JSONObject makeFontPaths(String font) { + private JSONObject makeFontObject(String font, String packageID) { if(font == null || font.equals("")) { return null; @@ -1047,14 +1056,17 @@ private JSONObject makeFontPaths(String font) { JSONObject jfont = new JSONObject(); jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length()-4)); JSONArray jfiles = new JSONArray(); - jfiles.put(keyboardRoot + font); + String fontRoot = KMManager.isDefaultFont(font) ? getDataRootUrl() : getPackageRootUrl(packageID); + jfiles.put(fontRoot + font); jfont.put(KMManager.KMKey_FontFiles, jfiles); return jfont; } + // REVIEW: Why do we need the complicated code below? Can this still + // happen, or can we remove it? (see also getFontFilename) + KMLog.LogInfo(TAG, "Got font without font extension: " + font); + JSONObject fontObj = new JSONObject(font); - JSONArray sourceArray; - String fontFile; // Replace "sources" key with "files" if (fontObj.has(KMManager.KMKey_FontSource)) { @@ -1064,16 +1076,18 @@ private JSONObject makeFontPaths(String font) { Object obj = fontObj.get(KMManager.KMKey_FontFiles); if (obj instanceof String) { - fontFile = fontObj.getString(KMManager.KMKey_FontFiles); - fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + obj); + String fontFile = fontObj.getString(KMManager.KMKey_FontFiles); + String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRootUrl() : getPackageRootUrl(packageID); + fontObj.put(KMManager.KMKey_FontFiles, fontRoot + obj); return fontObj; } else if (obj instanceof JSONArray) { - sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); + JSONArray sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles); if (sourceArray != null) { for (int i = 0; i < sourceArray.length(); i++) { - fontFile = sourceArray.getString(i); + String fontFile = sourceArray.getString(i); if (FileUtils.hasFontExtension(fontFile)) { - fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + fontFile); + String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRootUrl() : getPackageRootUrl(packageID); + fontObj.put(KMManager.KMKey_FontFiles, fontRoot + fontFile); fontObj.remove(KMManager.KMKey_FontSource); return fontObj; } @@ -1082,7 +1096,6 @@ private JSONObject makeFontPaths(String font) { } } catch (JSONException e) { KMLog.LogException(TAG, "Failed to make font for '"+font+"'", e); - return null; } return null; diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java index 853d997d7e2..f043a56681e 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2023 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -9,9 +9,13 @@ import android.graphics.RectF; import android.net.Uri; import android.util.Log; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; import android.widget.RelativeLayout; +import androidx.webkit.WebViewAssetLoader; +import androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler; import com.keyman.engine.KeyboardEventHandler.EventType; import com.keyman.engine.KMManager; @@ -19,6 +23,7 @@ import com.keyman.engine.KMManager.SuggestionType; import com.keyman.engine.util.KMLog; import com.keyman.engine.data.Keyboard; +import com.keyman.engine.util.WebViewUtils; import org.json.JSONObject; @@ -31,11 +36,16 @@ public final class KMKeyboardWebViewClient extends WebViewClient { public Context context; private KeyboardType keyboardType; private boolean keyboardLoaded; + private WebViewAssetLoader assetLoader; KMKeyboardWebViewClient(Context context, KeyboardType keyboardType) { this.context = context; this.keyboardType = keyboardType; this.keyboardLoaded = false; + this.assetLoader = new WebViewAssetLoader.Builder() + .addPathHandler(WebViewUtils.ASSET_DATA_PATH, + new InternalStoragePathHandler(context, context.getDir("data", Context.MODE_PRIVATE))) + .build(); if (keyboardType != KeyboardType.KEYBOARD_TYPE_INAPP && keyboardType != KeyboardType.KEYBOARD_TYPE_SYSTEM) { KMLog.LogError(TAG, String.format("Cannot initialize: Invalid keyboard type: %s", keyboardType.toString())); @@ -58,6 +68,11 @@ public void setKeyboardLoaded(boolean keyboardLoaded) { public void onPageStarted(WebView view, String url, Bitmap favicon) { } + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + return this.assetLoader.shouldInterceptRequest(request.getUrl()); + } + @Override public void onPageFinished(WebView view, String url) { Log.d("KMEA", String.format("onPageFinished: [%s] %s", keyboardType.toString(), url)); @@ -74,46 +89,44 @@ private void pageLoaded(WebView view, String url) { kmKeyboard.keyboardSet = false; KMManager.currentLexicalModel = null; - if (url.startsWith("file")) { // TODO: is this test necessary? - this.keyboardLoaded = true; + this.keyboardLoaded = true; - SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); - int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); - if (index < 0) { - index = 0; + SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE); + int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0); + Keyboard keyboardInfo = null; + if (index >= 0) { + keyboardInfo = KMManager.getKeyboardInfo(context, index); + } + String langId = null; + if (keyboardInfo != null) { + langId = keyboardInfo.getLanguageID(); + kmKeyboard.setKeyboard(keyboardInfo); + } else { + // Revert to default (index 0) or fallback keyboard + keyboardInfo = KMManager.getKeyboardInfo(context, 0); + if (keyboardInfo == null) { + // Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses + keyboardInfo = KMManager.getDefaultKeyboard(context); } - Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index); - String langId = null; if (keyboardInfo != null) { langId = keyboardInfo.getLanguageID(); kmKeyboard.setKeyboard(keyboardInfo); - } else { - // Revert to default (index 0) or fallback keyboard - keyboardInfo = KMManager.getKeyboardInfo(context, 0); - if (keyboardInfo == null) { - // Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses - keyboardInfo = KMManager.getDefaultKeyboard(context); - } - if (keyboardInfo != null) { - langId = keyboardInfo.getLanguageID(); - kmKeyboard.setKeyboard(keyboardInfo); - } } + } - KMManager.registerAssociatedLexicalModel(langId); + KMManager.registerAssociatedLexicalModel(langId); - kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time! + kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time! - kmKeyboard.callJavascriptAfterLoad(); - kmKeyboard.setSpacebarText(KMManager.getSpacebarText()); + kmKeyboard.callJavascriptAfterLoad(); + kmKeyboard.setSpacebarText(KMManager.getSpacebarText()); - KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null); + KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null); - // Special handling for in-app TextView context keymanapp/keyman#3809 - if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP && - KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) { - KMTextView.updateTextContext(); - } + // Special handling for in-app TextView context keymanapp/keyman#3809 + if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP && + KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) { + KMTextView.updateTextContext(); } } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java index cd9a737d9c8..9314c26e28e 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/KMManager.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2017 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine; @@ -401,6 +401,10 @@ public static String getLexicalModelsDir() { return getResourceRoot() + KMDefault_LexicalModelPackages + File.separator; } + public static String getLexicalModelsUrl() { + return WebViewUtils.buildAssetUrl(KMDefault_LexicalModelPackages + "/"); + } + public static String getCloudDir() { return getResourceRoot() + KMDefault_UndefinedPackageID + File.separator; } @@ -491,6 +495,19 @@ public static boolean isReservedNamespace(String packageID) { return false; } + /** + * Check if the given font is the default font or the default OSK font. + * @param fontName String - The font name and extension + * @return boolean true if `fontName` is DejaVuSans.ttf or keymanweb-osk.ttf + * or null or empty string, false otherwise + */ + public static boolean isDefaultFont(String fontName) { + if (fontName == null || fontName.isEmpty()) { + return true; + } + return fontName.equals(KMDefault_KeyboardFont) || fontName.equals(KMFilename_Osk_Ttf_Font); + } + public static void initialize(final Context context, KeyboardType keyboardType) { appContext = context.getApplicationContext(); @@ -1608,6 +1625,16 @@ public static void setMaySendCrashReport(boolean override) { */ public static Typeface getFontTypeface(Context context, String fontFilename) { try { + if (fontFilename == null || fontFilename.isEmpty()) { + return null; + } + if (fontFilename.startsWith("http://") || fontFilename.startsWith("https://") + || fontFilename.startsWith("file://")) { + // Font file is not local, so cannot load Typeface + KMLog.LogError(TAG, "Font file is not local: " + fontFilename); + return null; + } + if ((fontFilename != null) && FileUtils.hasFontExtension(fontFilename)) { // Ignore .woff files if Android 7.0 / 7.1 (Issue #4896) if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) && @@ -1646,8 +1673,9 @@ public static boolean registerLexicalModel(HashMap lexicalModelI String modelID = lexicalModelInfo.get(KMKey_LexicalModelID); String languageID = lexicalModelInfo.get(KMKey_LanguageID); boolean modelFileExists = true; - File modelFile = new File(getLexicalModelsDir(), pkgID + File.separator + modelID + ".model.js"); - String path = "file://" + modelFile.getAbsolutePath(); + String modelFilename = pkgID + File.separator + modelID + ".model.js"; + File modelFile = new File(getLexicalModelsDir(), modelFilename); + String url = getLexicalModelsUrl() + modelFilename; // Disable sugestions if lexical-model file doesn't exist if (!modelFile.exists()) { @@ -1662,7 +1690,7 @@ public static boolean registerLexicalModel(HashMap lexicalModelI modelObj.put("id", modelID); languageJSONArray.put(languageID); modelObj.put("languages", languageJSONArray); - modelObj.put("path", path); + modelObj.put("path", url); modelObj.put("CustomHelpLink", lexicalModelInfo.get(KMKey_CustomHelpLink)); } catch (JSONException e) { KMLog.LogException(TAG, "Invalid lexical model to register", e); @@ -2221,7 +2249,12 @@ public boolean accept(File pathname) { File kmpJSONFile = new File(path); if (!kmpJSONFile.exists()) { if (!KMManager.isTestMode()) { - KMLog.LogError(TAG, path + " not found. Returning version 1.0"); + if (packageID.equals(KMDefault_PackageID)) { + // Suppress error log for the default fallback keyboard if it's not installed + Log.d(TAG, path + " not found. Returning version 1.0"); + } else { + KMLog.LogError(TAG, path + " not found. Returning version 1.0"); + } } return "1.0"; } diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java index fb127095b6e..896fb23e965 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/data/Keyboard.java @@ -1,5 +1,5 @@ -/** - * Copyright (C) 2020 SIL International. All rights reserved. +/* + * Keyman is copyright (C) SIL Global. MIT License. */ package com.keyman.engine.data; @@ -15,6 +15,7 @@ import com.keyman.engine.util.FileUtils; import com.keyman.engine.util.KMLog; import com.keyman.engine.util.KMString; +import com.keyman.engine.util.WebViewUtils; import org.json.JSONArray; import org.json.JSONException; @@ -184,24 +185,24 @@ public JSONObject toJSON() { return o; } - private String getKeyboardRoot(Context context) { - String keyboardRoot = context.getDir("data", Context.MODE_PRIVATE).toString() + - File.separator; + private String getDataRoot() { + return WebViewUtils.buildAssetUrl(""); + } + private String getPackageRoot() { if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return keyboardRoot + KMManager.KMDefault_UndefinedPackageID + File.separator; - } else { - return keyboardRoot + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator; + return getDataRoot() + KMManager.KMDefault_UndefinedPackageID + "/"; } + return getDataRoot() + KMManager.KMDefault_AssetPackages + "/" + packageID + "/"; } - public String getKeyboardPath(Context context) { + private String getKeyboardUrl() { String keyboardID = this.getKeyboardID(); String keyboardVersion = this.getVersion(); if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) { - return getKeyboardRoot(context) + keyboardID + "-" + keyboardVersion + ".js"; + return getPackageRoot() + keyboardID + "-" + keyboardVersion + ".js"; } else { - return getKeyboardRoot(context) + keyboardID + ".js"; + return getPackageRoot() + keyboardID + ".js"; } } @@ -213,17 +214,17 @@ public String toStub(Context context) { stubObj.put("KI", "Keyboard_" + this.getKeyboardID()); stubObj.put("KLC", this.getLanguageID()); stubObj.put("KL", this.getLanguageName()); - stubObj.put("KF", this.getKeyboardPath(context)); + stubObj.put("KF", this.getKeyboardUrl()); stubObj.put("KP", this.getPackageID()); String displayFont = this.getFont(); if(displayFont != null) { - stubObj.put("KFont", this.buildDisplayFontObject(displayFont, context)); + stubObj.put("KFont", this.buildDisplayFontObject(displayFont)); } String oskFont = this.getOSKFont(); if(oskFont != null) { - stubObj.put("KOskFont", this.buildDisplayFontObject(oskFont, context)); + stubObj.put("KOskFont", this.buildDisplayFontObject(oskFont)); } String displayName = this.getDisplayName(); @@ -239,34 +240,33 @@ public String toStub(Context context) { } /** - * Take a font JSON object and adjust to pass to JS - * 1. Replace "source" keys for "files" keys - * 2. Create full font paths for .ttf or .svg - * @param font String font JSON object as a string - * @return JSONObject of modified font information with full paths. If font is invalid, return `null` + * Create a JSON object consisting of the font family and the font + * file URL. + * + * @param font A string containing the font filename + * @return JSONObject of modified font information with full URL. If font + * is invalid, return `null`. */ - private JSONObject buildDisplayFontObject(String font, Context context) { + private JSONObject buildDisplayFontObject(String font) { if(font == null || font.equals("")) { return null; } - String keyboardRoot = this.getKeyboardRoot(context); + String fontRoot = KMManager.isDefaultFont(font) ? this.getDataRoot() : this.getPackageRoot(); try { if (FileUtils.hasFontExtension(font)) { JSONObject jfont = new JSONObject(); jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length() - 4)); JSONArray jfiles = new JSONArray(); - jfiles.put(keyboardRoot + font); + jfiles.put(fontRoot + font); jfont.put(KMManager.KMKey_FontFiles, jfiles); return jfont; - } else { - return null; } } catch (JSONException e) { KMLog.LogException(TAG, "Failed to make font for '"+font+"'", e); - return null; } + return null; } /** diff --git a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java index de8b37f73ed..30be0a86a6f 100644 --- a/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java +++ b/android/KMEA/app/src/main/java/com/keyman/engine/util/WebViewUtils.java @@ -35,6 +35,28 @@ public enum SystemWebViewStatus { private static final String CHROME_INSTALL_PATTERN_FORMATSTR = "^.*Chrome/([\\d.]+).*$"; private static final Pattern installPattern = Pattern.compile(CHROME_INSTALL_PATTERN_FORMATSTR); + /** + * Reserved magic domain for loading files from the local device. At runtime + * the WebViewAssetLoader will replace the protocol and domain with the + * internal storage path. + * See https://developer.android.com/reference/androidx/webkit/WebViewAssetLoader + */ + private static final String MAGIC_DEFAULT_DOMAIN = "https://appassets.androidplatform.net"; + + /** + * Path under the asset domain where all assets live + */ + public static final String ASSET_DATA_PATH = "/data/"; + + /** + * Build a full URL to the provided asset + */ + public static String buildAssetUrl(String assetPath) { + String appendAsset = assetPath == null ? "" : + (assetPath.startsWith("/") ? assetPath.substring(1) : assetPath); + return WebViewUtils.MAGIC_DEFAULT_DOMAIN + WebViewUtils.ASSET_DATA_PATH + appendAsset; + } + /** * Get the Keyman Engine mode based on the Chrome version. * @param context - The context diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java index 91fc7dd3709..afc206c9da6 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/KMManagerTest.java @@ -1,5 +1,9 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + */ package com.keyman.engine; +import android.content.Context; import android.util.Log; import androidx.test.core.app.ApplicationProvider; @@ -32,7 +36,11 @@ public class KMManagerTest { // For some keyboard list tests, load an existing keyboard list. // Can't use @Before because context is null before running tests. public void loadOldKeyboardsList() { - KMManager.initialize(ApplicationProvider.getApplicationContext(), KMManager.KeyboardType.KEYBOARD_TYPE_INAPP); + Context context = ApplicationProvider.getApplicationContext(); + // Create appData directory accessed in KMKeyboardWebViewClient + File dataDir = context.getDir("data", Context.MODE_PRIVATE); + dataDir.mkdirs(); + KMManager.initialize(context, KMManager.KeyboardType.KEYBOARD_TYPE_INAPP); File keyboards_dat = new File(TEST_RESOURCE_ROOT, OLD_KEYBOARDS_LIST); if (keyboards_dat == null || !keyboards_dat.exists()) { @@ -111,6 +119,11 @@ public void test_getTier() { */ @Test public void create_newKeyboardsList() { + if(File.separatorChar != '/') { + Log.w(TAG, "create_newKeyboardsList skipped due to #16183"); + return; + } + loadOldKeyboardsList(); dat_list = new ArrayList>(); @@ -182,6 +195,12 @@ public void create_newKeyboardsList() { @Test public void test_updateOldKeyboardsList() { + // #16183 + if(File.separatorChar != '/') { + Log.w(TAG, "test_updateOldKeyboardsList skipped due to #16183"); + return; + } + loadOldKeyboardsList(); Assert.assertNotNull(dat_list); diff --git a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java index 344b01879a9..c024c939f12 100644 --- a/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java +++ b/android/KMEA/app/src/test/java/com/keyman/engine/util/WebViewUtilsTest.java @@ -57,4 +57,15 @@ public void test_Chrome57_EngineWebViewVersionStatusFull() { Assert.assertEquals(WebViewUtils.getEngineWebViewVersionStatus(context, null, chromeVersion), WebViewUtils.EngineWebViewVersionStatus.FULL); } + + @Test + public void test_buildAssetUrl() { + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl("")); + Assert.assertEquals("https://appassets.androidplatform.net/data/", WebViewUtils.buildAssetUrl(null)); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo", WebViewUtils.buildAssetUrl("foo")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/", WebViewUtils.buildAssetUrl("/foo/")); + Assert.assertEquals("https://appassets.androidplatform.net/data/foo/bar.html", WebViewUtils.buildAssetUrl("foo/bar.html")); + } + } diff --git a/android/Samples/KMSample1/app/build.gradle b/android/Samples/KMSample1/app/build.gradle index 65c0ecb8c9f..da949d36d1c 100644 --- a/android/Samples/KMSample1/app/build.gradle +++ b/android/Samples/KMSample1/app/build.gradle @@ -41,4 +41,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' api(name: 'keyman-engine', ext: 'aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Samples/KMSample2/app/build.gradle b/android/Samples/KMSample2/app/build.gradle index a7e47393207..45d5dc1c7f0 100644 --- a/android/Samples/KMSample2/app/build.gradle +++ b/android/Samples/KMSample2/app/build.gradle @@ -40,4 +40,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' api (name:'keyman-engine', ext:'aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Tests/KeyboardHarness/app/build.gradle b/android/Tests/KeyboardHarness/app/build.gradle index c53d3e97391..1b015cf1c4d 100644 --- a/android/Tests/KeyboardHarness/app/build.gradle +++ b/android/Tests/KeyboardHarness/app/build.gradle @@ -51,4 +51,5 @@ dependencies { implementation 'com.google.android.material:material:1.12.0' api (name:'keyman-engine', ext:'aar') implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } diff --git a/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java b/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java index 7b73341786a..6b5995bf06e 100644 --- a/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java +++ b/android/Tests/KeyboardHarness/app/src/main/java/com/keyman/android/tests/keyboardHarness/MainActivity.java @@ -58,6 +58,7 @@ protected void onCreate(Bundle savedInstanceState) { KMManager.KMDefault_KeyboardFont, KMManager.KMDefault_KeyboardFont); KMManager.addKeyboard(this, chiralityKBInfo); + Keyboard.setDefaultKeyboard(chiralityKBInfo); // Longpress test keyboard Keyboard longpressKBbInfo = new Keyboard( diff --git a/android/docs/engine/KMManager/getFontTypeface.md b/android/docs/engine/KMManager/getFontTypeface.md index 4149d92b360..9dd86928e71 100644 --- a/android/docs/engine/KMManager/getFontTypeface.md +++ b/android/docs/engine/KMManager/getFontTypeface.md @@ -19,7 +19,7 @@ KMManager.getFontTypeface(Context context, String fontFilename) : The context. `fontFilename` -: The filename of the font. +: The filename and full path of the font. ### Returns @@ -29,7 +29,7 @@ if it exists, `null` otherwise. ## Description Use this method to create a new typeface from the font file with -specified filename if it exists in `assets/fonts/` folder. +specified filename if it exists. ## Examples @@ -37,9 +37,10 @@ specified filename if it exists in `assets/fonts/` folder. The following script illustrate the use of `getFontTypeface()`: -``` javascript +``` java KMTextView textView = (KMTextView) findViewById(R.id.kmTextView); - Typeface fontTypeface = KMManager.getFontTypeface(this, "aava1.ttf"); + String textFontFilename = KMManager.getKeyboardTextFontFilename(); + Typeface fontTypeface = KMManager.getFontTypeface(this, textFontFilename); textView.setTypeface(fontTypeface); ``` diff --git a/android/docs/engine/KMManager/getKeyboardOskFontFilename.md b/android/docs/engine/KMManager/getKeyboardOskFontFilename.md index 8465ea4e8c4..7406effb9d5 100644 --- a/android/docs/engine/KMManager/getKeyboardOskFontFilename.md +++ b/android/docs/engine/KMManager/getKeyboardOskFontFilename.md @@ -5,18 +5,25 @@ title: KMManager.getKeyboardOskFontFilename() ## Summary The **`getKeyboardOskFontFilename()`** method returns the selected -keyboard's OSK font filename. +keyboard's OSK font filename and full path. ## Syntax -``` javascript +``` java KMManager.getKeyboardOskFontFilename() ``` ### Returns -Returns the selected keyboard's OSK font filename as `String` if it has -any, empty string otherwise. +Returns the selected keyboard's OSK font filename and full path as +`String` if it has any, empty string otherwise. Note that the +on-screen keyboard will fallback to the keyboard text font if +no OSK font is specified. + +The OSK font should not be used for a text view, because some +OSK fonts are appropriate for use only in the on screen keyboard; +see [`&displayMap`](/developer/language/reference/displaymap) for +reference. ## Description @@ -29,8 +36,8 @@ Use this method to get the OSK font filename of the selected keyboard. The following script illustrate the use of `getKeyboardOskFontFilename()`: -``` javascript - String oskFontFilename = KMManager.getKeyboardOskFontFilename(); +``` java +String oskFontFilename = KMManager.getKeyboardOskFontFilename(); ``` ## See also diff --git a/android/docs/engine/KMManager/getKeyboardTextFontFilename.md b/android/docs/engine/KMManager/getKeyboardTextFontFilename.md index 5c381cc23a0..f1e26e51a37 100644 --- a/android/docs/engine/KMManager/getKeyboardTextFontFilename.md +++ b/android/docs/engine/KMManager/getKeyboardTextFontFilename.md @@ -5,18 +5,18 @@ title: KMManager.getKeyboardTextFontFilename() ## Summary The **`getKeyboardTextFontFilename()`** method returns the selected -keyboard's text font filename. +keyboard's text font filename and full path. ## Syntax -``` javascript +``` java KMManager.getKeyboardTextFontFilename() ``` ### Returns -Returns the selected keyboard's text font filename as `String` if it has -any, empty string otherwise. +Returns the selected keyboard's text font filename and full path as +`String` if it has any, empty string otherwise. ## Description @@ -29,8 +29,8 @@ Use this method to get the text font filename of the selected keyboard. The following script illustrate the use of `getKeyboardTextFontFilename()`: -``` javascript - String textFontFilename = KMManager.getKeyboardTextFontFilename(); +``` java +String textFontFilename = KMManager.getKeyboardTextFontFilename(); ``` ## See also diff --git a/oem/firstvoices/android/app/build.gradle b/oem/firstvoices/android/app/build.gradle index d341db7a80a..ff5f15628bd 100644 --- a/oem/firstvoices/android/app/build.gradle +++ b/oem/firstvoices/android/app/build.gradle @@ -134,6 +134,7 @@ dependencies { api(name: 'keyman-engine', ext: 'aar') implementation 'io.sentry:sentry-android:7.22.6' implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.webkit:webkit:1.14.0' } apply plugin: 'com.android.application'