diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KMKeyboardDownloaderActivity.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KMKeyboardDownloaderActivity.java index c2d7a1e9f9..c122381785 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KMKeyboardDownloaderActivity.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KMKeyboardDownloaderActivity.java @@ -33,7 +33,7 @@ public class KMKeyboardDownloaderActivity extends AppCompatActivity { public static final String ARG_MODEL_ID = "KMKeyboardActivity.modelID"; public static final String ARG_MODEL_NAME = "KMKeyboardActivity.modelName"; public static final String ARG_MODEL_URL = "KMKeyboardActivity.modelURL"; - public static final String ARG_MODEL_CUSTOM_HELP_LINK = "KMKeyboardActivity.customHelpLink"; + public static final String ARG_CUSTOM_HELP_LINK = "KMKeyboardActivity.customHelpLink"; // custom keyboard public static final String ARG_KEYBOARD = "KMKeyboardActivity.keyboard"; diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardInfoActivity.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardInfoActivity.java index 090581b02e..84eccb725f 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardInfoActivity.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardInfoActivity.java @@ -4,7 +4,6 @@ package com.tavultesoft.kmea; -import java.io.File; import java.util.ArrayList; import java.util.HashMap; @@ -16,7 +15,6 @@ import android.graphics.Bitmap; import android.graphics.Typeface; import android.net.Uri; import android.os.Bundle; -import androidx.core.content.FileProvider; import android.util.Log; import android.view.View; import android.view.ViewGroup; @@ -30,8 +28,8 @@ import android.widget.SimpleAdapter; import android.widget.TextView; import android.widget.Toast; -import com.tavultesoft.kmea.util.FileUtils; import com.tavultesoft.kmea.util.FileProviderUtils; +import com.tavultesoft.kmea.util.HelpFile; import com.tavultesoft.kmea.util.MapCompat; import com.tavultesoft.kmea.util.QRCodeUtil; @@ -52,7 +50,6 @@ public final class KeyboardInfoActivity extends AppCompatActivity { super.onCreate(savedInstanceState); supportRequestWindowFeature(Window.FEATURE_NO_TITLE); final Context context = this; - final String authority = FileProviderUtils.getAuthority(context); setContentView(R.layout.activity_list_layout); toolbar = (Toolbar) findViewById(R.id.list_toolbar); @@ -126,30 +123,15 @@ public final class KeyboardInfoActivity extends AppCompatActivity { @Override public void onItemClick(AdapterView parent, View view, int position, long id) { if (position == 1) { - Intent i = new Intent(Intent.ACTION_VIEW); - if (customHelpLink != null) { - if (FileUtils.isWelcomeFile(customHelpLink) && ! KMManager.isTestMode()) { - File customHelp = new File(new File(customHelpLink).getAbsolutePath()); - i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - // Starting with Android N, you can't pass file:// to intents, so we use FileProvider - try { - Uri contentUri = FileProvider.getUriForFile( - context, authority, customHelp); - i.setDataAndType(contentUri, "text/html"); - } catch (NullPointerException e) { - String message = "FileProvider undefined in app to load" + customHelp.toString(); - Toast.makeText(context, message, Toast.LENGTH_LONG).show(); - Log.e("KeyboardInfoActivity", message); - } - } - else { - i.setData(Uri.parse(customHelpLink)); - } + // Display local welcome.htm help file, including associated assets + Intent i = HelpFile.toActionView(context, customHelpLink, packageID); + if (FileProviderUtils.exists(context)|| KMManager.isTestMode()) { startActivity(i); } } else { + Intent i = new Intent(Intent.ACTION_VIEW); i.setData(Uri.parse(helpUrlStr)); startActivity(i); } diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardSettingsActivity.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardSettingsActivity.java index 2e9ed67fd5..6921b86dcb 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardSettingsActivity.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/KeyboardSettingsActivity.java @@ -33,6 +33,7 @@ import android.widget.Toast; import com.tavultesoft.kmea.util.FileUtils; import com.tavultesoft.kmea.util.FileProviderUtils; +import com.tavultesoft.kmea.util.HelpFile; import com.tavultesoft.kmea.util.MapCompat; import com.tavultesoft.kmea.util.QRCodeUtil; @@ -140,33 +141,19 @@ public final class KeyboardSettingsActivity extends AppCompatActivity { // "Help" link clicked if (itemTitle.equals(getString(R.string.help_link))) { - Intent i = new Intent(Intent.ACTION_VIEW); - if (customHelpLink != null) { - if (FileUtils.isWelcomeFile(customHelpLink)) { - File customHelp = new File(new File(customHelpLink).getAbsolutePath()); - i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - // Starting with Android N, you can't pass file:// to intents, so we use FileProvider - try { - Uri contentUri = FileProvider.getUriForFile( - context, authority, customHelp); - i.setDataAndType(contentUri, "text/html"); - } catch (NullPointerException e) { - String message = "FileProvider undefined in app to load" + customHelp.toString(); - Toast.makeText(context, message, Toast.LENGTH_LONG).show(); - Log.e("TAG", message); - } - } - else { - i.setData(Uri.parse(customHelpLink)); - } - if (FileProviderUtils.exists(context)) { + // Display local welcome.htm help file, including associated assets + Intent i = HelpFile.toActionView(context, customHelpLink, packageID); + + if (FileProviderUtils.exists(context) || KMManager.isTestMode()) { startActivity(i); } } else { + Intent i = new Intent(Intent.ACTION_VIEW); i.setData(Uri.parse(helpUrlStr)); startActivity(i); } + // "Uninstall Keyboard" clicked } else if (itemTitle.equals(getString(R.string.uninstall_keyboard))) { // Uninstall selected keyboard diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/ModelInfoActivity.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/ModelInfoActivity.java index 8f78d3a359..b360dbda6f 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/ModelInfoActivity.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/ModelInfoActivity.java @@ -28,6 +28,7 @@ import android.widget.Toast; import com.tavultesoft.kmea.util.FileUtils; import com.tavultesoft.kmea.util.FileProviderUtils; +import com.tavultesoft.kmea.util.HelpFile; import com.tavultesoft.kmea.util.MapCompat; import static com.tavultesoft.kmea.ConfirmDialogFragment.DialogType.DIALOG_TYPE_DELETE_MODEL; @@ -81,7 +82,7 @@ public final class ModelInfoActivity extends AppCompatActivity { // Currently, model help only available if custom link exists String icon = String.valueOf(R.drawable.ic_arrow_forward); // Don't show help link arrow if both custom help and File Provider don't exist - // TODO: Update this when model help available on help.keyman.com + // TODO: Update this when model help available on help.keyman.com if ( (!customHelpLink.equals("") && !FileProviderUtils.exists(context)) || customHelpLink.equals("") ){ icon = noIcon; @@ -128,32 +129,17 @@ public final class ModelInfoActivity extends AppCompatActivity { // "Help" link clicked if (itemTitle.equals(getString(R.string.help_link))) { - Intent i = new Intent(Intent.ACTION_VIEW); - if (!customHelpLink.equals("")) { - if (FileUtils.isWelcomeFile(customHelpLink)) { - File customHelp = new File(new File(customHelpLink).getAbsolutePath()); - i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - // Starting with Android N, you can't pass file:// to intents, so we use FileProvider - try { - Uri contentUri = FileProvider.getUriForFile( - context, authority, customHelp); - i.setDataAndType(contentUri, "text/html"); - } catch (NullPointerException e) { - String message = "FileProvider undefined in app to load" + customHelp.toString(); - Toast.makeText(context, message, Toast.LENGTH_LONG).show(); - Log.e("ModelInfoActivity", message); - } - } - else { - i.setData(Uri.parse(customHelpLink)); - } - if (FileProviderUtils.exists(context)) { + // Display local welcome.htm help file, including associated assets + Intent i = HelpFile.toActionView(context, customHelpLink, packageID); + + if (FileProviderUtils.exists(context) || KMManager.isTestMode()) { startActivity(i); } } else { // We should always have a help file packaged with models. } + // "Uninstall Model" clicked } else if (itemTitle.equals(getString(R.string.uninstall_model))) { // Uninstall selected model diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtil.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtil.java index a6070b6fc2..0a83d73733 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtil.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtil.java @@ -37,7 +37,7 @@ public class CloudDataJsonUtil { public static HashMap createKeyboardInfoMap(String aPackageId,String aLanguageId, String aLanguageName, String aKeyboardId, String aKeyboardName, String aKeyboardVersion, String anIsCustomKeyboard, - String aFont, String aOskFont) + String aFont, String aOskFont, String aCustomHelpLink) { HashMap keyboardInfo = new HashMap(); keyboardInfo.put(KMManager.KMKey_PackageID, aPackageId); @@ -48,8 +48,12 @@ public class CloudDataJsonUtil { keyboardInfo.put(KMManager.KMKey_KeyboardVersion, aKeyboardVersion); keyboardInfo.put(KMManager.KMKey_CustomKeyboard, anIsCustomKeyboard); keyboardInfo.put(KMManager.KMKey_Font, aFont); - if (aOskFont != null) + if (aOskFont != null) { keyboardInfo.put(KMManager.KMKey_OskFont, aOskFont); + } + if (aCustomHelpLink != null) { + keyboardInfo.put(KMManager.KMKey_CustomHelpLink, aCustomHelpLink); + } return keyboardInfo; } @@ -81,9 +85,10 @@ public class CloudDataJsonUtil { String kbName = keyboardJSON.getString(KMManager.KMKey_Name); String kbVersion = keyboardJSON.optString(KMManager.KMKey_KeyboardVersion, "1.0"); String kbFont = keyboardJSON.optString(KMManager.KMKey_Font, ""); + String customHelpLink = keyboardJSON.optString(KMManager.KMKey_CustomHelpLink, null); //String kbKey = String.format("%s_%s", langID, kbID); - HashMap hashMap = createKeyboardInfoMap(pkgID,langID,langName,kbID,kbName,kbVersion,isCustom,kbFont,null); + HashMap hashMap = createKeyboardInfoMap(pkgID,langID,langName,kbID,kbName,kbVersion,isCustom,kbFont,null, customHelpLink); // if (keyboardModifiedDates.get(kbID) == null) { diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/impl/CloudKeyboardMetaDataDownloadCallback.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/impl/CloudKeyboardMetaDataDownloadCallback.java index 07c7fb5f9c..4938057056 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/impl/CloudKeyboardMetaDataDownloadCallback.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/cloud/impl/CloudKeyboardMetaDataDownloadCallback.java @@ -271,12 +271,13 @@ public class CloudKeyboardMetaDataDownloadCallback implements ICloudDownloadCall String _font = _keyboard.optString(KMManager.KMKey_Font); String _oskFont = _keyboard.optString(KMManager.KMKey_OskFont); + String _customHelpLink = _keyboard.optString(KMManager.KMKey_CustomHelpLink, null); theKbData.additionalDownloadid = CloudKeyboardDataDownloadCallback.createDownloadId(_key_id); theKbData.keyboardInfo = CloudDataJsonUtil .createKeyboardInfoMap( - _pkgID, _lang_id, _langName, _key_id, _kbName, _kbVersion, _kbIsCustom, _font, _oskFont); + _pkgID, _lang_id, _langName, _key_id, _kbName, _kbVersion, _kbIsCustom, _font, _oskFont, _customHelpLink); theKbData.additionalDownloads = urls; } catch(JSONException _e) diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/Keyboard.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/Keyboard.java index fa088416a8..bed03bbbb4 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/Keyboard.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/Keyboard.java @@ -5,6 +5,7 @@ import android.os.Bundle; import com.tavultesoft.kmea.KMKeyboardDownloaderActivity; import com.tavultesoft.kmea.KMManager; import com.tavultesoft.kmea.KeyboardPickerActivity; +import com.tavultesoft.kmea.util.MapCompat; import java.io.Serializable; import java.util.Map; @@ -50,6 +51,13 @@ public class Keyboard implements Serializable, LanguageResource { return this.map.get(KMManager.KMKey_KeyboardName); } + public String getCustomHelpLink() { + if (this.map.containsKey(KMManager.KMKey_CustomHelpLink)) { + return this.map.get(KMManager.KMKey_CustomHelpLink); + } + return null; + } + public String getVersion() { return this.map.get(KMManager.KMKey_KeyboardVersion); } @@ -60,6 +68,7 @@ public class Keyboard implements Serializable, LanguageResource { public Bundle buildDownloadBundle() { Bundle bundle = new Bundle(); + bundle.putString(KMKeyboardDownloaderActivity.ARG_PKG_ID, getPackage()); bundle.putString(KMKeyboardDownloaderActivity.ARG_KB_ID, getResourceId()); bundle.putString(KMKeyboardDownloaderActivity.ARG_LANG_ID, getLanguageCode()); @@ -70,9 +79,13 @@ public class Keyboard implements Serializable, LanguageResource { if(isCustom == null) { isCustom = "N"; } - bundle.putBoolean(KMKeyboardDownloaderActivity.ARG_IS_CUSTOM, isCustom.equals("Y")); + String customHelpLink = map.get(KMManager.KMKey_CustomHelpLink); + if (customHelpLink != null) { + bundle.putString(KMKeyboardDownloaderActivity.ARG_CUSTOM_HELP_LINK, getCustomHelpLink()); + } + return bundle; } diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LanguageResource.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LanguageResource.java index 635c76580b..fb687637a9 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LanguageResource.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LanguageResource.java @@ -9,6 +9,7 @@ public interface LanguageResource { String getLanguageName(); String getVersion(); String getPackage(); + String getCustomHelpLink(); Bundle buildDownloadBundle(); } diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LexicalModel.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LexicalModel.java index 2dddca0ddb..876013eb56 100644 --- a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LexicalModel.java +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/data/LexicalModel.java @@ -4,6 +4,7 @@ import android.os.Bundle; import com.tavultesoft.kmea.KMKeyboardDownloaderActivity; import com.tavultesoft.kmea.KMManager; +import com.tavultesoft.kmea.util.MapCompat; import java.io.Serializable; import java.util.Map; @@ -52,6 +53,13 @@ public class LexicalModel implements Serializable, LanguageResource { return this.map.get(KMManager.KMKey_PackageID); } + public String getCustomHelpLink() { + if (this.map.containsKey(KMManager.KMKey_CustomHelpLink)) { + return this.map.get(KMManager.KMKey_CustomHelpLink); + } + return null; + } + public Bundle buildDownloadBundle() { Bundle bundle = new Bundle(); @@ -64,8 +72,6 @@ public class LexicalModel implements Serializable, LanguageResource { return null; } - String customHelpLink = map.get(KMManager.KMKey_CustomHelpLink); - bundle.putString(KMKeyboardDownloaderActivity.ARG_PKG_ID, getPackage()); bundle.putString(KMKeyboardDownloaderActivity.ARG_MODEL_ID, getResourceId()); bundle.putString(KMKeyboardDownloaderActivity.ARG_LANG_ID, getLanguageCode()); @@ -73,7 +79,11 @@ public class LexicalModel implements Serializable, LanguageResource { bundle.putString(KMKeyboardDownloaderActivity.ARG_LANG_NAME, getLanguageName()); bundle.putBoolean(KMKeyboardDownloaderActivity.ARG_IS_CUSTOM, false); bundle.putString(KMKeyboardDownloaderActivity.ARG_MODEL_URL, modelURL); - bundle.putString(KMKeyboardDownloaderActivity.ARG_MODEL_CUSTOM_HELP_LINK, customHelpLink); + + String customHelpLink = map.get(KMManager.KMKey_CustomHelpLink); + if (customHelpLink != null) { + bundle.putString(KMKeyboardDownloaderActivity.ARG_CUSTOM_HELP_LINK, customHelpLink); + } return bundle; } diff --git a/android/KMEA/app/src/main/java/com/tavultesoft/kmea/util/HelpFile.java b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/util/HelpFile.java new file mode 100644 index 0000000000..588a7546db --- /dev/null +++ b/android/KMEA/app/src/main/java/com/tavultesoft/kmea/util/HelpFile.java @@ -0,0 +1,89 @@ +package com.tavultesoft.kmea.util; + +import android.content.ClipData; +import android.content.ClipDescription; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import android.widget.Toast; + +import androidx.core.content.FileProvider; + +import com.tavultesoft.kmea.KMManager; +import com.tavultesoft.kmea.util.FileProviderUtils; +import com.tavultesoft.kmea.util.FileUtils; + +import java.io.File; +import java.io.FileFilter; + + +public final class HelpFile { + private static final String TAG = "HelpFile"; + private static final String[] ASSET_MIME_TYPES = { + ClipDescription.MIMETYPE_TEXT_HTML, + "text/css", + "image/gif", + "image/jpeg", + "image/png"}; + + /** + * Utility to pass a help file and all associated assets to an Intent for Intent.ACTION_VIEW + * @param context + * @param helpFile Full path string of the html file to view + * @param packageID String of the package ID + * @return Intent + */ + public static Intent toActionView(Context context, String helpFile, String packageID) { + Intent i = new Intent(Intent.ACTION_VIEW); + + if (FileUtils.isWelcomeFile(helpFile) && ! KMManager.isTestMode()) { + File customHelp = new File(new File(helpFile).getAbsolutePath()); + i.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + // Starting with Android N, you can't pass file:// to intents, so we use FileProvider + try { + final String authority = FileProviderUtils.getAuthority(context); + Uri contentUri = FileProvider.getUriForFile( + context, authority, customHelp); + i.setDataAndType(contentUri, "text/html"); + + // Grant read permission to all the files in the package so embedded assets can be viewed + ClipData clipData = new ClipData(null, ASSET_MIME_TYPES, new ClipData.Item(contentUri)); + + // Exclude html help files and JS files. Treat rest of the files as assets + FileFilter _fileFilter = new FileFilter() { + @Override + public boolean accept(File pathname) { + String name = pathname.getName(); + if (pathname.isFile() && (FileUtils.isReadmeFile(name) || + FileUtils.isWelcomeFile(name) || FileUtils.hasJavaScriptExtension(name))) { + return false; + } + return true; + } + }; + + String base = helpFile.contains("packages") ? "packages" : "models"; + File packageDir = new File( + context.getDir("data", Context.MODE_PRIVATE), base + File.separator + packageID + File.separator); + File[] files = packageDir.listFiles(_fileFilter); + for(File assetFile : files) { + Uri assetUri = FileProvider.getUriForFile( + context, authority, assetFile); + clipData.addItem(new ClipData.Item(assetUri)); + } + + // Associate assets in clipData to the intent + i.setClipData(clipData); + } catch (NullPointerException e) { + String message = "FileProvider undefined in app to load" + customHelp.toString(); + Toast.makeText(context, message, Toast.LENGTH_LONG).show(); + Log.e(TAG, message); + } + } else { + i.setData(Uri.parse(helpFile)); + } + return i; + } + +} diff --git a/android/KMEA/app/src/test/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtilTest.java b/android/KMEA/app/src/test/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtilTest.java index dc41a7fdaa..906f965bad 100644 --- a/android/KMEA/app/src/test/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtilTest.java +++ b/android/KMEA/app/src/test/java/com/tavultesoft/kmea/cloud/CloudDataJsonUtilTest.java @@ -30,6 +30,7 @@ public class CloudDataJsonUtilTest { private final String customKeyboard = "N"; private final String aFont = "AndikaAfr"; private final String oskFont = aFont; + private final String customHelpLink = ""; @Before public void initializeTestPackage() { @@ -40,7 +41,7 @@ public class CloudDataJsonUtilTest { public void shouldLowercaseLanguageID() { // Test createKeyboardInfoMap() used by processKeyboardJSON() HashMap kbInfo = CloudDataJsonUtil.createKeyboardInfoMap( - pkgID, langID, langName, keyboardID, keyboardName, keyboardVersion, customKeyboard, aFont, oskFont); + pkgID, langID, langName, keyboardID, keyboardName, keyboardVersion, customKeyboard, aFont, oskFont, customHelpLink); Assert.assertEquals(langID.toLowerCase(), kbInfo.get(KMManager.KMKey_LanguageID)); // Test processLexicalModelJSON() diff --git a/android/history.md b/android/history.md index f06252b5a4..c39b05497e 100644 --- a/android/history.md +++ b/android/history.md @@ -22,6 +22,8 @@ * Fix memory issues during build process (#2361) * Fix crashes when parsing JSON data from Cloud (#2393) * Improve compatibility with applications such as Gmail, Chrome that do not conform to the Android input APIs (#2382, #2376) + * Propagate custom help links (#2448) + * Fix file permissions for viewing welcome.htm assets (#2465) ## 2019-12-12 12.0.4214 stable * Bug fix: diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift index 89757bd9b0..a964fa468d 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Manager.swift @@ -44,6 +44,8 @@ public class Manager: NSObject, UIGestureRecognizerDelegate { public static let shared = Manager() + public var fileBrowserLauncher: ((UINavigationController) -> Void)? = nil + /// Display the help bubble on first use. public var isKeymanHelpOn = true @@ -976,9 +978,12 @@ public class Manager: NSObject, UIGestureRecognizerDelegate { // Keyboard download notification observers private func keyboardDownloadCompleted(_ keyboards: [InstallableKeyboard]) { - // TODO: Only do this if it's an update. We'll need a bit of notification retooling for this first. + // There's little harm in reloading the keyboard (and thus, KMW) for a clean reset + // after resource downloads or updates. That said, we should avoid *directly* + // triggering an immediate reset, as an extra reset will occur once we leave the + // settings menu. The delay also helps any chained downloads (keyboard > lexical model) + // to fully complete first. shouldReloadKeyboard = true - inputViewController.reload() } /*----------------------------- diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift index bdf91122f8..cefc8f1649 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceDownloadManager.swift @@ -581,4 +581,8 @@ public class ResourceDownloadManager { return updateQueue } + + public func installLexicalModelPackage(at packageURL: URL) -> InstallableLexicalModel? { + return downloader.installLexicalModelPackage(downloadedPackageFile: packageURL) + } } diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift index d6bc2820d2..5d91bd6431 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Resource Management/ResourceFileManager.swift @@ -45,6 +45,12 @@ public class ResourceFileManager { var destinationUrl = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] destinationUrl.appendPathComponent(url.lastPathComponent) + // Since it's possible to request an install from a KMP in our owned document space, + // we need to check that it's not already in place where we want it. + if url == destinationUrl { + return url + } + do { try copyWithOverwrite(from: url, to: destinationUrl) return destinationUrl @@ -103,7 +109,9 @@ public class ResourceFileManager { }) } - public func promptPackageInstall(of package: KeymanPackage, in rootVC: UIViewController) { + public func promptPackageInstall(of package: KeymanPackage, + in rootVC: UIViewController, + successHandler: ((KeymanPackage) -> Void)? = nil) { let vc = PackageInstallViewController(for: package, completionHandler: { error in if let err = error { if let kmpError = err as? KMPError { @@ -111,7 +119,9 @@ public class ResourceFileManager { rootVC.present(alert, animated: true, completion: nil) } } else { - let alert = self.buildSimpleAlert(title: "Success", message: "Installed successfully.") + let alert = self.buildSimpleAlert(title: "Success", message: "Installed successfully.", completionHandler: { + successHandler?(package) + }) rootVC.present(alert, animated: true, completion: nil) } }) @@ -124,12 +134,14 @@ public class ResourceFileManager { return buildSimpleAlert(title: "Error", message: error.rawValue) } - public func buildSimpleAlert(title: String, message: String) -> UIAlertController { + public func buildSimpleAlert(title: String, message: String, completionHandler: (() -> Void)? = nil ) -> UIAlertController { let alertController = UIAlertController(title: title, message: message, preferredStyle: UIAlertController.Style.alert) alertController.addAction(UIAlertAction(title: "OK", style: UIAlertAction.Style.default, - handler: nil)) + handler: { _ in + completionHandler?() + })) //UIApplication.shared.keyWindow?.rootViewController?.present(alertController, animated: true, completion: nil) return alertController diff --git a/ios/engine/KMEI/KeymanEngine/Classes/Settings/SettingsViewController.swift b/ios/engine/KMEI/KeymanEngine/Classes/Settings/SettingsViewController.swift index 5e4d0a9255..6183fd9b04 100644 --- a/ios/engine/KMEI/KeymanEngine/Classes/Settings/SettingsViewController.swift +++ b/ios/engine/KMEI/KeymanEngine/Classes/Settings/SettingsViewController.swift @@ -31,6 +31,8 @@ open class SettingsViewController: UITableViewController { } @objc func doneClicked(_ sender: Any) { + // While the called method might should be renamed, it does the job well enough. + // This resets KMW so that any new and/or updated resources can be properly loaded. Manager.shared.dismissKeyboardPicker(self) } @@ -69,6 +71,16 @@ open class SettingsViewController: UITableViewController { "subtitle": "", "reuseid" : "showgetstarted" ]) + + // The iOS Files app is only available with 11.0+. + if #available(iOS 11.0, *) { + itemsArray.append([ + "title": "Install From File", + "subtitle": "Browse for .kmp files", + "reuseid" : "installfile" + ]) + } + _ = view } @@ -84,8 +96,7 @@ open class SettingsViewController: UITableViewController { } override open func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - // #warning Incomplete implementation, return the number of rows - return 3 + return itemsArray.count } public func frameAtRightOfCell(cell cellFrame: CGRect, controlSize: CGSize) -> CGRect { @@ -148,6 +159,8 @@ open class SettingsViewController: UITableViewController { showAgainSwitch.rightAnchor.constraint(equalTo: cell.layoutMarginsGuide.rightAnchor).isActive = true showAgainSwitch.centerYAnchor.constraint(equalTo: cell.layoutMarginsGuide.centerYAnchor).isActive = true } + case "installfile": + cell.accessoryType = .disclosureIndicator default: log.error("unknown cellIdentifier(\"\(cellIdentifier ?? "EMPTY")\")") cell.accessoryType = .none @@ -187,6 +200,8 @@ open class SettingsViewController: UITableViewController { if indexPath.row == 0 { cell.accessoryType = .disclosureIndicator + } else if indexPath.row == 3 { + cell.accessoryType = .disclosureIndicator } else { cell.textLabel?.isEnabled = true cell.detailTextLabel?.isEnabled = false @@ -207,7 +222,18 @@ open class SettingsViewController: UITableViewController { private func performAction(for indexPath: IndexPath) { switch indexPath.section { case 0: - showLanguages() + switch indexPath.row { + case 0: + showLanguages() + case 3: + if let block = Manager.shared.fileBrowserLauncher { + block(navigationController!) + } else { + log.info("Listener for framework signal to launch file browser is missing") + } + default: + break + } default: break } diff --git a/ios/history.md b/ios/history.md index 74306c970a..2f0ff00392 100644 --- a/ios/history.md +++ b/ios/history.md @@ -2,6 +2,7 @@ ## 13.0 alpha * Start version 13.0 +* Adds file browsing for installable KMPs and makes KMPs for resources installed this way available to the Files app (#2457) * Testing for upcoming patch to stable: * Fixes for deprecated code, improving maintainability (#2282) diff --git a/ios/keyman/Keyman/Keyman-Info.plist b/ios/keyman/Keyman/Keyman-Info.plist index 2263aad0d4..29f1c86509 100644 --- a/ios/keyman/Keyman/Keyman-Info.plist +++ b/ios/keyman/Keyman/Keyman-Info.plist @@ -80,7 +80,7 @@ UIInterfaceOrientationLandscapeRight UISupportsDocumentBrowser - + UTExportedTypeDeclarations diff --git a/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj b/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj index d8ba19390b..29e7bb8282 100644 --- a/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj +++ b/ios/keyman/Keyman/Keyman.xcodeproj/project.pbxproj @@ -136,6 +136,7 @@ CE6138021FB999C8009D0EF2 /* KeymanEngine.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE6138031FB999C8009D0EF2 /* KeymanEngine.framework */; }; CE79CDB52370111200010C06 /* Themes+Colors.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CE79CDB42370111200010C06 /* Themes+Colors.xcassets */; }; CE7C1AE2236925D800100C2C /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = CE7C1AE1236925D800100C2C /* LaunchScreen.storyboard */; }; + CE7FF1F0239A0293007859D9 /* PackageBrowserViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7FF1EF239A0293007859D9 /* PackageBrowserViewController.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -300,6 +301,7 @@ CE6138031FB999C8009D0EF2 /* KeymanEngine.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = KeymanEngine.framework; sourceTree = BUILT_PRODUCTS_DIR; }; CE79CDB42370111200010C06 /* Themes+Colors.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Themes+Colors.xcassets"; sourceTree = ""; }; CE7C1AE1236925D800100C2C /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; + CE7FF1EF239A0293007859D9 /* PackageBrowserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageBrowserViewController.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -524,6 +526,7 @@ 9845A7C71A439A9200544E2E /* SetUpViewController */, 98ABADBA176935E400B62590 /* Supporting Files */, C0FF769D1F5D4ECB00BD23C3 /* ActivityItemProvider.swift */, + CE7FF1EF239A0293007859D9 /* PackageBrowserViewController.swift */, C0FF769C1F5D4ECA00BD23C3 /* Keyman-Bridging-Header.h */, C0E943F71F61234C00E7D98C /* UIImage+Helpers.swift */, C0E943F91F6124E100E7D98C /* AppDelegate.swift */, @@ -928,6 +931,7 @@ C055F0B11F60E8D400140735 /* GetStartedViewController.swift in Sources */, C055F0B51F610FB200140735 /* DropDownListView.swift in Sources */, C059FCC01FD927EF00BD1A64 /* Log.swift in Sources */, + CE7FF1F0239A0293007859D9 /* PackageBrowserViewController.swift in Sources */, C0E943F81F61234C00E7D98C /* UIImage+Helpers.swift in Sources */, C0E943FE1F61377900E7D98C /* MainViewController.swift in Sources */, 162E2C9920926C8600F40769 /* UIView+Extensions.swift in Sources */, diff --git a/ios/keyman/Keyman/Keyman/MainViewController.swift b/ios/keyman/Keyman/Keyman/MainViewController.swift index 73ded14a8f..e4d544962d 100644 --- a/ios/keyman/Keyman/Keyman/MainViewController.swift +++ b/ios/keyman/Keyman/Keyman/MainViewController.swift @@ -122,6 +122,15 @@ class MainViewController: UIViewController, TextViewDelegate, UIActionSheetDeleg observer: self, function: MainViewController.keyboardRemoved) + // Unfortunately, it's the main app with the file definitions. + // We have to gerry-rig this so that the framework-based SettingsViewController + // can launch the app-based DocumentViewController. + if #available(iOS 11.0, *) { + Manager.shared.fileBrowserLauncher = { navController in + let vc = PackageBrowserViewController() + navController.pushViewController(vc, animated: true) + } + } } required init?(coder aDecoder: NSCoder) { diff --git a/ios/keyman/Keyman/Keyman/PackageBrowserViewController.swift b/ios/keyman/Keyman/Keyman/PackageBrowserViewController.swift new file mode 100644 index 0000000000..fcde824287 --- /dev/null +++ b/ios/keyman/Keyman/Keyman/PackageBrowserViewController.swift @@ -0,0 +1,78 @@ +// +// PackageBrowserViewController.swift +// Keyman +// +// Created by Joshua Horton on 12/6/19. +// Copyright © 2019 SIL International. All rights reserved. +// + +import UIKit +import SwiftUI +import KeymanEngine + +@available(iOS 11.0, *) +class PackageBrowserViewController: UIDocumentBrowserViewController, UIDocumentBrowserViewControllerDelegate { + + override func viewDidLoad() { + super.viewDidLoad() + + delegate = self + + allowsDocumentCreation = false + allowsPickingMultipleItems = false + + // Update the style of the UIDocumentBrowserViewController + // browserUserInterfaceStyle = .dark + // view.tintColor = .white + + // Specify the allowed content types of your application via the Info.plist. + + // Do any additional setup after loading the view. + } + + // MARK: UIDocumentBrowserViewControllerDelegate + + func documentBrowser(_ controller: UIDocumentBrowserViewController, didPickDocumentsAt documentURLs: [URL]) { + guard let sourceURL = documentURLs.first else { return } + + // Present the Document View Controller for the first document that was picked. + // If you support picking multiple items, make sure you handle them all. + doInstall(of: sourceURL) + } + + func documentBrowser(_ controller: UIDocumentBrowserViewController, + didImportDocumentAt sourceURL: URL, + toDestinationURL destinationURL: URL) { + // Present the Document View Controller for the new newly created document + doInstall(of: destinationURL) + } + + func documentBrowser(_ controller: UIDocumentBrowserViewController, + failedToImportDocumentAt documentURL: URL, + error: Error?) { + // Make sure to handle the failed import appropriately, e.g., by presenting an error message to the user. + } + + // MARK: Document Presentation + + func doInstall(of url: URL) { + // Once selected, start the standard install process. + let rfm = ResourceFileManager.shared + + guard let destinationUrl = rfm.importFile(url) else { + return + } + + rfm.prepareKMPInstall(from: destinationUrl, + alertHost: self, + completionHandler: { package in + // We choose to prompt the user for comfirmation, rather + // than automatically installing the package. + rfm.promptPackageInstall(of: package, in: self, successHandler: { _ in + // Auto-dismiss the document browser upon successful KMP install. + // It's likely quite rare that someone would want to install 2+ at once. + self.navigationController?.popViewController(animated: true) + }) + }) + } +} diff --git a/web/source/kmwdevice.ts b/web/source/kmwdevice.ts index fce6196117..5588601aa4 100644 --- a/web/source/kmwdevice.ts +++ b/web/source/kmwdevice.ts @@ -1,5 +1,6 @@ // Includes version-related functionality /// +/// // The Device object definition ------------------------------------------------- @@ -13,6 +14,10 @@ namespace com.keyman { version: string; orientation: string|number; browser: string; + colorScheme: 'light' | 'dark'; + + private detected: boolean = false; + private _styles: utils.StyleConstants; // Generates a default Device value. constructor() { @@ -178,6 +183,9 @@ namespace com.keyman { } } } + + this.colorScheme = this.prefersDarkMode() ? 'dark' : 'light'; + this.detected = true; } static _GetIEVersion() { @@ -219,5 +227,27 @@ namespace com.keyman { return 999; } + + /** + * Checks is a user's browser is in dark mode, if the feature is supported. Returns false otherwise. + * + * Thanks to https://stackoverflow.com/a/57795518 for this code. + */ + private prefersDarkMode(): boolean { + // Ensure the detector exists (otherwise, returns false) + return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + } + + public get styles(): utils.StyleConstants { + if(!this._styles) { + if(!this.detected) { + this.detect(); + } + + this._styles = new utils.StyleConstants(this); + } + + return this._styles; + } } } \ No newline at end of file diff --git a/web/source/kmwdom.ts b/web/source/kmwdom.ts index ad4c05f533..370e663a98 100644 --- a/web/source/kmwdom.ts +++ b/web/source/kmwdom.ts @@ -1631,6 +1631,10 @@ namespace com.keyman { // Display the OSK (again) if enabled, in order to set its position correctly after // adding the UI to the page this.keyman.osk._Show(); + } else if(this.keyman.isEmbedded) { + // UI modules aren't utilized in embedded mode. There's nothing to init, so we simply + // return instead of waiting for a UI module that will never come. + return; } else { window.setTimeout(this.initializeUI.bind(this),1000); } diff --git a/web/source/kmwnative.ts b/web/source/kmwnative.ts index 66b6a7ac8b..cd3ad45a13 100644 --- a/web/source/kmwnative.ts +++ b/web/source/kmwnative.ts @@ -204,7 +204,8 @@ namespace com.keyman.osk { * @param {number} edge -1 left edge, 1 right edge, else 0 */ VisualKeyboard.prototype.drawPreview = function(this: VisualKeyboard, canvas: HTMLCanvasElement, w: number, h: number, edge: number) { - let device = com.keyman.singleton.util.device; + let util = com.keyman.singleton.util; + let device = util.device; var ctx = canvas.getContext('2d'), dx = (canvas.width - w)/2, hMax = canvas.height, w0 = 0, w1 = dx, w2 = w + dx, w3 = w + 2 * dx, @@ -232,11 +233,9 @@ namespace com.keyman.osk { // Define appearance of preview (cannot be done directly in CSS) if(device.OS == 'Android') { var wx=(w1+w2)/2; - w1 = w2 = wx; - ctx.fillStyle = '#999'; - } else { - ctx.fillStyle = '#ffffff'; - } + w1 = w2 = wx; + } + ctx.fillStyle = device.styles.popupCanvasBackgroundColor; ctx.lineWidth = 1; ctx.strokeStyle = '#cccccc'; diff --git a/web/source/resources/osk/kmwosk.css b/web/source/resources/osk/kmwosk.css index dfbb0a2793..bc36d94fa0 100644 --- a/web/source/resources/osk/kmwosk.css +++ b/web/source/resources/osk/kmwosk.css @@ -64,6 +64,17 @@ .phone.ios .kmw-key.kmw-key-special-on {color:#000;background-color:#fdfdfe;} .phone.ios .kmw-key.kmw-key-touched {background-color:#88f;} +/* Probably best to make this its own CSS that can be optionally included? */ +@media (prefers-color-scheme: dark) { + .phone.ios .kmw-key-layer-group {background-color: #0f1319} + .phone.ios .kmw-key.kmw-key-default {color:#fff;background-color:#3d3d3e} + .phone.ios .kmw-key.kmw-key-shift, + .phone.ios .kmw-key.kmw-key-special {color:#fff;background-color:#595c62;} + .phone.ios .kmw-key.kmw-key-shift-on, + .phone.ios .kmw-key.kmw-key-special-on {color:#fff;background-color:#3d3d3e;} + .phone.ios .kmw-key.kmw-key-touched {background-color:#447;} +} + .ios .kmw-banner-bar { background-color: #cfd3d9; position: absolute; @@ -90,7 +101,28 @@ .phone.ios.kmw-osk-frame, .tablet.ios.kmw-osk-frame { background-color: #cfd3d9; -} +} + +/* Probably best to make this its own CSS that can be optionally included? */ +@media (prefers-color-scheme: dark) { + .ios .kmw-banner-bar { + background-color: #0f1319; + } + + .ios .kmw-banner-bar .kmw-suggest-option { + background-color: #3d3d3e; + border-bottom: solid 1px #8a8d90; + } + + .ios .kmw-suggestion-text { + color: #fff; + } + + .phone.ios.kmw-osk-frame, + .tablet.ios.kmw-osk-frame { + background-color: #0f1319; + } +} .phone.android .kmw-key-layer-group {background-color: #333;} .phone.android .kmw-key {border: none; border-bottom: solid 1px #8a8d90; box-shadow:none; border-radius: 3px;} @@ -155,6 +187,17 @@ .tablet.ios .kmw-suggest-option.kmw-suggest-touched {background-color:#88f;} +/* Probably best to make this its own CSS that can be optionally included? */ +@media (prefers-color-scheme: dark) { + .tablet.ios .kmw-key-layer-group {background-color: #0f1319} + .tablet.ios .kmw-key.kmw-key-default {color:#fff;background-color:#3d3d3e} + .tablet.ios .kmw-key.kmw-key-shift, + .tablet.ios .kmw-key.kmw-key-special {color:#fff;background-color:#595c62;} + .tablet.ios .kmw-key.kmw-key-shift-on, + .tablet.ios .kmw-key.kmw-key-special-on {color:#fff;background-color:#3d3d3e;} + .tablet.ios .kmw-key.kmw-key-touched {background-color:#447;} +} + .tablet .kmw-key-row {-webkit-touch-callout:none;-webkit-user-select:none;-ms-user-select:none;user-select:none;-webkit-tap-highlight-color:rgba(0,0,0,0);} .tablet.android .kmw-key-layer-group {background-color: #b4b4b8; border-top: 1px solid #666;} @@ -268,19 +311,31 @@ body div.kmw-key-shift-on span.kmw-key-text {font-family:SpecialOSK !important;f #kmw-popup-callout { position:fixed; display:block; background-color: #fdfdfe; border-radius: 0 0 6px 6px; z-index:10001; pointer-events:none; - } +} /* Key preview styles */ div.ios div.kmw-keytip { position:fixed; left:0; top:0; width:3em; height:3em; background-color:rgb(0,0,0,0);overflow:visible; +} + +/* Dark mode - ensure text is colored appropriately for key tips. */ +@media (prefers-color-scheme: dark) { + div.ios div.kmw-keytip { + color:#fff; } + /* Style for callout used on phones */ + #kmw-popup-callout { + background-color: #0f1319; + } +} + div.android div.kmw-keytip { position:fixed; left:0; top:0; width:3em; height:3em; color:#fff; background-color: rgb(0,0,0,0); overflow:visible; - } +} div.android #keytip {background-color:#f00;} @@ -337,6 +392,12 @@ div.android #keytip {background-color:#f00;} .phone.android #kmw-popup-keys {border:none; border-radius: 2px; background-color:#ccc; padding:5px 5px 0 0;} .tablet.android #kmw-popup-keys {border:1px solid #eee; border-radius: 3px; background-color:#888; padding:8px 12px 4px 4px;} +@media (prefers-color-scheme: dark) { + #kmw-popup-keys { + background-color: #0f1319; + } +} + /* Filter (shim) to darken screen and highlight popup keys */ #kmw-popup-shim { position: fixed; width: 100%; height: 100%; bottom: 0; left: 0; @@ -358,6 +419,11 @@ div.android #keytip {background-color:#f00;} .kmw-spacebar-caption{font:0.6em Arial !important;color:rgba(0,0,0,0.15);} +/* Probably best to make this its own CSS that can be optionally included? */ +@media (prefers-color-scheme: dark) { + .kmw-spacebar-caption{color:rgba(255, 255, 255, 0.15);} +} + /* Static styles */ .desktop-static.kmw-osk-inner-frame{margin:0;background-color:#666;border:2px solid #ad4a28;box-sizing:border-box;-moz-box-sizing:border-box;} diff --git a/web/source/utils/styleConstants.ts b/web/source/utils/styleConstants.ts new file mode 100644 index 0000000000..6c835fb580 --- /dev/null +++ b/web/source/utils/styleConstants.ts @@ -0,0 +1,24 @@ +// Includes Device definitions, which may play a role in constant logic. +/// + +/* + * This file is intended for CSS-styling constants that see use with the OSK. + */ + +namespace com.keyman.utils { + /** + * Defines device-level constants used for CSS styling. + */ + export class StyleConstants { + constructor(device: Device) { + // popupCanvasBackgroundColor + if(device.OS == 'Android') { + this.popupCanvasBackgroundColor = '#999'; + } else { + this.popupCanvasBackgroundColor = device.colorScheme == 'dark' ? '#0f1319' : '#ffffff'; + } + } + + public readonly popupCanvasBackgroundColor: string; + } +} \ No newline at end of file