Skip to content

Commit 98a4e44

Browse files
authored
Merge pull request #16146 from keymanapp/fix/android/16096_blankKeyboard
fix(android): use `WebViewAssetLoader` for loading assets on Android (address regression in 19.0.242) This PR migrates the Android WebView from loading local files via file:// URLs to using `WebViewAssetLoader`, which serves internal storage files through a magic HTTPS domain (https://appassets.androidplatform.net). This fixes the blank keyboard problem reported in #16096 for Android. Core pattern of the change: ``` // Before: "file://" + context.getDir("data", ...) + "/" + filename // After: "https://appassets.androidplatform.net" + "/data/" + filename ``` The `WebViewAssetLoader` in `KMKeyboardWebViewClient` intercepts requests to this domain and serves files from internal storage. Since the domain is a constant, `Context` is no longer needed to construct URLs, simplifying several method signatures. Also this PR makes `KMKeyboard.getKeyboardRoot()` private, and renames public `Keyboard.getKeyboardPath()` to private `Keyboard.getKeyboardUrl()`. Part-of: #16096 Replaces: #16132
2 parents 8d2cdc4 + 28652af commit 98a4e44

14 files changed

Lines changed: 214 additions & 112 deletions

File tree

android/KMAPro/kMAPro/build.gradle

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,11 @@ dependencies {
173173
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
174174
implementation 'com.google.android.material:material:1.12.0'
175175
implementation 'com.stepstone.stepper:material-stepper:4.3.1'
176+
implementation 'androidx.webkit:webkit:1.14.0'
176177
implementation files('libs/keyman-engine.aar')
177178
implementation 'io.sentry:sentry-android:8.19.1'
178179
implementation 'androidx.preference:preference:1.2.1'
179-
implementation "com.android.installreferrer:installreferrer:2.2"
180+
implementation 'com.android.installreferrer:installreferrer:2.2'
180181

181182
// Add dependency for generating QR Codes
182183
// (Even though it's embedded in KMEA, because we're manually copying keyman-engine.aar,

android/KMEA/app/build.gradle

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ dependencies {
7272
implementation 'commons-io:commons-io:2.16.1'
7373
implementation 'io.sentry:sentry-android:8.19.1'
7474
implementation 'androidx.preference:preference:1.2.1'
75+
implementation 'androidx.webkit:webkit:1.14.0'
7576

7677
// Robolectric
7778
testImplementation 'androidx.test.ext:junit:1.2.1'

android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboard.java

Lines changed: 56 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
/**
2-
* Copyright (C) 2017-2018 SIL International. All rights reserved.
1+
/*
2+
* Keyman is copyright (C) SIL Global. MIT License.
33
*/
44

55
package com.keyman.engine;
@@ -23,6 +23,7 @@
2323
import com.keyman.engine.util.FileUtils;
2424
import com.keyman.engine.util.KMLog;
2525
import com.keyman.engine.util.KMString;
26+
import com.keyman.engine.util.WebViewUtils;
2627

2728
import android.annotation.SuppressLint;
2829
import android.content.Context;
@@ -85,7 +86,8 @@ final class KMKeyboard extends WebView {
8586

8687
private static String txtFont = "";
8788
private static String oskFont = null;
88-
private static String keyboardRoot = "";
89+
private static String dataRoot = "";
90+
private static String packageRoot = "";
8991
private final String fontUndefined = "undefined";
9092
private GestureDetector gestureDetector;
9193
private static ArrayList<OnKeyboardEventListener> kbEventListeners = null;
@@ -366,8 +368,7 @@ public void loadKeyboard() {
366368
} else {
367369
KMManager.SystemKeyboardWebViewClient.setKeyboardLoaded(false);
368370
}
369-
370-
String htmlPath = "file://" + getContext().getDir("data", Context.MODE_PRIVATE) + "/" + KMManager.KMFilename_KeyboardHtml;
371+
String htmlPath = WebViewUtils.buildAssetUrl(KMManager.KMFilename_KeyboardHtml);
371372
loadUrl(htmlPath);
372373
setBackgroundColor(0);
373374
}
@@ -561,6 +562,7 @@ public static String oskFontFilename() {
561562
return oskFont;
562563
}
563564

565+
// REVIEW: this method seems to be unused
564566
/**
565567
* Return the full path to the special OSK font,
566568
* which is with all the keyboard assets at the root app_data folder
@@ -646,7 +648,7 @@ public boolean prepareKeyboardSwitch(String packageID, String keyboardID, String
646648
}
647649
String kbKey = KMString.format("%s_%s", languageID, keyboardID);
648650

649-
setKeyboardRoot(packageID);
651+
setPackageRoot(packageID);
650652

651653
// Escape single-quoted names for javascript call
652654
keyboardName = keyboardName.replaceAll("\'", "\\\\'"); // Double-escaped-backslash b/c regex.
@@ -702,28 +704,28 @@ public boolean setKeyboard(String packageID, String keyboardID, String languageI
702704
KMManager.getLatestKeyboardFileVersion(getContext(), packageID, keyboardID) : null;
703705
}
704706

705-
setKeyboardRoot(packageID);
707+
setPackageRoot(packageID);
706708

707709
if(kOskFont == null || kOskFont.isEmpty())
708710
kOskFont = kFont;
709711

710-
JSONObject jDisplayFont = makeFontPaths(kFont);
711-
JSONObject jOskFont = makeFontPaths(kOskFont);
712+
JSONObject jDisplayFont = makeFontObject(kFont);
713+
JSONObject jOskFont = makeFontObject(kOskFont);
712714

713715
txtFont = getFontFilename(jDisplayFont);
714716
oskFont = getFontFilename(jOskFont);
715717

716718
String kbKey = KMString.format("%s_%s", languageID, keyboardID);
717719

718-
String keyboardPath = makeKeyboardPath(packageID, keyboardID, keyboardVersion);
720+
String keyboardUrl = makeKeyboardUrl(packageID, keyboardID, keyboardVersion);
719721

720722
JSONObject reg = new JSONObject();
721723
try {
722724
reg.put("KN", keyboardName);
723725
reg.put("KI", "Keyboard_" + keyboardID);
724726
reg.put("KLC", languageID);
725727
reg.put("KL", languageName);
726-
reg.put("KF", keyboardPath);
728+
reg.put("KF", keyboardUrl);
727729
reg.put("KP", packageID);
728730

729731
if (jDisplayFont != null) reg.put("KFont", jDisplayFont);
@@ -809,28 +811,31 @@ private void sendError(String packageID, String keyboardID, String languageID, b
809811
}
810812

811813
// Set the base path of the keyboard depending on the package ID
812-
private void setKeyboardRoot(String packageID) {
814+
private void setPackageRoot(String packageID) {
815+
this.dataRoot = WebViewUtils.buildAssetUrl("");
813816
if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) {
814-
this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() +
815-
File.separator + KMManager.KMDefault_UndefinedPackageID + File.separator);
817+
this.packageRoot = this.dataRoot + KMManager.KMDefault_UndefinedPackageID + "/";
816818
} else {
817-
this.keyboardRoot = (context.getDir("data", Context.MODE_PRIVATE).toString() +
818-
File.separator + KMManager.KMDefault_AssetPackages + File.separator + packageID + File.separator);
819+
this.packageRoot = this.dataRoot + KMManager.KMDefault_AssetPackages + "/" + packageID + "/";
819820
}
820821
}
821822

822-
public String getKeyboardRoot() {
823-
return this.keyboardRoot;
823+
private String getDataRoot() {
824+
return this.dataRoot;
825+
}
826+
827+
private String getPackageRoot() {
828+
return this.packageRoot;
824829
}
825830

826-
private String makeKeyboardPath(String packageID, String keyboardID, String keyboardVersion) {
827-
String keyboardPath;
831+
private String makeKeyboardUrl(String packageID, String keyboardID, String keyboardVersion) {
832+
String keyboardUrl = getPackageRoot();
828833
if (packageID.equals(KMManager.KMDefault_UndefinedPackageID)) {
829-
keyboardPath = getKeyboardRoot() + keyboardID + "-" + keyboardVersion + ".js";
834+
keyboardUrl += keyboardID + "-" + keyboardVersion + ".js";
830835
} else {
831-
keyboardPath = getKeyboardRoot() + keyboardID + ".js";
836+
keyboardUrl += keyboardID + ".js";
832837
}
833-
return keyboardPath;
838+
return keyboardUrl;
834839
}
835840

836841
private void sendKMWError(int lineNumber, String sourceId, String message) {
@@ -1042,13 +1047,26 @@ public void onDismiss() {
10421047
}
10431048

10441049
/**
1045-
* Take a font JSON object and adjust to pass to JS
1046-
* 1. Replace "source" keys for "files" keys
1047-
* 2. Create full font paths for .ttf or .svg
1048-
* @param font String font JSON object as a string
1049-
* @return JSONObject of modified font information with full paths. If font is invalid, return `null`
1050+
* Create a JSON object consisting of the font family and the URLs of the
1051+
* font files on the local device.
1052+
*
1053+
* The `font` parameter can either be the filename of the font (with an
1054+
* extension recognized as font), or a Font object or JSON string.
1055+
* In the former case a new JSON object is created with the font family
1056+
* derived from the filename, and the font filename prefixed with path
1057+
* to the fonts.
1058+
* In the latter case the legacy `sources` key is renamed to `files`.
1059+
* If `files` is a single string it will be prefixed with the path to the
1060+
* fonts. If `files` is an array, the array is iterated until finding
1061+
* the first file with a font extension which is then prefixed with the
1062+
* path to the fonts.
1063+
*
1064+
* @param font A string containing either the font filename or a font JSON
1065+
* object as a string
1066+
* @return JSONObject of modified font information with full paths. If font
1067+
* is invalid, return `null`.
10501068
*/
1051-
private JSONObject makeFontPaths(String font) {
1069+
private JSONObject makeFontObject(String font) {
10521070

10531071
if(font == null || font.equals("")) {
10541072
return null;
@@ -1059,14 +1077,13 @@ private JSONObject makeFontPaths(String font) {
10591077
JSONObject jfont = new JSONObject();
10601078
jfont.put(KMManager.KMKey_FontFamily, font.substring(0, font.length()-4));
10611079
JSONArray jfiles = new JSONArray();
1062-
jfiles.put(keyboardRoot + font);
1080+
String fontRoot = KMManager.isDefaultFont(font) ? getDataRoot() : getPackageRoot();
1081+
jfiles.put(fontRoot + font);
10631082
jfont.put(KMManager.KMKey_FontFiles, jfiles);
10641083
return jfont;
10651084
}
10661085

10671086
JSONObject fontObj = new JSONObject(font);
1068-
JSONArray sourceArray;
1069-
String fontFile;
10701087

10711088
// Replace "sources" key with "files"
10721089
if (fontObj.has(KMManager.KMKey_FontSource)) {
@@ -1076,16 +1093,18 @@ private JSONObject makeFontPaths(String font) {
10761093

10771094
Object obj = fontObj.get(KMManager.KMKey_FontFiles);
10781095
if (obj instanceof String) {
1079-
fontFile = fontObj.getString(KMManager.KMKey_FontFiles);
1080-
fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + obj);
1096+
String fontFile = fontObj.getString(KMManager.KMKey_FontFiles);
1097+
String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRoot() : getPackageRoot();
1098+
fontObj.put(KMManager.KMKey_FontFiles, fontRoot + obj);
10811099
return fontObj;
10821100
} else if (obj instanceof JSONArray) {
1083-
sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles);
1101+
JSONArray sourceArray = fontObj.optJSONArray(KMManager.KMKey_FontFiles);
10841102
if (sourceArray != null) {
10851103
for (int i = 0; i < sourceArray.length(); i++) {
1086-
fontFile = sourceArray.getString(i);
1104+
String fontFile = sourceArray.getString(i);
10871105
if (FileUtils.hasFontExtension(fontFile)) {
1088-
fontObj.put(KMManager.KMKey_FontFiles, keyboardRoot + fontFile);
1106+
String fontRoot = KMManager.isDefaultFont(fontFile) ? getDataRoot() : getPackageRoot();
1107+
fontObj.put(KMManager.KMKey_FontFiles, fontRoot + fontFile);
10891108
fontObj.remove(KMManager.KMKey_FontSource);
10901109
return fontObj;
10911110
}
@@ -1094,7 +1113,6 @@ private JSONObject makeFontPaths(String font) {
10941113
}
10951114
} catch (JSONException e) {
10961115
KMLog.LogException(TAG, "Failed to make font for '"+font+"'", e);
1097-
return null;
10981116
}
10991117

11001118
return null;

android/KMEA/app/src/main/java/com/keyman/engine/KMKeyboardWebViewClient.java

Lines changed: 44 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
/**
2-
* Copyright (C) 2023 SIL International. All rights reserved.
1+
/*
2+
* Keyman is copyright (C) SIL Global. MIT License.
33
*/
44
package com.keyman.engine;
55

@@ -9,16 +9,21 @@
99
import android.graphics.RectF;
1010
import android.net.Uri;
1111
import android.util.Log;
12+
import android.webkit.WebResourceRequest;
13+
import android.webkit.WebResourceResponse;
1214
import android.webkit.WebView;
1315
import android.webkit.WebViewClient;
1416
import android.widget.RelativeLayout;
17+
import androidx.webkit.WebViewAssetLoader;
18+
import androidx.webkit.WebViewAssetLoader.InternalStoragePathHandler;
1519

1620
import com.keyman.engine.KeyboardEventHandler.EventType;
1721
import com.keyman.engine.KMManager;
1822
import com.keyman.engine.KMManager.KeyboardType;
1923
import com.keyman.engine.KMManager.SuggestionType;
2024
import com.keyman.engine.util.KMLog;
2125
import com.keyman.engine.data.Keyboard;
26+
import com.keyman.engine.util.WebViewUtils;
2227

2328
import org.json.JSONObject;
2429

@@ -31,11 +36,16 @@ public final class KMKeyboardWebViewClient extends WebViewClient {
3136
public Context context;
3237
private KeyboardType keyboardType;
3338
private boolean keyboardLoaded;
39+
private WebViewAssetLoader assetLoader;
3440

3541
KMKeyboardWebViewClient(Context context, KeyboardType keyboardType) {
3642
this.context = context;
3743
this.keyboardType = keyboardType;
3844
this.keyboardLoaded = false;
45+
this.assetLoader = new WebViewAssetLoader.Builder()
46+
.addPathHandler(WebViewUtils.ASSET_DATA_PATH,
47+
new InternalStoragePathHandler(context, context.getDir("data", Context.MODE_PRIVATE)))
48+
.build();
3949

4050
if (keyboardType != KeyboardType.KEYBOARD_TYPE_INAPP && keyboardType != KeyboardType.KEYBOARD_TYPE_SYSTEM) {
4151
KMLog.LogError(TAG, String.format("Cannot initialize: Invalid keyboard type: %s", keyboardType.toString()));
@@ -58,6 +68,11 @@ public void setKeyboardLoaded(boolean keyboardLoaded) {
5868
public void onPageStarted(WebView view, String url, Bitmap favicon) {
5969
}
6070

71+
@Override
72+
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
73+
return this.assetLoader.shouldInterceptRequest(request.getUrl());
74+
}
75+
6176
@Override
6277
public void onPageFinished(WebView view, String url) {
6378
Log.d("KMEA", String.format("onPageFinished: [%s] %s", keyboardType.toString(), url));
@@ -74,46 +89,44 @@ private void pageLoaded(WebView view, String url) {
7489
kmKeyboard.keyboardSet = false;
7590
KMManager.currentLexicalModel = null;
7691

77-
if (url.startsWith("file")) { // TODO: is this test necessary?
78-
this.keyboardLoaded = true;
92+
this.keyboardLoaded = true;
7993

80-
SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE);
81-
int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0);
82-
if (index < 0) {
83-
index = 0;
94+
SharedPreferences prefs = context.getSharedPreferences(context.getString(R.string.kma_prefs_name), Context.MODE_PRIVATE);
95+
int index = prefs.getInt(KMManager.KMKey_UserKeyboardIndex, 0);
96+
Keyboard keyboardInfo = null;
97+
if (index >= 0) {
98+
keyboardInfo = KMManager.getKeyboardInfo(context, index);
99+
}
100+
String langId = null;
101+
if (keyboardInfo != null) {
102+
langId = keyboardInfo.getLanguageID();
103+
kmKeyboard.setKeyboard(keyboardInfo);
104+
} else {
105+
// Revert to default (index 0) or fallback keyboard
106+
keyboardInfo = KMManager.getKeyboardInfo(context, 0);
107+
if (keyboardInfo == null) {
108+
// Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses
109+
keyboardInfo = KMManager.getDefaultKeyboard(context);
84110
}
85-
Keyboard keyboardInfo = KMManager.getKeyboardInfo(context, index);
86-
String langId = null;
87111
if (keyboardInfo != null) {
88112
langId = keyboardInfo.getLanguageID();
89113
kmKeyboard.setKeyboard(keyboardInfo);
90-
} else {
91-
// Revert to default (index 0) or fallback keyboard
92-
keyboardInfo = KMManager.getKeyboardInfo(context, 0);
93-
if (keyboardInfo == null) {
94-
// Don't log to Sentry because some keyboard apps like FV don't install keyboards until the user chooses
95-
keyboardInfo = KMManager.getDefaultKeyboard(context);
96-
}
97-
if (keyboardInfo != null) {
98-
langId = keyboardInfo.getLanguageID();
99-
kmKeyboard.setKeyboard(keyboardInfo);
100-
}
101114
}
115+
}
102116

103-
KMManager.registerAssociatedLexicalModel(langId);
117+
KMManager.registerAssociatedLexicalModel(langId);
104118

105-
kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time!
119+
kmKeyboard.showHelpBubbleAfterDelay(2000, true); // check if it should be shown at that time!
106120

107-
kmKeyboard.callJavascriptAfterLoad();
108-
kmKeyboard.setSpacebarText(KMManager.getSpacebarText());
121+
kmKeyboard.callJavascriptAfterLoad();
122+
kmKeyboard.setSpacebarText(KMManager.getSpacebarText());
109123

110-
KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null);
124+
KeyboardEventHandler.notifyListeners(KMTextView.kbEventListeners, keyboardType, EventType.KEYBOARD_LOADED, null);
111125

112-
// Special handling for in-app TextView context keymanapp/keyman#3809
113-
if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP &&
114-
KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) {
115-
KMTextView.updateTextContext();
116-
}
126+
// Special handling for in-app TextView context keymanapp/keyman#3809
127+
if (keyboardType == KeyboardType.KEYBOARD_TYPE_INAPP &&
128+
KMTextView.activeView != null && KMTextView.activeView.getClass() == KMTextView.class) {
129+
KMTextView.updateTextContext();
117130
}
118131
}
119132

0 commit comments

Comments
 (0)