first commit

This commit is contained in:
dslul 2019-12-31 18:19:35 +01:00
commit 797ee4f5c6
2784 changed files with 343578 additions and 0 deletions

9
.gitignore vendored Executable file
View file

@ -0,0 +1,9 @@
*.iml
.idea
.gradle
local.properties
.DS_Store
build
app/build
app/release
app/.cxx

39
app/build.gradle Executable file
View file

@ -0,0 +1,39 @@
apply plugin: 'com.android.application'
android {
compileSdkVersion 29
defaultConfig {
applicationId "org.dslul.openboard.inputmethod.latin"
minSdkVersion 14
targetSdkVersion 29
versionCode 1
versionName "1.0"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
aaptOptions {
noCompress 'dict'
}
externalNativeBuild {
ndkBuild {
path 'src/main/native/jni/Android.mk'
}
}
lintOptions {
checkReleaseBuilds false
}
}
dependencies {
implementation 'com.google.code.findbugs:jsr305:3.0.2'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
}

17
app/proguard-rules.pro vendored Executable file
View file

@ -0,0 +1,17 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /home/iwo/android-sdk/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Add any project specific keep options here:
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}

View file

@ -0,0 +1,192 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2012 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
coreApp="true"
package="org.dslul.openboard.inputmethod.latin">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.READ_PROFILE" />
<uses-permission android:name="android.permission.READ_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.READ_SYNC_STATS" />
<uses-permission android:name="android.permission.READ_USER_DICTIONARY" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.USE_CREDENTIALS" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_SYNC_SETTINGS" />
<uses-permission android:name="android.permission.WRITE_USER_DICTIONARY" />
<!-- A signature-protected permission to ask AOSP Keyboard to close the software keyboard.
To use this, add the following line into calling application's AndroidManifest.xml
<pre>
{@code
<uses-permission android:name="org.dslul.openboard.inputmethod.latin.HIDE_SOFT_INPUT"/>
}
</pre>
then call {@link android.content.Context#sendBroadcast(Intent)} as follows:
<pre>
{@code
sendBroadcast(new Intent("org.dslul.openboard.inputmethod.latin.HIDE_SOFT_INPUT")
.setPackage("org.dslul.openboard.inputmethod.latin"));
}
</pre> -->
<permission android:name="org.dslul.openboard.inputmethod.latin.HIDE_SOFT_INPUT"
android:protectionLevel="signature" />
<application android:label="@string/english_ime_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:allowBackup="true"
android:defaultToDeviceProtectedStorage="true"
android:directBootAware="true">
<!-- Services -->
<service android:name="LatinIME"
android:label="@string/english_ime_name"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data android:name="android.view.im" android:resource="@xml/method" />
</service>
<service android:name=".spellcheck.AndroidSpellCheckerService"
android:label="@string/spell_checker_service_name"
android:permission="android.permission.BIND_TEXT_SERVICE">
<intent-filter>
<action android:name="android.service.textservice.SpellCheckerService" />
</intent-filter>
<meta-data android:name="android.view.textservice.scs"
android:resource="@xml/spellchecker" />
</service>
<service android:name="org.dslul.openboard.inputmethod.dictionarypack.DictionaryService"
android:label="@string/dictionary_service_name">
</service>
<!-- Activities -->
<activity android:name=".setup.SetupActivity"
android:theme="@style/platformActivityTheme"
android:label="@string/english_ime_name"
android:icon="@mipmap/ic_launcher"
android:launchMode="singleTask"
android:noHistory="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".permissions.PermissionsActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:exported="false"
android:taskAffinity="" >
</activity>
<activity android:name=".setup.SetupWizardActivity"
android:theme="@style/platformActivityTheme"
android:label="@string/english_ime_name"
android:clearTaskOnLaunch="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
<activity android:name=".settings.SettingsActivity"
android:theme="@style/platformSettingsTheme"
android:label="@string/english_ime_settings">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
<activity android:name=".spellcheck.SpellCheckerSettingsActivity"
android:theme="@style/platformSettingsTheme"
android:label="@string/android_spell_checker_settings">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
<activity android:name="org.dslul.openboard.inputmethod.dictionarypack.DictionarySettingsActivity"
android:theme="@style/platformSettingsTheme"
android:label="@string/dictionary_settings_title"
android:uiOptions="splitActionBarWhenNarrow">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<activity android:name="org.dslul.openboard.inputmethod.dictionarypack.DownloadOverMeteredDialog"
android:theme="@style/platformActivityTheme"
android:label="@string/dictionary_install_over_metered_network_prompt">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<!-- Unexported activity used for tests. -->
<activity android:name=".settings.TestFragmentActivity"
android:exported="false" />
<!-- Broadcast receivers -->
<receiver android:name="SystemBroadcastReceiver">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.USER_INITIALIZE" />
<action android:name="android.intent.action.LOCALE_CHANGED" />
</intent-filter>
</receiver>
<receiver android:name="DictionaryPackInstallBroadcastReceiver" android:exported="false">
<intent-filter>
<action android:name="org.dslul.openboard.inputmethod.dictionarypack.aosp.UNKNOWN_CLIENT" />
</intent-filter>
</receiver>
<receiver android:name="org.dslul.openboard.inputmethod.dictionarypack.EventHandler">
<intent-filter>
<action android:name="android.intent.action.DOWNLOAD_COMPLETE" />
<action android:name="android.intent.action.DATE_CHANGED" />
<action android:name="org.dslul.openboard.inputmethod.dictionarypack.aosp.UPDATE_NOW" />
<action android:name="org.dslul.openboard.inputmethod.dictionarypack.aosp.INIT_AND_UPDATE_NOW" />
</intent-filter>
</receiver>
<!-- Broadcast receiver for AccountManager#LOGIN_ACCOUNTS_CHANGED_ACTION. -->
<receiver android:name=".accounts.AccountsChangedReceiver">
<intent-filter>
<action android:name="android.accounts.LOGIN_ACCOUNTS_CHANGED" />
</intent-filter>
</receiver>
<!-- Content providers -->
<provider android:name="org.dslul.openboard.inputmethod.dictionarypack.DictionaryProvider"
android:grantUriPermissions="true"
android:exported="false"
android:authorities="@string/authority"
android:multiprocess="false"
android:label="@string/dictionary_provider_name">
</provider>
</application>
</manifest>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,38 @@
# This is a sample wordlist that can be converted to a binary dictionary
# for use by the Latin IME.
# The file is essentially a CSV file, with indent level denoting nesting.
#
# The file starts with a single CSV line with the header attributes. Whatever
# the content, these are included as is in the binary file. The first attribute
# of the file should be `dictionary'. Usual fields are `locale', `description',
# `date', `version', `options'.
#
# Each word has a `word' entry and at least a `f' argument denoting its
# probability, as an integer between 0 and 255 on a logarithmic scale, with
# 255 meaning 1 and each decrement in 1 dividing probability by 1.15.
# As a special case, a weight of 0 is taken to mean profanity - words that
# should not be considered a typo, but that should never be suggested
# explicitly. An entry may be made not a word by adding a `not_a_word'
# field with a value of `true'. The main reason for putting such entries
# into the dictionary is to add shortcut targets and maybe a whitelist
# replacement.
#
# Each word may or may not have any number of shortcut target lines
# starting with a `shortcut' entry and having at least a `f' frequency
# value between 0 and 14, or the special value `whitelist' which becomes
# 15, which is then taken to be the whitelist target of this word.
#
# Each word may also have any number of bigram lines starting with a
# `bigram' entry containing the following word whose frequency should
# override the unigram frequency when following the word this bigram is
# for.
#
dictionary=main:en,locale=en,description=Sample wordlist,date=1351495318,version=1
word=sample,f=200
bigram=wordlist,f=243
word=wordlist,f=180
word=shortcut,f=176
shortcut=target,f=10
word=witelisted,f=10,not_a_word=true
shortcut=whitelisted,f=whitelist
word=profanity,f=0

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View file

@ -0,0 +1,67 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.accessibility;
import android.content.Context;
import android.os.Handler;
import android.os.Message;
import org.dslul.openboard.inputmethod.keyboard.Key;
import org.dslul.openboard.inputmethod.latin.R;
// Handling long press timer to show a more keys keyboard.
final class AccessibilityLongPressTimer extends Handler {
public interface LongPressTimerCallback {
public void performLongClickOn(Key key);
}
private static final int MSG_LONG_PRESS = 1;
private final LongPressTimerCallback mCallback;
private final long mConfigAccessibilityLongPressTimeout;
public AccessibilityLongPressTimer(final LongPressTimerCallback callback,
final Context context) {
super();
mCallback = callback;
mConfigAccessibilityLongPressTimeout = context.getResources().getInteger(
R.integer.config_accessibility_long_press_key_timeout);
}
@Override
public void handleMessage(final Message msg) {
switch (msg.what) {
case MSG_LONG_PRESS:
cancelLongPress();
mCallback.performLongClickOn((Key)msg.obj);
return;
default:
super.handleMessage(msg);
return;
}
}
public void startLongPress(final Key key) {
cancelLongPress();
final Message longPressMessage = obtainMessage(MSG_LONG_PRESS, key);
sendMessageDelayed(longPressMessage, mConfigAccessibilityLongPressTimeout);
}
public void cancelLongPress() {
removeMessages(MSG_LONG_PRESS);
}
}

View file

@ -0,0 +1,266 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.accessibility;
import android.content.Context;
import android.media.AudioManager;
import android.os.Build;
import android.os.SystemClock;
import android.provider.Settings;
import androidx.core.view.accessibility.AccessibilityEventCompat;
import android.text.TextUtils;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityEvent;
import android.view.accessibility.AccessibilityManager;
import android.view.inputmethod.EditorInfo;
import org.dslul.openboard.inputmethod.compat.SettingsSecureCompatUtils;
import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.SuggestedWords;
import org.dslul.openboard.inputmethod.latin.utils.InputTypeUtils;
public final class AccessibilityUtils {
private static final String TAG = AccessibilityUtils.class.getSimpleName();
private static final String CLASS = AccessibilityUtils.class.getName();
private static final String PACKAGE =
AccessibilityUtils.class.getPackage().getName();
private static final AccessibilityUtils sInstance = new AccessibilityUtils();
private Context mContext;
private AccessibilityManager mAccessibilityManager;
private AudioManager mAudioManager;
/** The most recent auto-correction. */
private String mAutoCorrectionWord;
/** The most recent typed word for auto-correction. */
private String mTypedWord;
/*
* Setting this constant to {@code false} will disable all keyboard
* accessibility code, regardless of whether Accessibility is turned on in
* the system settings. It should ONLY be used in the event of an emergency.
*/
private static final boolean ENABLE_ACCESSIBILITY = true;
public static void init(final Context context) {
if (!ENABLE_ACCESSIBILITY) return;
// These only need to be initialized if the kill switch is off.
sInstance.initInternal(context);
}
public static AccessibilityUtils getInstance() {
return sInstance;
}
private AccessibilityUtils() {
// This class is not publicly instantiable.
}
private void initInternal(final Context context) {
mContext = context;
mAccessibilityManager =
(AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE);
mAudioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
}
/**
* Returns {@code true} if accessibility is enabled. Currently, this means
* that the kill switch is off and system accessibility is turned on.
*
* @return {@code true} if accessibility is enabled.
*/
public boolean isAccessibilityEnabled() {
return ENABLE_ACCESSIBILITY && mAccessibilityManager.isEnabled();
}
/**
* Returns {@code true} if touch exploration is enabled. Currently, this
* means that the kill switch is off, the device supports touch exploration,
* and system accessibility is turned on.
*
* @return {@code true} if touch exploration is enabled.
*/
public boolean isTouchExplorationEnabled() {
return isAccessibilityEnabled() && mAccessibilityManager.isTouchExplorationEnabled();
}
/**
* Returns {@true} if the provided event is a touch exploration (e.g. hover)
* event. This is used to determine whether the event should be processed by
* the touch exploration code within the keyboard.
*
* @param event The event to check.
* @return {@true} is the event is a touch exploration event
*/
public static boolean isTouchExplorationEvent(final MotionEvent event) {
final int action = event.getAction();
return action == MotionEvent.ACTION_HOVER_ENTER
|| action == MotionEvent.ACTION_HOVER_EXIT
|| action == MotionEvent.ACTION_HOVER_MOVE;
}
/**
* Returns whether the device should obscure typed password characters.
* Typically this means speaking "dot" in place of non-control characters.
*
* @return {@code true} if the device should obscure password characters.
*/
@SuppressWarnings("deprecation")
public boolean shouldObscureInput(final EditorInfo editorInfo) {
if (editorInfo == null) return false;
// The user can optionally force speaking passwords.
if (SettingsSecureCompatUtils.ACCESSIBILITY_SPEAK_PASSWORD != null) {
final boolean speakPassword = Settings.Secure.getInt(mContext.getContentResolver(),
SettingsSecureCompatUtils.ACCESSIBILITY_SPEAK_PASSWORD, 0) != 0;
if (speakPassword) return false;
}
// Always speak if the user is listening through headphones.
if (mAudioManager.isWiredHeadsetOn() || mAudioManager.isBluetoothA2dpOn()) {
return false;
}
// Don't speak if the IME is connected to a password field.
return InputTypeUtils.isPasswordInputType(editorInfo.inputType);
}
/**
* Sets the current auto-correction word and typed word. These may be used
* to provide the user with a spoken description of what auto-correction
* will occur when a key is typed.
*
* @param suggestedWords the list of suggested auto-correction words
*/
public void setAutoCorrection(final SuggestedWords suggestedWords) {
if (suggestedWords.mWillAutoCorrect) {
mAutoCorrectionWord = suggestedWords.getWord(SuggestedWords.INDEX_OF_AUTO_CORRECTION);
final SuggestedWords.SuggestedWordInfo typedWordInfo = suggestedWords.mTypedWordInfo;
if (null == typedWordInfo) {
mTypedWord = null;
} else {
mTypedWord = typedWordInfo.mWord;
}
} else {
mAutoCorrectionWord = null;
mTypedWord = null;
}
}
/**
* Obtains a description for an auto-correction key, taking into account the
* currently typed word and auto-correction.
*
* @param keyCodeDescription spoken description of the key that will insert
* an auto-correction
* @param shouldObscure whether the key should be obscured
* @return a description including a description of the auto-correction, if
* needed
*/
public String getAutoCorrectionDescription(
final String keyCodeDescription, final boolean shouldObscure) {
if (!TextUtils.isEmpty(mAutoCorrectionWord)) {
if (!TextUtils.equals(mAutoCorrectionWord, mTypedWord)) {
if (shouldObscure) {
// This should never happen, but just in case...
return mContext.getString(R.string.spoken_auto_correct_obscured,
keyCodeDescription);
}
return mContext.getString(R.string.spoken_auto_correct, keyCodeDescription,
mTypedWord, mAutoCorrectionWord);
}
}
return keyCodeDescription;
}
/**
* Sends the specified text to the {@link AccessibilityManager} to be
* spoken.
*
* @param view The source view.
* @param text The text to speak.
*/
public void announceForAccessibility(final View view, final CharSequence text) {
if (!mAccessibilityManager.isEnabled()) {
Log.e(TAG, "Attempted to speak when accessibility was disabled!");
return;
}
// The following is a hack to avoid using the heavy-weight TextToSpeech
// class. Instead, we're just forcing a fake AccessibilityEvent into
// the screen reader to make it speak.
final AccessibilityEvent event = AccessibilityEvent.obtain();
event.setPackageName(PACKAGE);
event.setClassName(CLASS);
event.setEventTime(SystemClock.uptimeMillis());
event.setEnabled(true);
event.getText().add(text);
// Platforms starting at SDK version 16 (Build.VERSION_CODES.JELLY_BEAN) should use
// announce events.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
event.setEventType(AccessibilityEventCompat.TYPE_ANNOUNCEMENT);
} else {
event.setEventType(AccessibilityEvent.TYPE_VIEW_FOCUSED);
}
final ViewParent viewParent = view.getParent();
if ((viewParent == null) || !(viewParent instanceof ViewGroup)) {
Log.e(TAG, "Failed to obtain ViewParent in announceForAccessibility");
return;
}
viewParent.requestSendAccessibilityEvent(view, event);
}
/**
* Handles speaking the "connect a headset to hear passwords" notification
* when connecting to a password field.
*
* @param view The source view.
* @param editorInfo The input connection's editor info attribute.
* @param restarting Whether the connection is being restarted.
*/
public void onStartInputViewInternal(final View view, final EditorInfo editorInfo,
final boolean restarting) {
if (shouldObscureInput(editorInfo)) {
final CharSequence text = mContext.getText(R.string.spoken_use_headphones);
announceForAccessibility(view, text);
}
}
/**
* Sends the specified {@link AccessibilityEvent} if accessibility is
* enabled. No operation if accessibility is disabled.
*
* @param event The event to send.
*/
public void requestSendAccessibilityEvent(final AccessibilityEvent event) {
if (mAccessibilityManager.isEnabled()) {
mAccessibilityManager.sendAccessibilityEvent(event);
}
}
}

View file

@ -0,0 +1,365 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.accessibility;
import android.content.Context;
import android.content.res.Resources;
import android.text.TextUtils;
import android.util.Log;
import android.util.SparseIntArray;
import android.view.inputmethod.EditorInfo;
import org.dslul.openboard.inputmethod.keyboard.Key;
import org.dslul.openboard.inputmethod.keyboard.Keyboard;
import org.dslul.openboard.inputmethod.keyboard.KeyboardId;
import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.common.Constants;
import org.dslul.openboard.inputmethod.latin.common.StringUtils;
import java.util.Locale;
final class KeyCodeDescriptionMapper {
private static final String TAG = KeyCodeDescriptionMapper.class.getSimpleName();
private static final String SPOKEN_LETTER_RESOURCE_NAME_FORMAT = "spoken_accented_letter_%04X";
private static final String SPOKEN_SYMBOL_RESOURCE_NAME_FORMAT = "spoken_symbol_%04X";
private static final String SPOKEN_EMOJI_RESOURCE_NAME_FORMAT = "spoken_emoji_%04X";
private static final String SPOKEN_EMOTICON_RESOURCE_NAME_PREFIX = "spoken_emoticon";
private static final String SPOKEN_EMOTICON_CODE_POINT_FORMAT = "_%02X";
// The resource ID of the string spoken for obscured keys
private static final int OBSCURED_KEY_RES_ID = R.string.spoken_description_dot;
private static final KeyCodeDescriptionMapper sInstance = new KeyCodeDescriptionMapper();
public static KeyCodeDescriptionMapper getInstance() {
return sInstance;
}
// Sparse array of spoken description resource IDs indexed by key codes
private final SparseIntArray mKeyCodeMap = new SparseIntArray();
private KeyCodeDescriptionMapper() {
// Special non-character codes defined in Keyboard
mKeyCodeMap.put(Constants.CODE_SPACE, R.string.spoken_description_space);
mKeyCodeMap.put(Constants.CODE_DELETE, R.string.spoken_description_delete);
mKeyCodeMap.put(Constants.CODE_ENTER, R.string.spoken_description_return);
mKeyCodeMap.put(Constants.CODE_SETTINGS, R.string.spoken_description_settings);
mKeyCodeMap.put(Constants.CODE_SHIFT, R.string.spoken_description_shift);
mKeyCodeMap.put(Constants.CODE_SHORTCUT, R.string.spoken_description_mic);
mKeyCodeMap.put(Constants.CODE_SWITCH_ALPHA_SYMBOL, R.string.spoken_description_to_symbol);
mKeyCodeMap.put(Constants.CODE_TAB, R.string.spoken_description_tab);
mKeyCodeMap.put(Constants.CODE_LANGUAGE_SWITCH,
R.string.spoken_description_language_switch);
mKeyCodeMap.put(Constants.CODE_ACTION_NEXT, R.string.spoken_description_action_next);
mKeyCodeMap.put(Constants.CODE_ACTION_PREVIOUS,
R.string.spoken_description_action_previous);
mKeyCodeMap.put(Constants.CODE_EMOJI, R.string.spoken_description_emoji);
// Because the upper-case and lower-case mappings of the following letters is depending on
// the locale, the upper case descriptions should be defined here. The lower case
// descriptions are handled in {@link #getSpokenLetterDescriptionId(Context,int)}.
// U+0049: "I" LATIN CAPITAL LETTER I
// U+0069: "i" LATIN SMALL LETTER I
// U+0130: "İ" LATIN CAPITAL LETTER I WITH DOT ABOVE
// U+0131: "ı" LATIN SMALL LETTER DOTLESS I
mKeyCodeMap.put(0x0049, R.string.spoken_letter_0049);
mKeyCodeMap.put(0x0130, R.string.spoken_letter_0130);
}
/**
* Returns the localized description of the action performed by a specified
* key based on the current keyboard state.
*
* @param context The package's context.
* @param keyboard The keyboard on which the key resides.
* @param key The key from which to obtain a description.
* @param shouldObscure {@true} if text (e.g. non-control) characters should be obscured.
* @return a character sequence describing the action performed by pressing the key
*/
public String getDescriptionForKey(final Context context, final Keyboard keyboard,
final Key key, final boolean shouldObscure) {
final int code = key.getCode();
if (code == Constants.CODE_SWITCH_ALPHA_SYMBOL) {
final String description = getDescriptionForSwitchAlphaSymbol(context, keyboard);
if (description != null) {
return description;
}
}
if (code == Constants.CODE_SHIFT) {
return getDescriptionForShiftKey(context, keyboard);
}
if (code == Constants.CODE_ENTER) {
// The following function returns the correct description in all action and
// regular enter cases, taking care of all modes.
return getDescriptionForActionKey(context, keyboard, key);
}
if (code == Constants.CODE_OUTPUT_TEXT) {
final String outputText = key.getOutputText();
final String description = getSpokenEmoticonDescription(context, outputText);
return TextUtils.isEmpty(description) ? outputText : description;
}
// Just attempt to speak the description.
if (code != Constants.CODE_UNSPECIFIED) {
// If the key description should be obscured, now is the time to do it.
final boolean isDefinedNonCtrl = Character.isDefined(code)
&& !Character.isISOControl(code);
if (shouldObscure && isDefinedNonCtrl) {
return context.getString(OBSCURED_KEY_RES_ID);
}
final String description = getDescriptionForCodePoint(context, code);
if (description != null) {
return description;
}
if (!TextUtils.isEmpty(key.getLabel())) {
return key.getLabel();
}
return context.getString(R.string.spoken_description_unknown);
}
return null;
}
/**
* Returns a context-specific description for the CODE_SWITCH_ALPHA_SYMBOL
* key or {@code null} if there is not a description provided for the
* current keyboard context.
*
* @param context The package's context.
* @param keyboard The keyboard on which the key resides.
* @return a character sequence describing the action performed by pressing the key
*/
private static String getDescriptionForSwitchAlphaSymbol(final Context context,
final Keyboard keyboard) {
final KeyboardId keyboardId = keyboard.mId;
final int elementId = keyboardId.mElementId;
final int resId;
switch (elementId) {
case KeyboardId.ELEMENT_ALPHABET:
case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED:
case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED:
case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED:
case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED:
resId = R.string.spoken_description_to_symbol;
break;
case KeyboardId.ELEMENT_SYMBOLS:
case KeyboardId.ELEMENT_SYMBOLS_SHIFTED:
resId = R.string.spoken_description_to_alpha;
break;
case KeyboardId.ELEMENT_PHONE:
resId = R.string.spoken_description_to_symbol;
break;
case KeyboardId.ELEMENT_PHONE_SYMBOLS:
resId = R.string.spoken_description_to_numeric;
break;
default:
Log.e(TAG, "Missing description for keyboard element ID:" + elementId);
return null;
}
return context.getString(resId);
}
/**
* Returns a context-sensitive description of the "Shift" key.
*
* @param context The package's context.
* @param keyboard The keyboard on which the key resides.
* @return A context-sensitive description of the "Shift" key.
*/
private static String getDescriptionForShiftKey(final Context context,
final Keyboard keyboard) {
final KeyboardId keyboardId = keyboard.mId;
final int elementId = keyboardId.mElementId;
final int resId;
switch (elementId) {
case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED:
case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED:
resId = R.string.spoken_description_caps_lock;
break;
case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED:
case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED:
resId = R.string.spoken_description_shift_shifted;
break;
case KeyboardId.ELEMENT_SYMBOLS:
resId = R.string.spoken_description_symbols_shift;
break;
case KeyboardId.ELEMENT_SYMBOLS_SHIFTED:
resId = R.string.spoken_description_symbols_shift_shifted;
break;
default:
resId = R.string.spoken_description_shift;
}
return context.getString(resId);
}
/**
* Returns a context-sensitive description of the "Enter" action key.
*
* @param context The package's context.
* @param keyboard The keyboard on which the key resides.
* @param key The key to describe.
* @return Returns a context-sensitive description of the "Enter" action key.
*/
private static String getDescriptionForActionKey(final Context context, final Keyboard keyboard,
final Key key) {
final KeyboardId keyboardId = keyboard.mId;
final int actionId = keyboardId.imeAction();
final int resId;
// Always use the label, if available.
if (!TextUtils.isEmpty(key.getLabel())) {
return key.getLabel().trim();
}
// Otherwise, use the action ID.
switch (actionId) {
case EditorInfo.IME_ACTION_SEARCH:
resId = R.string.spoken_description_search;
break;
case EditorInfo.IME_ACTION_GO:
resId = R.string.label_go_key;
break;
case EditorInfo.IME_ACTION_SEND:
resId = R.string.label_send_key;
break;
case EditorInfo.IME_ACTION_NEXT:
resId = R.string.label_next_key;
break;
case EditorInfo.IME_ACTION_DONE:
resId = R.string.label_done_key;
break;
case EditorInfo.IME_ACTION_PREVIOUS:
resId = R.string.label_previous_key;
break;
default:
resId = R.string.spoken_description_return;
}
return context.getString(resId);
}
/**
* Returns a localized character sequence describing what will happen when
* the specified key is pressed based on its key code point.
*
* @param context The package's context.
* @param codePoint The code point from which to obtain a description.
* @return a character sequence describing the code point.
*/
public String getDescriptionForCodePoint(final Context context, final int codePoint) {
// If the key description should be obscured, now is the time to do it.
final int index = mKeyCodeMap.indexOfKey(codePoint);
if (index >= 0) {
return context.getString(mKeyCodeMap.valueAt(index));
}
final String accentedLetter = getSpokenAccentedLetterDescription(context, codePoint);
if (accentedLetter != null) {
return accentedLetter;
}
// Here, <code>code</code> may be a base (non-accented) letter.
final String unsupportedSymbol = getSpokenSymbolDescription(context, codePoint);
if (unsupportedSymbol != null) {
return unsupportedSymbol;
}
final String emojiDescription = getSpokenEmojiDescription(context, codePoint);
if (emojiDescription != null) {
return emojiDescription;
}
if (Character.isDefined(codePoint) && !Character.isISOControl(codePoint)) {
return StringUtils.newSingleCodePointString(codePoint);
}
return null;
}
// TODO: Remove this method once TTS supports those accented letters' verbalization.
private String getSpokenAccentedLetterDescription(final Context context, final int code) {
final boolean isUpperCase = Character.isUpperCase(code);
final int baseCode = isUpperCase ? Character.toLowerCase(code) : code;
final int baseIndex = mKeyCodeMap.indexOfKey(baseCode);
final int resId = (baseIndex >= 0) ? mKeyCodeMap.valueAt(baseIndex)
: getSpokenDescriptionId(context, baseCode, SPOKEN_LETTER_RESOURCE_NAME_FORMAT);
if (resId == 0) {
return null;
}
final String spokenText = context.getString(resId);
return isUpperCase ? context.getString(R.string.spoken_description_upper_case, spokenText)
: spokenText;
}
// TODO: Remove this method once TTS supports those symbols' verbalization.
private String getSpokenSymbolDescription(final Context context, final int code) {
final int resId = getSpokenDescriptionId(context, code, SPOKEN_SYMBOL_RESOURCE_NAME_FORMAT);
if (resId == 0) {
return null;
}
final String spokenText = context.getString(resId);
if (!TextUtils.isEmpty(spokenText)) {
return spokenText;
}
// If a translated description is empty, fall back to unknown symbol description.
return context.getString(R.string.spoken_symbol_unknown);
}
// TODO: Remove this method once TTS supports emoji verbalization.
private String getSpokenEmojiDescription(final Context context, final int code) {
final int resId = getSpokenDescriptionId(context, code, SPOKEN_EMOJI_RESOURCE_NAME_FORMAT);
if (resId == 0) {
return null;
}
final String spokenText = context.getString(resId);
if (!TextUtils.isEmpty(spokenText)) {
return spokenText;
}
// If a translated description is empty, fall back to unknown emoji description.
return context.getString(R.string.spoken_emoji_unknown);
}
private int getSpokenDescriptionId(final Context context, final int code,
final String resourceNameFormat) {
final String resourceName = String.format(Locale.ROOT, resourceNameFormat, code);
final Resources resources = context.getResources();
// Note that the resource package name may differ from the context package name.
final String resourcePackageName = resources.getResourcePackageName(
R.string.spoken_description_unknown);
final int resId = resources.getIdentifier(resourceName, "string", resourcePackageName);
if (resId != 0) {
mKeyCodeMap.append(code, resId);
}
return resId;
}
// TODO: Remove this method once TTS supports emoticon verbalization.
private static String getSpokenEmoticonDescription(final Context context,
final String outputText) {
final StringBuilder sb = new StringBuilder(SPOKEN_EMOTICON_RESOURCE_NAME_PREFIX);
final int textLength = outputText.length();
for (int index = 0; index < textLength; index = outputText.offsetByCodePoints(index, 1)) {
final int codePoint = outputText.codePointAt(index);
sb.append(String.format(Locale.ROOT, SPOKEN_EMOTICON_CODE_POINT_FORMAT, codePoint));
}
final String resourceName = sb.toString();
final Resources resources = context.getResources();
// Note that the resource package name may differ from the context package name.
final String resourcePackageName = resources.getResourcePackageName(
R.string.spoken_description_unknown);
final int resId = resources.getIdentifier(resourceName, "string", resourcePackageName);
return (resId == 0) ? null : resources.getString(resId);
}
}

View file

@ -0,0 +1,327 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.accessibility;
import android.content.Context;
import android.os.SystemClock;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewParent;
import android.view.accessibility.AccessibilityEvent;
import org.dslul.openboard.inputmethod.keyboard.Key;
import org.dslul.openboard.inputmethod.keyboard.KeyDetector;
import org.dslul.openboard.inputmethod.keyboard.Keyboard;
import org.dslul.openboard.inputmethod.keyboard.KeyboardView;
/**
* This class represents a delegate that can be registered in a class that extends
* {@link KeyboardView} to enhance accessibility support via composition rather via inheritance.
*
* To implement accessibility mode, the target keyboard view has to:<p>
* - Call {@link #setKeyboard(Keyboard)} when a new keyboard is set to the keyboard view.
* - Dispatch a hover event by calling {@link #onHoverEnter(MotionEvent)}.
*
* @param <KV> The keyboard view class type.
*/
public class KeyboardAccessibilityDelegate<KV extends KeyboardView>
extends AccessibilityDelegateCompat {
private static final String TAG = KeyboardAccessibilityDelegate.class.getSimpleName();
protected static final boolean DEBUG_HOVER = false;
protected final KV mKeyboardView;
protected final KeyDetector mKeyDetector;
private Keyboard mKeyboard;
private KeyboardAccessibilityNodeProvider<KV> mAccessibilityNodeProvider;
private Key mLastHoverKey;
public static final int HOVER_EVENT_POINTER_ID = 0;
public KeyboardAccessibilityDelegate(final KV keyboardView, final KeyDetector keyDetector) {
super();
mKeyboardView = keyboardView;
mKeyDetector = keyDetector;
// Ensure that the view has an accessibility delegate.
ViewCompat.setAccessibilityDelegate(keyboardView, this);
}
/**
* Called when the keyboard layout changes.
* <p>
* <b>Note:</b> This method will be called even if accessibility is not
* enabled.
* @param keyboard The keyboard that is being set to the wrapping view.
*/
public void setKeyboard(final Keyboard keyboard) {
if (keyboard == null) {
return;
}
if (mAccessibilityNodeProvider != null) {
mAccessibilityNodeProvider.setKeyboard(keyboard);
}
mKeyboard = keyboard;
}
protected final Keyboard getKeyboard() {
return mKeyboard;
}
protected final void setLastHoverKey(final Key key) {
mLastHoverKey = key;
}
protected final Key getLastHoverKey() {
return mLastHoverKey;
}
/**
* Sends a window state change event with the specified string resource id.
*
* @param resId The string resource id of the text to send with the event.
*/
protected void sendWindowStateChanged(final int resId) {
if (resId == 0) {
return;
}
final Context context = mKeyboardView.getContext();
sendWindowStateChanged(context.getString(resId));
}
/**
* Sends a window state change event with the specified text.
*
* @param text The text to send with the event.
*/
protected void sendWindowStateChanged(final String text) {
final AccessibilityEvent stateChange = AccessibilityEvent.obtain(
AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED);
mKeyboardView.onInitializeAccessibilityEvent(stateChange);
stateChange.getText().add(text);
stateChange.setContentDescription(null);
final ViewParent parent = mKeyboardView.getParent();
if (parent != null) {
parent.requestSendAccessibilityEvent(mKeyboardView, stateChange);
}
}
/**
* Delegate method for View.getAccessibilityNodeProvider(). This method is called in SDK
* version 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) and higher to obtain the virtual
* node hierarchy provider.
*
* @param host The host view for the provider.
* @return The accessibility node provider for the current keyboard.
*/
@Override
public KeyboardAccessibilityNodeProvider<KV> getAccessibilityNodeProvider(final View host) {
return getAccessibilityNodeProvider();
}
/**
* @return A lazily-instantiated node provider for this view delegate.
*/
protected KeyboardAccessibilityNodeProvider<KV> getAccessibilityNodeProvider() {
// Instantiate the provide only when requested. Since the system
// will call this method multiple times it is a good practice to
// cache the provider instance.
if (mAccessibilityNodeProvider == null) {
mAccessibilityNodeProvider =
new KeyboardAccessibilityNodeProvider<>(mKeyboardView, this);
}
return mAccessibilityNodeProvider;
}
/**
* Get a key that a hover event is on.
*
* @param event The hover event.
* @return key The key that the <code>event</code> is on.
*/
protected final Key getHoverKeyOf(final MotionEvent event) {
final int actionIndex = event.getActionIndex();
final int x = (int)event.getX(actionIndex);
final int y = (int)event.getY(actionIndex);
return mKeyDetector.detectHitKey(x, y);
}
/**
* Receives hover events when touch exploration is turned on in SDK versions ICS and higher.
*
* @param event The hover event.
* @return {@code true} if the event is handled.
*/
public boolean onHoverEvent(final MotionEvent event) {
switch (event.getActionMasked()) {
case MotionEvent.ACTION_HOVER_ENTER:
onHoverEnter(event);
break;
case MotionEvent.ACTION_HOVER_MOVE:
onHoverMove(event);
break;
case MotionEvent.ACTION_HOVER_EXIT:
onHoverExit(event);
break;
default:
Log.w(getClass().getSimpleName(), "Unknown hover event: " + event);
break;
}
return true;
}
/**
* Process {@link MotionEvent#ACTION_HOVER_ENTER} event.
*
* @param event A hover enter event.
*/
protected void onHoverEnter(final MotionEvent event) {
final Key key = getHoverKeyOf(event);
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverEnter: key=" + key);
}
if (key != null) {
onHoverEnterTo(key);
}
setLastHoverKey(key);
}
/**
* Process {@link MotionEvent#ACTION_HOVER_MOVE} event.
*
* @param event A hover move event.
*/
protected void onHoverMove(final MotionEvent event) {
final Key lastKey = getLastHoverKey();
final Key key = getHoverKeyOf(event);
if (key != lastKey) {
if (lastKey != null) {
onHoverExitFrom(lastKey);
}
if (key != null) {
onHoverEnterTo(key);
}
}
if (key != null) {
onHoverMoveWithin(key);
}
setLastHoverKey(key);
}
/**
* Process {@link MotionEvent#ACTION_HOVER_EXIT} event.
*
* @param event A hover exit event.
*/
protected void onHoverExit(final MotionEvent event) {
final Key lastKey = getLastHoverKey();
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverExit: key=" + getHoverKeyOf(event) + " last=" + lastKey);
}
if (lastKey != null) {
onHoverExitFrom(lastKey);
}
final Key key = getHoverKeyOf(event);
// Make sure we're not getting an EXIT event because the user slid
// off the keyboard area, then force a key press.
if (key != null) {
performClickOn(key);
onHoverExitFrom(key);
}
setLastHoverKey(null);
}
/**
* Perform click on a key.
*
* @param key A key to be registered.
*/
public void performClickOn(final Key key) {
if (DEBUG_HOVER) {
Log.d(TAG, "performClickOn: key=" + key);
}
simulateTouchEvent(MotionEvent.ACTION_DOWN, key);
simulateTouchEvent(MotionEvent.ACTION_UP, key);
}
/**
* Simulating a touch event by injecting a synthesized touch event into {@link KeyboardView}.
*
* @param touchAction The action of the synthesizing touch event.
* @param key The key that a synthesized touch event is on.
*/
private void simulateTouchEvent(final int touchAction, final Key key) {
final int x = key.getHitBox().centerX();
final int y = key.getHitBox().centerY();
final long eventTime = SystemClock.uptimeMillis();
final MotionEvent touchEvent = MotionEvent.obtain(
eventTime, eventTime, touchAction, x, y, 0 /* metaState */);
mKeyboardView.onTouchEvent(touchEvent);
touchEvent.recycle();
}
/**
* Handles a hover enter event on a key.
*
* @param key The currently hovered key.
*/
protected void onHoverEnterTo(final Key key) {
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverEnterTo: key=" + key);
}
key.onPressed();
mKeyboardView.invalidateKey(key);
final KeyboardAccessibilityNodeProvider<KV> provider = getAccessibilityNodeProvider();
provider.onHoverEnterTo(key);
provider.performActionForKey(key, AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
}
/**
* Handles a hover move event on a key.
*
* @param key The currently hovered key.
*/
protected void onHoverMoveWithin(final Key key) { }
/**
* Handles a hover exit event on a key.
*
* @param key The currently hovered key.
*/
protected void onHoverExitFrom(final Key key) {
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverExitFrom: key=" + key);
}
key.onReleased();
mKeyboardView.invalidateKey(key);
final KeyboardAccessibilityNodeProvider<KV> provider = getAccessibilityNodeProvider();
provider.onHoverExitFrom(key);
}
/**
* Perform long click on a key.
*
* @param key A key to be long pressed on.
*/
public void performLongClickOn(final Key key) {
// A extended class should override this method to implement long press.
}
}

View file

@ -0,0 +1,342 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.accessibility;
import android.graphics.Rect;
import android.os.Bundle;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityEventCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.core.view.accessibility.AccessibilityNodeProviderCompat;
import androidx.core.view.accessibility.AccessibilityRecordCompat;
import android.util.Log;
import android.view.View;
import android.view.accessibility.AccessibilityEvent;
import android.view.inputmethod.EditorInfo;
import org.dslul.openboard.inputmethod.keyboard.Key;
import org.dslul.openboard.inputmethod.keyboard.Keyboard;
import org.dslul.openboard.inputmethod.keyboard.KeyboardView;
import org.dslul.openboard.inputmethod.latin.common.CoordinateUtils;
import org.dslul.openboard.inputmethod.latin.settings.Settings;
import org.dslul.openboard.inputmethod.latin.settings.SettingsValues;
import java.util.List;
/**
* Exposes a virtual view sub-tree for {@link KeyboardView} and generates
* {@link AccessibilityEvent}s for individual {@link Key}s.
* <p>
* A virtual sub-tree is composed of imaginary {@link View}s that are reported
* as a part of the view hierarchy for accessibility purposes. This enables
* custom views that draw complex content to report them selves as a tree of
* virtual views, thus conveying their logical structure.
* </p>
*/
final class KeyboardAccessibilityNodeProvider<KV extends KeyboardView>
extends AccessibilityNodeProviderCompat {
private static final String TAG = KeyboardAccessibilityNodeProvider.class.getSimpleName();
// From {@link android.view.accessibility.AccessibilityNodeInfo#UNDEFINED_ITEM_ID}.
private static final int UNDEFINED = Integer.MAX_VALUE;
private final KeyCodeDescriptionMapper mKeyCodeDescriptionMapper;
private final AccessibilityUtils mAccessibilityUtils;
/** Temporary rect used to calculate in-screen bounds. */
private final Rect mTempBoundsInScreen = new Rect();
/** The parent view's cached on-screen location. */
private final int[] mParentLocation = CoordinateUtils.newInstance();
/** The virtual view identifier for the focused node. */
private int mAccessibilityFocusedView = UNDEFINED;
/** The virtual view identifier for the hovering node. */
private int mHoveringNodeId = UNDEFINED;
/** The keyboard view to provide an accessibility node info. */
private final KV mKeyboardView;
/** The accessibility delegate. */
private final KeyboardAccessibilityDelegate<KV> mDelegate;
/** The current keyboard. */
private Keyboard mKeyboard;
public KeyboardAccessibilityNodeProvider(final KV keyboardView,
final KeyboardAccessibilityDelegate<KV> delegate) {
super();
mKeyCodeDescriptionMapper = KeyCodeDescriptionMapper.getInstance();
mAccessibilityUtils = AccessibilityUtils.getInstance();
mKeyboardView = keyboardView;
mDelegate = delegate;
// Since this class is constructed lazily, we might not get a subsequent
// call to setKeyboard() and therefore need to call it now.
setKeyboard(keyboardView.getKeyboard());
}
/**
* Sets the keyboard represented by this node provider.
*
* @param keyboard The keyboard that is being set to the keyboard view.
*/
public void setKeyboard(final Keyboard keyboard) {
mKeyboard = keyboard;
}
private Key getKeyOf(final int virtualViewId) {
if (mKeyboard == null) {
return null;
}
final List<Key> sortedKeys = mKeyboard.getSortedKeys();
// Use a virtual view id as an index of the sorted keys list.
if (virtualViewId >= 0 && virtualViewId < sortedKeys.size()) {
return sortedKeys.get(virtualViewId);
}
return null;
}
private int getVirtualViewIdOf(final Key key) {
if (mKeyboard == null) {
return View.NO_ID;
}
final List<Key> sortedKeys = mKeyboard.getSortedKeys();
final int size = sortedKeys.size();
for (int index = 0; index < size; index++) {
if (sortedKeys.get(index) == key) {
// Use an index of the sorted keys list as a virtual view id.
return index;
}
}
return View.NO_ID;
}
/**
* Creates and populates an {@link AccessibilityEvent} for the specified key
* and event type.
*
* @param key A key on the host keyboard view.
* @param eventType The event type to create.
* @return A populated {@link AccessibilityEvent} for the key.
* @see AccessibilityEvent
*/
public AccessibilityEvent createAccessibilityEvent(final Key key, final int eventType) {
final int virtualViewId = getVirtualViewIdOf(key);
final String keyDescription = getKeyDescription(key);
final AccessibilityEvent event = AccessibilityEvent.obtain(eventType);
event.setPackageName(mKeyboardView.getContext().getPackageName());
event.setClassName(key.getClass().getName());
event.setContentDescription(keyDescription);
event.setEnabled(true);
final AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event);
record.setSource(mKeyboardView, virtualViewId);
return event;
}
public void onHoverEnterTo(final Key key) {
final int id = getVirtualViewIdOf(key);
if (id == View.NO_ID) {
return;
}
// Start hovering on the key. Because our accessibility model is lift-to-type, we should
// report the node info without click and long click actions to avoid unnecessary
// announcements.
mHoveringNodeId = id;
// Invalidate the node info of the key.
sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED);
sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_VIEW_HOVER_ENTER);
}
public void onHoverExitFrom(final Key key) {
mHoveringNodeId = UNDEFINED;
// Invalidate the node info of the key to be able to revert the change we have done
// in {@link #onHoverEnterTo(Key)}.
sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_WINDOW_CONTENT_CHANGED);
sendAccessibilityEventForKey(key, AccessibilityEventCompat.TYPE_VIEW_HOVER_EXIT);
}
/**
* Returns an {@link AccessibilityNodeInfoCompat} representing a virtual
* view, i.e. a descendant of the host View, with the given <code>virtualViewId</code> or
* the host View itself if <code>virtualViewId</code> equals to {@link View#NO_ID}.
* <p>
* A virtual descendant is an imaginary View that is reported as a part of
* the view hierarchy for accessibility purposes. This enables custom views
* that draw complex content to report them selves as a tree of virtual
* views, thus conveying their logical structure.
* </p>
* <p>
* The implementer is responsible for obtaining an accessibility node info
* from the pool of reusable instances and setting the desired properties of
* the node info before returning it.
* </p>
*
* @param virtualViewId A client defined virtual view id.
* @return A populated {@link AccessibilityNodeInfoCompat} for a virtual descendant or the host
* View.
* @see AccessibilityNodeInfoCompat
*/
@Override
public AccessibilityNodeInfoCompat createAccessibilityNodeInfo(final int virtualViewId) {
if (virtualViewId == UNDEFINED) {
return null;
}
if (virtualViewId == View.NO_ID) {
// We are requested to create an AccessibilityNodeInfo describing
// this View, i.e. the root of the virtual sub-tree.
final AccessibilityNodeInfoCompat rootInfo =
AccessibilityNodeInfoCompat.obtain(mKeyboardView);
ViewCompat.onInitializeAccessibilityNodeInfo(mKeyboardView, rootInfo);
updateParentLocation();
// Add the virtual children of the root View.
final List<Key> sortedKeys = mKeyboard.getSortedKeys();
final int size = sortedKeys.size();
for (int index = 0; index < size; index++) {
final Key key = sortedKeys.get(index);
if (key.isSpacer()) {
continue;
}
// Use an index of the sorted keys list as a virtual view id.
rootInfo.addChild(mKeyboardView, index);
}
return rootInfo;
}
// Find the key that corresponds to the given virtual view id.
final Key key = getKeyOf(virtualViewId);
if (key == null) {
Log.e(TAG, "Invalid virtual view ID: " + virtualViewId);
return null;
}
final String keyDescription = getKeyDescription(key);
final Rect boundsInParent = key.getHitBox();
// Calculate the key's in-screen bounds.
mTempBoundsInScreen.set(boundsInParent);
mTempBoundsInScreen.offset(
CoordinateUtils.x(mParentLocation), CoordinateUtils.y(mParentLocation));
final Rect boundsInScreen = mTempBoundsInScreen;
// Obtain and initialize an AccessibilityNodeInfo with information about the virtual view.
final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain();
info.setPackageName(mKeyboardView.getContext().getPackageName());
info.setClassName(key.getClass().getName());
info.setContentDescription(keyDescription);
info.setBoundsInParent(boundsInParent);
info.setBoundsInScreen(boundsInScreen);
info.setParent(mKeyboardView);
info.setSource(mKeyboardView, virtualViewId);
info.setEnabled(key.isEnabled());
info.setVisibleToUser(true);
// Don't add ACTION_CLICK and ACTION_LONG_CLOCK actions while hovering on the key.
// See {@link #onHoverEnterTo(Key)} and {@link #onHoverExitFrom(Key)}.
if (virtualViewId != mHoveringNodeId) {
info.addAction(AccessibilityNodeInfoCompat.ACTION_CLICK);
if (key.isLongPressEnabled()) {
info.addAction(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK);
}
}
if (mAccessibilityFocusedView == virtualViewId) {
info.addAction(AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS);
} else {
info.addAction(AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS);
}
return info;
}
@Override
public boolean performAction(final int virtualViewId, final int action,
final Bundle arguments) {
final Key key = getKeyOf(virtualViewId);
if (key == null) {
return false;
}
return performActionForKey(key, action);
}
/**
* Performs the specified accessibility action for the given key.
*
* @param key The on which to perform the action.
* @param action The action to perform.
* @return The result of performing the action, or false if the action is not supported.
*/
boolean performActionForKey(final Key key, final int action) {
switch (action) {
case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS:
mAccessibilityFocusedView = getVirtualViewIdOf(key);
sendAccessibilityEventForKey(
key, AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUSED);
return true;
case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS:
mAccessibilityFocusedView = UNDEFINED;
sendAccessibilityEventForKey(
key, AccessibilityEventCompat.TYPE_VIEW_ACCESSIBILITY_FOCUS_CLEARED);
return true;
case AccessibilityNodeInfoCompat.ACTION_CLICK:
sendAccessibilityEventForKey(key, AccessibilityEvent.TYPE_VIEW_CLICKED);
mDelegate.performClickOn(key);
return true;
case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK:
sendAccessibilityEventForKey(key, AccessibilityEvent.TYPE_VIEW_LONG_CLICKED);
mDelegate.performLongClickOn(key);
return true;
default:
return false;
}
}
/**
* Sends an accessibility event for the given {@link Key}.
*
* @param key The key that's sending the event.
* @param eventType The type of event to send.
*/
void sendAccessibilityEventForKey(final Key key, final int eventType) {
final AccessibilityEvent event = createAccessibilityEvent(key, eventType);
mAccessibilityUtils.requestSendAccessibilityEvent(event);
}
/**
* Returns the context-specific description for a {@link Key}.
*
* @param key The key to describe.
* @return The context-specific description of the key.
*/
private String getKeyDescription(final Key key) {
final EditorInfo editorInfo = mKeyboard.mId.mEditorInfo;
final boolean shouldObscure = mAccessibilityUtils.shouldObscureInput(editorInfo);
final SettingsValues currentSettings = Settings.getInstance().getCurrent();
final String keyCodeDescription = mKeyCodeDescriptionMapper.getDescriptionForKey(
mKeyboardView.getContext(), mKeyboard, key, shouldObscure);
if (currentSettings.isWordSeparator(key.getCode())) {
return mAccessibilityUtils.getAutoCorrectionDescription(
keyCodeDescription, shouldObscure);
}
return keyCodeDescription;
}
/**
* Updates the parent's on-screen location.
*/
private void updateParentLocation() {
mKeyboardView.getLocationOnScreen(mParentLocation);
}
}

View file

@ -0,0 +1,301 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.accessibility;
import android.content.Context;
import android.graphics.Rect;
import android.os.SystemClock;
import android.util.Log;
import android.util.SparseIntArray;
import android.view.MotionEvent;
import org.dslul.openboard.inputmethod.keyboard.Key;
import org.dslul.openboard.inputmethod.keyboard.KeyDetector;
import org.dslul.openboard.inputmethod.keyboard.Keyboard;
import org.dslul.openboard.inputmethod.keyboard.KeyboardId;
import org.dslul.openboard.inputmethod.keyboard.MainKeyboardView;
import org.dslul.openboard.inputmethod.keyboard.PointerTracker;
import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.utils.SubtypeLocaleUtils;
/**
* This class represents a delegate that can be registered in {@link MainKeyboardView} to enhance
* accessibility support via composition rather via inheritance.
*/
public final class MainKeyboardAccessibilityDelegate
extends KeyboardAccessibilityDelegate<MainKeyboardView>
implements AccessibilityLongPressTimer.LongPressTimerCallback {
private static final String TAG = MainKeyboardAccessibilityDelegate.class.getSimpleName();
/** Map of keyboard modes to resource IDs. */
private static final SparseIntArray KEYBOARD_MODE_RES_IDS = new SparseIntArray();
static {
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_DATE, R.string.keyboard_mode_date);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_DATETIME, R.string.keyboard_mode_date_time);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_EMAIL, R.string.keyboard_mode_email);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_IM, R.string.keyboard_mode_im);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_NUMBER, R.string.keyboard_mode_number);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_PHONE, R.string.keyboard_mode_phone);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_TEXT, R.string.keyboard_mode_text);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_TIME, R.string.keyboard_mode_time);
KEYBOARD_MODE_RES_IDS.put(KeyboardId.MODE_URL, R.string.keyboard_mode_url);
}
/** The most recently set keyboard mode. */
private int mLastKeyboardMode = KEYBOARD_IS_HIDDEN;
private static final int KEYBOARD_IS_HIDDEN = -1;
// The rectangle region to ignore hover events.
private final Rect mBoundsToIgnoreHoverEvent = new Rect();
private final AccessibilityLongPressTimer mAccessibilityLongPressTimer;
public MainKeyboardAccessibilityDelegate(final MainKeyboardView mainKeyboardView,
final KeyDetector keyDetector) {
super(mainKeyboardView, keyDetector);
mAccessibilityLongPressTimer = new AccessibilityLongPressTimer(
this /* callback */, mainKeyboardView.getContext());
}
/**
* {@inheritDoc}
*/
@Override
public void setKeyboard(final Keyboard keyboard) {
if (keyboard == null) {
return;
}
final Keyboard lastKeyboard = getKeyboard();
super.setKeyboard(keyboard);
final int lastKeyboardMode = mLastKeyboardMode;
mLastKeyboardMode = keyboard.mId.mMode;
// Since this method is called even when accessibility is off, make sure
// to check the state before announcing anything.
if (!AccessibilityUtils.getInstance().isAccessibilityEnabled()) {
return;
}
// Announce the language name only when the language is changed.
if (lastKeyboard == null || !keyboard.mId.mSubtype.equals(lastKeyboard.mId.mSubtype)) {
announceKeyboardLanguage(keyboard);
return;
}
// Announce the mode only when the mode is changed.
if (keyboard.mId.mMode != lastKeyboardMode) {
announceKeyboardMode(keyboard);
return;
}
// Announce the keyboard type only when the type is changed.
if (keyboard.mId.mElementId != lastKeyboard.mId.mElementId) {
announceKeyboardType(keyboard, lastKeyboard);
return;
}
}
/**
* Called when the keyboard is hidden and accessibility is enabled.
*/
public void onHideWindow() {
if (mLastKeyboardMode != KEYBOARD_IS_HIDDEN) {
announceKeyboardHidden();
}
mLastKeyboardMode = KEYBOARD_IS_HIDDEN;
}
/**
* Announces which language of keyboard is being displayed.
*
* @param keyboard The new keyboard.
*/
private void announceKeyboardLanguage(final Keyboard keyboard) {
final String languageText = SubtypeLocaleUtils.getSubtypeDisplayNameInSystemLocale(
keyboard.mId.mSubtype.getRawSubtype());
sendWindowStateChanged(languageText);
}
/**
* Announces which type of keyboard is being displayed.
* If the keyboard type is unknown, no announcement is made.
*
* @param keyboard The new keyboard.
*/
private void announceKeyboardMode(final Keyboard keyboard) {
final Context context = mKeyboardView.getContext();
final int modeTextResId = KEYBOARD_MODE_RES_IDS.get(keyboard.mId.mMode);
if (modeTextResId == 0) {
return;
}
final String modeText = context.getString(modeTextResId);
final String text = context.getString(R.string.announce_keyboard_mode, modeText);
sendWindowStateChanged(text);
}
/**
* Announces which type of keyboard is being displayed.
*
* @param keyboard The new keyboard.
* @param lastKeyboard The last keyboard.
*/
private void announceKeyboardType(final Keyboard keyboard, final Keyboard lastKeyboard) {
final int lastElementId = lastKeyboard.mId.mElementId;
final int resId;
switch (keyboard.mId.mElementId) {
case KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED:
case KeyboardId.ELEMENT_ALPHABET:
if (lastElementId == KeyboardId.ELEMENT_ALPHABET
|| lastElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED) {
// Transition between alphabet mode and automatic shifted mode should be silently
// ignored because it can be determined by each key's talk back announce.
return;
}
resId = R.string.spoken_description_mode_alpha;
break;
case KeyboardId.ELEMENT_ALPHABET_MANUAL_SHIFTED:
if (lastElementId == KeyboardId.ELEMENT_ALPHABET_AUTOMATIC_SHIFTED) {
// Resetting automatic shifted mode by pressing the shift key causes the transition
// from automatic shifted to manual shifted that should be silently ignored.
return;
}
resId = R.string.spoken_description_shiftmode_on;
break;
case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCK_SHIFTED:
if (lastElementId == KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED) {
// Resetting caps locked mode by pressing the shift key causes the transition
// from shift locked to shift lock shifted that should be silently ignored.
return;
}
resId = R.string.spoken_description_shiftmode_locked;
break;
case KeyboardId.ELEMENT_ALPHABET_SHIFT_LOCKED:
resId = R.string.spoken_description_shiftmode_locked;
break;
case KeyboardId.ELEMENT_SYMBOLS:
resId = R.string.spoken_description_mode_symbol;
break;
case KeyboardId.ELEMENT_SYMBOLS_SHIFTED:
resId = R.string.spoken_description_mode_symbol_shift;
break;
case KeyboardId.ELEMENT_PHONE:
resId = R.string.spoken_description_mode_phone;
break;
case KeyboardId.ELEMENT_PHONE_SYMBOLS:
resId = R.string.spoken_description_mode_phone_shift;
break;
default:
return;
}
sendWindowStateChanged(resId);
}
/**
* Announces that the keyboard has been hidden.
*/
private void announceKeyboardHidden() {
sendWindowStateChanged(R.string.announce_keyboard_hidden);
}
@Override
public void performClickOn(final Key key) {
final int x = key.getHitBox().centerX();
final int y = key.getHitBox().centerY();
if (DEBUG_HOVER) {
Log.d(TAG, "performClickOn: key=" + key
+ " inIgnoreBounds=" + mBoundsToIgnoreHoverEvent.contains(x, y));
}
if (mBoundsToIgnoreHoverEvent.contains(x, y)) {
// This hover exit event points to the key that should be ignored.
// Clear the ignoring region to handle further hover events.
mBoundsToIgnoreHoverEvent.setEmpty();
return;
}
super.performClickOn(key);
}
@Override
protected void onHoverEnterTo(final Key key) {
final int x = key.getHitBox().centerX();
final int y = key.getHitBox().centerY();
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverEnterTo: key=" + key
+ " inIgnoreBounds=" + mBoundsToIgnoreHoverEvent.contains(x, y));
}
mAccessibilityLongPressTimer.cancelLongPress();
if (mBoundsToIgnoreHoverEvent.contains(x, y)) {
return;
}
// This hover enter event points to the key that isn't in the ignoring region.
// Further hover events should be handled.
mBoundsToIgnoreHoverEvent.setEmpty();
super.onHoverEnterTo(key);
if (key.isLongPressEnabled()) {
mAccessibilityLongPressTimer.startLongPress(key);
}
}
@Override
protected void onHoverExitFrom(final Key key) {
final int x = key.getHitBox().centerX();
final int y = key.getHitBox().centerY();
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverExitFrom: key=" + key
+ " inIgnoreBounds=" + mBoundsToIgnoreHoverEvent.contains(x, y));
}
mAccessibilityLongPressTimer.cancelLongPress();
super.onHoverExitFrom(key);
}
@Override
public void performLongClickOn(final Key key) {
if (DEBUG_HOVER) {
Log.d(TAG, "performLongClickOn: key=" + key);
}
final PointerTracker tracker = PointerTracker.getPointerTracker(HOVER_EVENT_POINTER_ID);
final long eventTime = SystemClock.uptimeMillis();
final int x = key.getHitBox().centerX();
final int y = key.getHitBox().centerY();
final MotionEvent downEvent = MotionEvent.obtain(
eventTime, eventTime, MotionEvent.ACTION_DOWN, x, y, 0 /* metaState */);
// Inject a fake down event to {@link PointerTracker} to handle a long press correctly.
tracker.processMotionEvent(downEvent, mKeyDetector);
downEvent.recycle();
// Invoke {@link PointerTracker#onLongPressed()} as if a long press timeout has passed.
tracker.onLongPressed();
// If {@link Key#hasNoPanelAutoMoreKeys()} is true (such as "0 +" key on the phone layout)
// or a key invokes IME switcher dialog, we should just ignore the next
// {@link #onRegisterHoverKey(Key,MotionEvent)}. It can be determined by whether
// {@link PointerTracker} is in operation or not.
if (tracker.isInOperation()) {
// This long press shows a more keys keyboard and further hover events should be
// handled.
mBoundsToIgnoreHoverEvent.setEmpty();
return;
}
// This long press has handled at {@link MainKeyboardView#onLongPress(PointerTracker)}.
// We should ignore further hover events on this key.
mBoundsToIgnoreHoverEvent.set(key.getHitBox());
if (key.hasNoPanelAutoMoreKey()) {
// This long press has registered a code point without showing a more keys keyboard.
// We should talk back the code point if possible.
final int codePointOfNoPanelAutoMoreKey = key.getMoreKeys()[0].mCode;
final String text = KeyCodeDescriptionMapper.getInstance().getDescriptionForCodePoint(
mKeyboardView.getContext(), codePointOfNoPanelAutoMoreKey);
if (text != null) {
sendWindowStateChanged(text);
}
}
}
}

View file

@ -0,0 +1,120 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.accessibility;
import android.graphics.Rect;
import android.util.Log;
import android.view.MotionEvent;
import org.dslul.openboard.inputmethod.keyboard.Key;
import org.dslul.openboard.inputmethod.keyboard.KeyDetector;
import org.dslul.openboard.inputmethod.keyboard.MoreKeysKeyboardView;
import org.dslul.openboard.inputmethod.keyboard.PointerTracker;
/**
* This class represents a delegate that can be registered in {@link MoreKeysKeyboardView} to
* enhance accessibility support via composition rather via inheritance.
*/
public class MoreKeysKeyboardAccessibilityDelegate
extends KeyboardAccessibilityDelegate<MoreKeysKeyboardView> {
private static final String TAG = MoreKeysKeyboardAccessibilityDelegate.class.getSimpleName();
private final Rect mMoreKeysKeyboardValidBounds = new Rect();
private static final int CLOSING_INSET_IN_PIXEL = 1;
private int mOpenAnnounceResId;
private int mCloseAnnounceResId;
public MoreKeysKeyboardAccessibilityDelegate(final MoreKeysKeyboardView moreKeysKeyboardView,
final KeyDetector keyDetector) {
super(moreKeysKeyboardView, keyDetector);
}
public void setOpenAnnounce(final int resId) {
mOpenAnnounceResId = resId;
}
public void setCloseAnnounce(final int resId) {
mCloseAnnounceResId = resId;
}
public void onShowMoreKeysKeyboard() {
sendWindowStateChanged(mOpenAnnounceResId);
}
public void onDismissMoreKeysKeyboard() {
sendWindowStateChanged(mCloseAnnounceResId);
}
@Override
protected void onHoverEnter(final MotionEvent event) {
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverEnter: key=" + getHoverKeyOf(event));
}
super.onHoverEnter(event);
final int actionIndex = event.getActionIndex();
final int x = (int)event.getX(actionIndex);
final int y = (int)event.getY(actionIndex);
final int pointerId = event.getPointerId(actionIndex);
final long eventTime = event.getEventTime();
mKeyboardView.onDownEvent(x, y, pointerId, eventTime);
}
@Override
protected void onHoverMove(final MotionEvent event) {
super.onHoverMove(event);
final int actionIndex = event.getActionIndex();
final int x = (int)event.getX(actionIndex);
final int y = (int)event.getY(actionIndex);
final int pointerId = event.getPointerId(actionIndex);
final long eventTime = event.getEventTime();
mKeyboardView.onMoveEvent(x, y, pointerId, eventTime);
}
@Override
protected void onHoverExit(final MotionEvent event) {
final Key lastKey = getLastHoverKey();
if (DEBUG_HOVER) {
Log.d(TAG, "onHoverExit: key=" + getHoverKeyOf(event) + " last=" + lastKey);
}
if (lastKey != null) {
super.onHoverExitFrom(lastKey);
}
setLastHoverKey(null);
final int actionIndex = event.getActionIndex();
final int x = (int)event.getX(actionIndex);
final int y = (int)event.getY(actionIndex);
final int pointerId = event.getPointerId(actionIndex);
final long eventTime = event.getEventTime();
// A hover exit event at one pixel width or height area on the edges of more keys keyboard
// are treated as closing.
mMoreKeysKeyboardValidBounds.set(0, 0, mKeyboardView.getWidth(), mKeyboardView.getHeight());
mMoreKeysKeyboardValidBounds.inset(CLOSING_INSET_IN_PIXEL, CLOSING_INSET_IN_PIXEL);
if (mMoreKeysKeyboardValidBounds.contains(x, y)) {
// Invoke {@link MoreKeysKeyboardView#onUpEvent(int,int,int,long)} as if this hover
// exit event selects a key.
mKeyboardView.onUpEvent(x, y, pointerId, eventTime);
// TODO: Should fix this reference. This is a hack to clear the state of
// {@link PointerTracker}.
PointerTracker.dismissAllMoreKeysPanels();
return;
}
// Close the more keys keyboard.
// TODO: Should fix this reference. This is a hack to clear the state of
// {@link PointerTracker}.
PointerTracker.dismissAllMoreKeysPanels();
}
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.annotations;
/**
* Denotes that the class, method or field should not be eliminated by ProGuard,
* because it is externally referenced. (See proguard.flags)
*/
public @interface ExternallyReferenced {
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.annotations;
/**
* Denotes that the class, method or field should not be eliminated by ProGuard,
* so that unit tests can access it. (See proguard.flags)
*/
public @interface UsedForTesting {
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.app.ActivityManager;
import android.content.Context;
import java.lang.reflect.Method;
public class ActivityManagerCompatUtils {
private static final Object LOCK = new Object();
private static volatile Boolean sBoolean = null;
private static final Method METHOD_isLowRamDevice = CompatUtils.getMethod(
ActivityManager.class, "isLowRamDevice");
private ActivityManagerCompatUtils() {
// Do not instantiate this class.
}
public static boolean isLowRamDevice(Context context) {
if (sBoolean == null) {
synchronized(LOCK) {
if (sBoolean == null) {
final ActivityManager am =
(ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
sBoolean = (Boolean)CompatUtils.invoke(am, false, METHOD_isLowRamDevice);
}
}
}
return sBoolean;
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.content.pm.PackageInfo;
@SuppressWarnings("unused")
public class AppWorkaroundsHelper {
private AppWorkaroundsHelper() {
// This helper class is not publicly instantiable.
}
public static boolean evaluateIsBrokenByRecorrection(final PackageInfo info) {
return false;
}
}

View file

@ -0,0 +1,60 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.content.pm.PackageInfo;
import android.os.Build.VERSION_CODES;
/**
* A class to encapsulate work-arounds specific to particular apps.
*/
public class AppWorkaroundsUtils {
private final PackageInfo mPackageInfo; // May be null
private final boolean mIsBrokenByRecorrection;
public AppWorkaroundsUtils(final PackageInfo packageInfo) {
mPackageInfo = packageInfo;
mIsBrokenByRecorrection = AppWorkaroundsHelper.evaluateIsBrokenByRecorrection(
packageInfo);
}
public boolean isBrokenByRecorrection() {
return mIsBrokenByRecorrection;
}
public boolean isBeforeJellyBean() {
if (null == mPackageInfo || null == mPackageInfo.applicationInfo) {
return false;
}
return mPackageInfo.applicationInfo.targetSdkVersion < VERSION_CODES.JELLY_BEAN;
}
@Override
public String toString() {
if (null == mPackageInfo || null == mPackageInfo.applicationInfo) {
return "";
}
final StringBuilder s = new StringBuilder();
s.append("Target application : ")
.append(mPackageInfo.applicationInfo.name)
.append("\nPackage : ")
.append(mPackageInfo.applicationInfo.packageName)
.append("\nTarget app sdk version : ")
.append(mPackageInfo.applicationInfo.targetSdkVersion);
return s.toString();
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.os.Build;
public final class BuildCompatUtils {
private BuildCompatUtils() {
// This utility class is not publicly instantiable.
}
private static final boolean IS_RELEASE_BUILD = Build.VERSION.CODENAME.equals("REL");
/**
* The "effective" API version.
* {@link android.os.Build.VERSION#SDK_INT} if the platform is a release build.
* {@link android.os.Build.VERSION#SDK_INT} plus 1 if the platform is a development build.
*/
public static final int EFFECTIVE_SDK_INT = IS_RELEASE_BUILD
? Build.VERSION.SDK_INT
: Build.VERSION.SDK_INT + 1;
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import java.lang.reflect.Method;
public final class CharacterCompat {
// Note that Character.isAlphabetic(int), has been introduced in API level 19
// (Build.VERSION_CODE.KITKAT).
private static final Method METHOD_isAlphabetic = CompatUtils.getMethod(
Character.class, "isAlphabetic", int.class);
private CharacterCompat() {
// This utility class is not publicly instantiable.
}
public static boolean isAlphabetic(final int code) {
if (METHOD_isAlphabetic != null) {
return (Boolean)CompatUtils.invoke(null, false, METHOD_isAlphabetic, code);
}
switch (Character.getType(code)) {
case Character.UPPERCASE_LETTER:
case Character.LOWERCASE_LETTER:
case Character.TITLECASE_LETTER:
case Character.MODIFIER_LETTER:
case Character.OTHER_LETTER:
case Character.LETTER_NUMBER:
return true;
default:
return false;
}
}
}

View file

@ -0,0 +1,218 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.text.TextUtils;
import android.util.Log;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public final class CompatUtils {
private static final String TAG = CompatUtils.class.getSimpleName();
private CompatUtils() {
// This utility class is not publicly instantiable.
}
public static Class<?> getClass(final String className) {
try {
return Class.forName(className);
} catch (final ClassNotFoundException e) {
return null;
}
}
public static Method getMethod(final Class<?> targetClass, final String name,
final Class<?>... parameterTypes) {
if (targetClass == null || TextUtils.isEmpty(name)) {
return null;
}
try {
return targetClass.getMethod(name, parameterTypes);
} catch (final SecurityException | NoSuchMethodException e) {
// ignore
}
return null;
}
public static Field getField(final Class<?> targetClass, final String name) {
if (targetClass == null || TextUtils.isEmpty(name)) {
return null;
}
try {
return targetClass.getField(name);
} catch (final SecurityException | NoSuchFieldException e) {
// ignore
}
return null;
}
public static Constructor<?> getConstructor(final Class<?> targetClass,
final Class<?> ... types) {
if (targetClass == null || types == null) {
return null;
}
try {
return targetClass.getConstructor(types);
} catch (final SecurityException | NoSuchMethodException e) {
// ignore
}
return null;
}
public static Object newInstance(final Constructor<?> constructor, final Object ... args) {
if (constructor == null) {
return null;
}
try {
return constructor.newInstance(args);
} catch (final InstantiationException | IllegalAccessException | IllegalArgumentException
| InvocationTargetException e) {
Log.e(TAG, "Exception in newInstance", e);
}
return null;
}
public static Object invoke(final Object receiver, final Object defaultValue,
final Method method, final Object... args) {
if (method == null) {
return defaultValue;
}
try {
return method.invoke(receiver, args);
} catch (final IllegalAccessException | IllegalArgumentException
| InvocationTargetException e) {
Log.e(TAG, "Exception in invoke", e);
}
return defaultValue;
}
public static Object getFieldValue(final Object receiver, final Object defaultValue,
final Field field) {
if (field == null) {
return defaultValue;
}
try {
return field.get(receiver);
} catch (final IllegalAccessException | IllegalArgumentException e) {
Log.e(TAG, "Exception in getFieldValue", e);
}
return defaultValue;
}
public static void setFieldValue(final Object receiver, final Field field, final Object value) {
if (field == null) {
return;
}
try {
field.set(receiver, value);
} catch (final IllegalAccessException | IllegalArgumentException e) {
Log.e(TAG, "Exception in setFieldValue", e);
}
}
public static ClassWrapper getClassWrapper(final String className) {
return new ClassWrapper(getClass(className));
}
public static final class ClassWrapper {
private final Class<?> mClass;
public ClassWrapper(final Class<?> targetClass) {
mClass = targetClass;
}
public boolean exists() {
return mClass != null;
}
public <T> ToObjectMethodWrapper<T> getMethod(final String name,
final T defaultValue, final Class<?>... parameterTypes) {
return new ToObjectMethodWrapper<>(CompatUtils.getMethod(mClass, name, parameterTypes),
defaultValue);
}
public ToIntMethodWrapper getPrimitiveMethod(final String name, final int defaultValue,
final Class<?>... parameterTypes) {
return new ToIntMethodWrapper(CompatUtils.getMethod(mClass, name, parameterTypes),
defaultValue);
}
public ToFloatMethodWrapper getPrimitiveMethod(final String name, final float defaultValue,
final Class<?>... parameterTypes) {
return new ToFloatMethodWrapper(CompatUtils.getMethod(mClass, name, parameterTypes),
defaultValue);
}
public ToBooleanMethodWrapper getPrimitiveMethod(final String name,
final boolean defaultValue, final Class<?>... parameterTypes) {
return new ToBooleanMethodWrapper(CompatUtils.getMethod(mClass, name, parameterTypes),
defaultValue);
}
}
public static final class ToObjectMethodWrapper<T> {
private final Method mMethod;
private final T mDefaultValue;
public ToObjectMethodWrapper(final Method method, final T defaultValue) {
mMethod = method;
mDefaultValue = defaultValue;
}
@SuppressWarnings("unchecked")
public T invoke(final Object receiver, final Object... args) {
return (T) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args);
}
}
public static final class ToIntMethodWrapper {
private final Method mMethod;
private final int mDefaultValue;
public ToIntMethodWrapper(final Method method, final int defaultValue) {
mMethod = method;
mDefaultValue = defaultValue;
}
public int invoke(final Object receiver, final Object... args) {
return (int) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args);
}
}
public static final class ToFloatMethodWrapper {
private final Method mMethod;
private final float mDefaultValue;
public ToFloatMethodWrapper(final Method method, final float defaultValue) {
mMethod = method;
mDefaultValue = defaultValue;
}
public float invoke(final Object receiver, final Object... args) {
return (float) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args);
}
}
public static final class ToBooleanMethodWrapper {
private final Method mMethod;
private final boolean mDefaultValue;
public ToBooleanMethodWrapper(final Method method, final boolean defaultValue) {
mMethod = method;
mDefaultValue = defaultValue;
}
public boolean invoke(final Object receiver, final Object... args) {
return (boolean) CompatUtils.invoke(receiver, mDefaultValue, mMethod, args);
}
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.net.ConnectivityManager;
import java.lang.reflect.Method;
public final class ConnectivityManagerCompatUtils {
// ConnectivityManager#isActiveNetworkMetered() has been introduced
// in API level 16 (Build.VERSION_CODES.JELLY_BEAN).
private static final Method METHOD_isActiveNetworkMetered = CompatUtils.getMethod(
ConnectivityManager.class, "isActiveNetworkMetered");
public static boolean isActiveNetworkMetered(final ConnectivityManager manager) {
return (Boolean)CompatUtils.invoke(manager,
// If the API telling whether the network is metered or not is not available,
// then the closest thing is "if it's a mobile connection".
manager.getActiveNetworkInfo().getType() == ConnectivityManager.TYPE_MOBILE,
METHOD_isActiveNetworkMetered);
}
}

View file

@ -0,0 +1,185 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.annotation.TargetApi;
import android.graphics.Matrix;
import android.graphics.RectF;
import android.os.Build;
import android.view.inputmethod.CursorAnchorInfo;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
/**
* A wrapper for {@link CursorAnchorInfo}, which has been introduced in API Level 21. You can use
* this wrapper to avoid direct dependency on newly introduced types.
*/
public class CursorAnchorInfoCompatWrapper {
/**
* The insertion marker or character bounds have at least one visible region.
*/
public static final int FLAG_HAS_VISIBLE_REGION = 0x01;
/**
* The insertion marker or character bounds have at least one invisible (clipped) region.
*/
public static final int FLAG_HAS_INVISIBLE_REGION = 0x02;
/**
* The insertion marker or character bounds is placed at right-to-left (RTL) character.
*/
public static final int FLAG_IS_RTL = 0x04;
CursorAnchorInfoCompatWrapper() {
// This class is not publicly instantiable.
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
@Nullable
public static CursorAnchorInfoCompatWrapper wrap(@Nullable final CursorAnchorInfo instance) {
if (BuildCompatUtils.EFFECTIVE_SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return null;
}
if (instance == null) {
return null;
}
return new RealWrapper(instance);
}
public int getSelectionStart() {
throw new UnsupportedOperationException("not supported.");
}
public int getSelectionEnd() {
throw new UnsupportedOperationException("not supported.");
}
public CharSequence getComposingText() {
throw new UnsupportedOperationException("not supported.");
}
public int getComposingTextStart() {
throw new UnsupportedOperationException("not supported.");
}
public Matrix getMatrix() {
throw new UnsupportedOperationException("not supported.");
}
@SuppressWarnings("unused")
public RectF getCharacterBounds(final int index) {
throw new UnsupportedOperationException("not supported.");
}
@SuppressWarnings("unused")
public int getCharacterBoundsFlags(final int index) {
throw new UnsupportedOperationException("not supported.");
}
public float getInsertionMarkerBaseline() {
throw new UnsupportedOperationException("not supported.");
}
public float getInsertionMarkerBottom() {
throw new UnsupportedOperationException("not supported.");
}
public float getInsertionMarkerHorizontal() {
throw new UnsupportedOperationException("not supported.");
}
public float getInsertionMarkerTop() {
throw new UnsupportedOperationException("not supported.");
}
public int getInsertionMarkerFlags() {
throw new UnsupportedOperationException("not supported.");
}
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
private static final class RealWrapper extends CursorAnchorInfoCompatWrapper {
@Nonnull
private final CursorAnchorInfo mInstance;
public RealWrapper(@Nonnull final CursorAnchorInfo info) {
mInstance = info;
}
@Override
public int getSelectionStart() {
return mInstance.getSelectionStart();
}
@Override
public int getSelectionEnd() {
return mInstance.getSelectionEnd();
}
@Override
public CharSequence getComposingText() {
return mInstance.getComposingText();
}
@Override
public int getComposingTextStart() {
return mInstance.getComposingTextStart();
}
@Override
public Matrix getMatrix() {
return mInstance.getMatrix();
}
@Override
public RectF getCharacterBounds(final int index) {
return mInstance.getCharacterBounds(index);
}
@Override
public int getCharacterBoundsFlags(final int index) {
return mInstance.getCharacterBoundsFlags(index);
}
@Override
public float getInsertionMarkerBaseline() {
return mInstance.getInsertionMarkerBaseline();
}
@Override
public float getInsertionMarkerBottom() {
return mInstance.getInsertionMarkerBottom();
}
@Override
public float getInsertionMarkerHorizontal() {
return mInstance.getInsertionMarkerHorizontal();
}
@Override
public float getInsertionMarkerTop() {
return mInstance.getInsertionMarkerTop();
}
@Override
public int getInsertionMarkerFlags() {
return mInstance.getInsertionMarkerFlags();
}
}
}

View file

@ -0,0 +1,98 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.view.inputmethod.EditorInfo;
import java.lang.reflect.Field;
import java.util.Locale;
public final class EditorInfoCompatUtils {
// Note that EditorInfo.IME_FLAG_FORCE_ASCII has been introduced
// in API level 16 (Build.VERSION_CODES.JELLY_BEAN).
private static final Field FIELD_IME_FLAG_FORCE_ASCII = CompatUtils.getField(
EditorInfo.class, "IME_FLAG_FORCE_ASCII");
private static final Integer OBJ_IME_FLAG_FORCE_ASCII = (Integer) CompatUtils.getFieldValue(
null /* receiver */, null /* defaultValue */, FIELD_IME_FLAG_FORCE_ASCII);
private static final Field FIELD_HINT_LOCALES = CompatUtils.getField(
EditorInfo.class, "hintLocales");
private EditorInfoCompatUtils() {
// This utility class is not publicly instantiable.
}
public static boolean hasFlagForceAscii(final int imeOptions) {
if (OBJ_IME_FLAG_FORCE_ASCII == null) return false;
return (imeOptions & OBJ_IME_FLAG_FORCE_ASCII) != 0;
}
public static String imeActionName(final int imeOptions) {
final int actionId = imeOptions & EditorInfo.IME_MASK_ACTION;
switch (actionId) {
case EditorInfo.IME_ACTION_UNSPECIFIED:
return "actionUnspecified";
case EditorInfo.IME_ACTION_NONE:
return "actionNone";
case EditorInfo.IME_ACTION_GO:
return "actionGo";
case EditorInfo.IME_ACTION_SEARCH:
return "actionSearch";
case EditorInfo.IME_ACTION_SEND:
return "actionSend";
case EditorInfo.IME_ACTION_NEXT:
return "actionNext";
case EditorInfo.IME_ACTION_DONE:
return "actionDone";
case EditorInfo.IME_ACTION_PREVIOUS:
return "actionPrevious";
default:
return "actionUnknown(" + actionId + ")";
}
}
public static String imeOptionsName(final int imeOptions) {
final String action = imeActionName(imeOptions);
final StringBuilder flags = new StringBuilder();
if ((imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) {
flags.append("flagNoEnterAction|");
}
if ((imeOptions & EditorInfo.IME_FLAG_NAVIGATE_NEXT) != 0) {
flags.append("flagNavigateNext|");
}
if ((imeOptions & EditorInfo.IME_FLAG_NAVIGATE_PREVIOUS) != 0) {
flags.append("flagNavigatePrevious|");
}
if (hasFlagForceAscii(imeOptions)) {
flags.append("flagForceAscii|");
}
return (action != null) ? flags + action : flags.toString();
}
public static Locale getPrimaryHintLocale(final EditorInfo editorInfo) {
if (editorInfo == null) {
return null;
}
final Object localeList = CompatUtils.getFieldValue(editorInfo, null, FIELD_HINT_LOCALES);
if (localeList == null) {
return null;
}
if (LocaleListCompatUtils.isEmpty(localeList)) {
return null;
}
return LocaleListCompatUtils.get(localeList, 0);
}
}

View file

@ -0,0 +1,64 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
public final class InputConnectionCompatUtils {
private static final CompatUtils.ClassWrapper sInputConnectionType;
private static final CompatUtils.ToBooleanMethodWrapper sRequestCursorUpdatesMethod;
static {
sInputConnectionType = new CompatUtils.ClassWrapper(InputConnection.class);
sRequestCursorUpdatesMethod = sInputConnectionType.getPrimitiveMethod(
"requestCursorUpdates", false, int.class);
}
public static boolean isRequestCursorUpdatesAvailable() {
return sRequestCursorUpdatesMethod != null;
}
/**
* Local copies of some constants in InputConnection until the SDK becomes publicly available.
*/
private static int CURSOR_UPDATE_IMMEDIATE = 1 << 0;
private static int CURSOR_UPDATE_MONITOR = 1 << 1;
private static boolean requestCursorUpdatesImpl(final InputConnection inputConnection,
final int cursorUpdateMode) {
if (!isRequestCursorUpdatesAvailable()) {
return false;
}
return sRequestCursorUpdatesMethod.invoke(inputConnection, cursorUpdateMode);
}
/**
* Requests the editor to call back {@link InputMethodManager#updateCursorAnchorInfo}.
* @param inputConnection the input connection to which the request is to be sent.
* @param enableMonitor {@code true} to request the editor to call back the method whenever the
* cursor/anchor position is changed.
* @param requestImmediateCallback {@code true} to request the editor to call back the method
* as soon as possible to notify the current cursor/anchor position to the input method.
* @return {@code false} if the request is not handled. Otherwise returns {@code true}.
*/
public static boolean requestCursorUpdates(final InputConnection inputConnection,
final boolean enableMonitor, final boolean requestImmediateCallback) {
final int cursorUpdateMode = (enableMonitor ? CURSOR_UPDATE_MONITOR : 0)
| (requestImmediateCallback ? CURSOR_UPDATE_IMMEDIATE : 0);
return requestCursorUpdatesImpl(inputConnection, cursorUpdateMode);
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.content.Context;
import android.os.IBinder;
import android.view.inputmethod.InputMethodManager;
import java.lang.reflect.Method;
public final class InputMethodManagerCompatWrapper {
// Note that InputMethodManager.switchToNextInputMethod() has been introduced
// in API level 16 (Build.VERSION_CODES.JELLY_BEAN).
private static final Method METHOD_switchToNextInputMethod = CompatUtils.getMethod(
InputMethodManager.class, "switchToNextInputMethod", IBinder.class, boolean.class);
// Note that InputMethodManager.shouldOfferSwitchingToNextInputMethod() has been introduced
// in API level 19 (Build.VERSION_CODES.KITKAT).
private static final Method METHOD_shouldOfferSwitchingToNextInputMethod =
CompatUtils.getMethod(InputMethodManager.class,
"shouldOfferSwitchingToNextInputMethod", IBinder.class);
public final InputMethodManager mImm;
public InputMethodManagerCompatWrapper(final Context context) {
mImm = (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE);
}
public boolean switchToNextInputMethod(final IBinder token, final boolean onlyCurrentIme) {
return (Boolean)CompatUtils.invoke(mImm, false /* defaultValue */,
METHOD_switchToNextInputMethod, token, onlyCurrentIme);
}
public boolean shouldOfferSwitchingToNextInputMethod(final IBinder token) {
return (Boolean)CompatUtils.invoke(mImm, false /* defaultValue */,
METHOD_shouldOfferSwitchingToNextInputMethod, token);
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.inputmethodservice.InputMethodService;
import java.lang.reflect.Method;
public final class InputMethodServiceCompatUtils {
// Note that {@link InputMethodService#enableHardwareAcceleration} has been introduced
// in API level 17 (Build.VERSION_CODES.JELLY_BEAN_MR1).
private static final Method METHOD_enableHardwareAcceleration =
CompatUtils.getMethod(InputMethodService.class, "enableHardwareAcceleration");
private InputMethodServiceCompatUtils() {
// This utility class is not publicly instantiable.
}
public static boolean enableHardwareAcceleration(final InputMethodService ims) {
return (Boolean)CompatUtils.invoke(ims, false /* defaultValue */,
METHOD_enableHardwareAcceleration);
}
}

View file

@ -0,0 +1,103 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.os.Build;
import android.text.TextUtils;
import android.view.inputmethod.InputMethodSubtype;
import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
import org.dslul.openboard.inputmethod.latin.RichInputMethodSubtype;
import org.dslul.openboard.inputmethod.latin.common.Constants;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.Locale;
import javax.annotation.Nonnull;
public final class InputMethodSubtypeCompatUtils {
private static final String TAG = InputMethodSubtypeCompatUtils.class.getSimpleName();
// Note that InputMethodSubtype(int nameId, int iconId, String locale, String mode,
// String extraValue, boolean isAuxiliary, boolean overridesImplicitlyEnabledSubtype, int id)
// has been introduced in API level 17 (Build.VERSION_CODE.JELLY_BEAN_MR1).
private static final Constructor<?> CONSTRUCTOR_INPUT_METHOD_SUBTYPE =
CompatUtils.getConstructor(InputMethodSubtype.class,
int.class, int.class, String.class, String.class, String.class, boolean.class,
boolean.class, int.class);
static {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
if (CONSTRUCTOR_INPUT_METHOD_SUBTYPE == null) {
android.util.Log.w(TAG, "Warning!!! Constructor is not defined.");
}
}
}
// Note that {@link InputMethodSubtype#isAsciiCapable()} has been introduced in API level 19
// (Build.VERSION_CODE.KITKAT).
private static final Method METHOD_isAsciiCapable = CompatUtils.getMethod(
InputMethodSubtype.class, "isAsciiCapable");
private InputMethodSubtypeCompatUtils() {
// This utility class is not publicly instantiable.
}
@SuppressWarnings("deprecation")
@Nonnull
public static InputMethodSubtype newInputMethodSubtype(int nameId, int iconId, String locale,
String mode, String extraValue, boolean isAuxiliary,
boolean overridesImplicitlyEnabledSubtype, int id) {
if (CONSTRUCTOR_INPUT_METHOD_SUBTYPE == null
|| Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) {
return new InputMethodSubtype(nameId, iconId, locale, mode, extraValue, isAuxiliary,
overridesImplicitlyEnabledSubtype);
}
return (InputMethodSubtype) CompatUtils.newInstance(CONSTRUCTOR_INPUT_METHOD_SUBTYPE,
nameId, iconId, locale, mode, extraValue, isAuxiliary,
overridesImplicitlyEnabledSubtype, id);
}
public static boolean isAsciiCapable(final RichInputMethodSubtype subtype) {
return isAsciiCapable(subtype.getRawSubtype());
}
public static boolean isAsciiCapable(final InputMethodSubtype subtype) {
return isAsciiCapableWithAPI(subtype)
|| subtype.containsExtraValueKey(Constants.Subtype.ExtraValue.ASCII_CAPABLE);
}
// Note that InputMethodSubtype.getLanguageTag() is expected to be available in Android N+.
private static final Method GET_LANGUAGE_TAG =
CompatUtils.getMethod(InputMethodSubtype.class, "getLanguageTag");
public static Locale getLocaleObject(final InputMethodSubtype subtype) {
// Locale.forLanguageTag() is available only in Android L and later.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
final String languageTag = (String) CompatUtils.invoke(subtype, null, GET_LANGUAGE_TAG);
if (!TextUtils.isEmpty(languageTag)) {
return Locale.forLanguageTag(languageTag);
}
}
return LocaleUtils.constructLocaleFromString(subtype.getLocale());
}
@UsedForTesting
public static boolean isAsciiCapableWithAPI(final InputMethodSubtype subtype) {
return (Boolean)CompatUtils.invoke(subtype, false, METHOD_isAsciiCapable);
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.content.Intent;
public final class IntentCompatUtils {
// Note that Intent.ACTION_USER_INITIALIZE have been introduced in API level 17
// (Build.VERSION_CODE.JELLY_BEAN_MR1).
private static final String ACTION_USER_INITIALIZE =
(String)CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */,
CompatUtils.getField(Intent.class, "ACTION_USER_INITIALIZE"));
private IntentCompatUtils() {
// This utility class is not publicly instantiable.
}
public static boolean is_ACTION_USER_INITIALIZE(final String action) {
return ACTION_USER_INITIALIZE != null && ACTION_USER_INITIALIZE.equals(action);
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import java.lang.reflect.Method;
import java.util.Locale;
public final class LocaleListCompatUtils {
private static final Class CLASS_LocaleList = CompatUtils.getClass("android.os.LocaleList");
private static final Method METHOD_get =
CompatUtils.getMethod(CLASS_LocaleList, "get", int.class);
private static final Method METHOD_isEmpty =
CompatUtils.getMethod(CLASS_LocaleList, "isEmpty");
private LocaleListCompatUtils() {
// This utility class is not publicly instantiable.
}
public static boolean isEmpty(final Object localeList) {
return (Boolean) CompatUtils.invoke(localeList, Boolean.FALSE, METHOD_isEmpty);
}
public static Locale get(final Object localeList, final int index) {
return (Locale) CompatUtils.invoke(localeList, null, METHOD_get, index);
}
}

View file

@ -0,0 +1,218 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.text.Spannable;
import android.text.Spanned;
import android.text.style.LocaleSpan;
import android.util.Log;
import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Locale;
@UsedForTesting
public final class LocaleSpanCompatUtils {
private static final String TAG = LocaleSpanCompatUtils.class.getSimpleName();
// Note that LocaleSpan(Locale locale) has been introduced in API level 17
// (Build.VERSION_CODE.JELLY_BEAN_MR1).
private static Class<?> getLocaleSpanClass() {
try {
return Class.forName("android.text.style.LocaleSpan");
} catch (ClassNotFoundException e) {
return null;
}
}
private static final Class<?> LOCALE_SPAN_TYPE;
private static final Constructor<?> LOCALE_SPAN_CONSTRUCTOR;
private static final Method LOCALE_SPAN_GET_LOCALE;
static {
LOCALE_SPAN_TYPE = getLocaleSpanClass();
LOCALE_SPAN_CONSTRUCTOR = CompatUtils.getConstructor(LOCALE_SPAN_TYPE, Locale.class);
LOCALE_SPAN_GET_LOCALE = CompatUtils.getMethod(LOCALE_SPAN_TYPE, "getLocale");
}
@UsedForTesting
public static boolean isLocaleSpanAvailable() {
return (LOCALE_SPAN_CONSTRUCTOR != null && LOCALE_SPAN_GET_LOCALE != null);
}
@UsedForTesting
public static Object newLocaleSpan(final Locale locale) {
return CompatUtils.newInstance(LOCALE_SPAN_CONSTRUCTOR, locale);
}
@UsedForTesting
public static Locale getLocaleFromLocaleSpan(final Object localeSpan) {
return (Locale) CompatUtils.invoke(localeSpan, null, LOCALE_SPAN_GET_LOCALE);
}
/**
* Ensures that the specified range is covered with only one {@link LocaleSpan} with the given
* locale. If the region is already covered by one or more {@link LocaleSpan}, their ranges are
* updated so that each character has only one locale.
* @param spannable the spannable object to be updated.
* @param start the start index from which {@link LocaleSpan} is attached (inclusive).
* @param end the end index to which {@link LocaleSpan} is attached (exclusive).
* @param locale the locale to be attached to the specified range.
*/
@UsedForTesting
public static void updateLocaleSpan(final Spannable spannable, final int start,
final int end, final Locale locale) {
if (end < start) {
Log.e(TAG, "Invalid range: start=" + start + " end=" + end);
return;
}
if (!isLocaleSpanAvailable()) {
return;
}
// A brief summary of our strategy;
// 1. Enumerate all LocaleSpans between [start - 1, end + 1].
// 2. For each LocaleSpan S:
// - Update the range of S so as not to cover [start, end] if S doesn't have the
// expected locale.
// - Mark S as "to be merged" if S has the expected locale.
// 3. Merge all the LocaleSpans that are marked as "to be merged" into one LocaleSpan.
// If no appropriate span is found, create a new one with newLocaleSpan method.
final int searchStart = Math.max(start - 1, 0);
final int searchEnd = Math.min(end + 1, spannable.length());
// LocaleSpans found in the target range. See the step 1 in the above comment.
final Object[] existingLocaleSpans = spannable.getSpans(searchStart, searchEnd,
LOCALE_SPAN_TYPE);
// LocaleSpans that are marked as "to be merged". See the step 2 in the above comment.
final ArrayList<Object> existingLocaleSpansToBeMerged = new ArrayList<>();
boolean isStartExclusive = true;
boolean isEndExclusive = true;
int newStart = start;
int newEnd = end;
for (final Object existingLocaleSpan : existingLocaleSpans) {
final Locale attachedLocale = getLocaleFromLocaleSpan(existingLocaleSpan);
if (!locale.equals(attachedLocale)) {
// This LocaleSpan does not have the expected locale. Update its range if it has
// an intersection with the range [start, end] (the first case of the step 2 in the
// above comment).
removeLocaleSpanFromRange(existingLocaleSpan, spannable, start, end);
continue;
}
final int spanStart = spannable.getSpanStart(existingLocaleSpan);
final int spanEnd = spannable.getSpanEnd(existingLocaleSpan);
if (spanEnd < spanStart) {
Log.e(TAG, "Invalid span: spanStart=" + spanStart + " spanEnd=" + spanEnd);
continue;
}
if (spanEnd < start || end < spanStart) {
// No intersection found.
continue;
}
// Here existingLocaleSpan has the expected locale and an intersection with the
// range [start, end] (the second case of the the step 2 in the above comment).
final int spanFlag = spannable.getSpanFlags(existingLocaleSpan);
if (spanStart < newStart) {
newStart = spanStart;
isStartExclusive = ((spanFlag & Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) ==
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
if (newEnd < spanEnd) {
newEnd = spanEnd;
isEndExclusive = ((spanFlag & Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) ==
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
existingLocaleSpansToBeMerged.add(existingLocaleSpan);
}
int originalLocaleSpanFlag = 0;
Object localeSpan = null;
if (existingLocaleSpansToBeMerged.isEmpty()) {
// If there is no LocaleSpan that is marked as to be merged, create a new one.
localeSpan = newLocaleSpan(locale);
} else {
// Reuse the first LocaleSpan to avoid unnecessary object instantiation.
localeSpan = existingLocaleSpansToBeMerged.get(0);
originalLocaleSpanFlag = spannable.getSpanFlags(localeSpan);
// No need to keep other instances.
for (int i = 1; i < existingLocaleSpansToBeMerged.size(); ++i) {
spannable.removeSpan(existingLocaleSpansToBeMerged.get(i));
}
}
final int localeSpanFlag = getSpanFlag(originalLocaleSpanFlag, isStartExclusive,
isEndExclusive);
spannable.setSpan(localeSpan, newStart, newEnd, localeSpanFlag);
}
private static void removeLocaleSpanFromRange(final Object localeSpan,
final Spannable spannable, final int removeStart, final int removeEnd) {
if (!isLocaleSpanAvailable()) {
return;
}
final int spanStart = spannable.getSpanStart(localeSpan);
final int spanEnd = spannable.getSpanEnd(localeSpan);
if (spanStart > spanEnd) {
Log.e(TAG, "Invalid span: spanStart=" + spanStart + " spanEnd=" + spanEnd);
return;
}
if (spanEnd < removeStart) {
// spanStart < spanEnd < removeStart < removeEnd
return;
}
if (removeEnd < spanStart) {
// spanStart < removeEnd < spanStart < spanEnd
return;
}
final int spanFlags = spannable.getSpanFlags(localeSpan);
if (spanStart < removeStart) {
if (removeEnd < spanEnd) {
// spanStart < removeStart < removeEnd < spanEnd
final Locale locale = getLocaleFromLocaleSpan(localeSpan);
spannable.setSpan(localeSpan, spanStart, removeStart, spanFlags);
final Object attionalLocaleSpan = newLocaleSpan(locale);
spannable.setSpan(attionalLocaleSpan, removeEnd, spanEnd, spanFlags);
return;
}
// spanStart < removeStart < spanEnd <= removeEnd
spannable.setSpan(localeSpan, spanStart, removeStart, spanFlags);
return;
}
if (removeEnd < spanEnd) {
// removeStart <= spanStart < removeEnd < spanEnd
spannable.setSpan(localeSpan, removeEnd, spanEnd, spanFlags);
return;
}
// removeStart <= spanStart < spanEnd < removeEnd
spannable.removeSpan(localeSpan);
}
private static int getSpanFlag(final int originalFlag,
final boolean isStartExclusive, final boolean isEndExclusive) {
return (originalFlag & ~Spanned.SPAN_POINT_MARK_MASK) |
getSpanPointMarkFlag(isStartExclusive, isEndExclusive);
}
private static int getSpanPointMarkFlag(final boolean isStartExclusive,
final boolean isEndExclusive) {
if (isStartExclusive) {
return isEndExclusive ? Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
: Spanned.SPAN_EXCLUSIVE_INCLUSIVE;
}
return isEndExclusive ? Spanned.SPAN_INCLUSIVE_EXCLUSIVE
: Spanned.SPAN_INCLUSIVE_INCLUSIVE;
}
}

View file

@ -0,0 +1,42 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.os.Looper;
import java.lang.reflect.Method;
/**
* Helper to call Looper#quitSafely, which was introduced in API
* level 18 (Build.VERSION_CODES.JELLY_BEAN_MR2).
*
* In unit tests, we create lots of instances of LatinIME, which means we need to clean up
* some Loopers lest we leak file descriptors. In normal use on a device though, this is never
* necessary (although it does not hurt).
*/
public final class LooperCompatUtils {
private static final Method METHOD_quitSafely = CompatUtils.getMethod(
Looper.class, "quitSafely");
public static void quitSafely(final Looper looper) {
if (null != METHOD_quitSafely) {
CompatUtils.invoke(looper, null /* default return value */, METHOD_quitSafely);
} else {
looper.quit();
}
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.app.Notification;
import android.os.Build;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
public class NotificationCompatUtils {
// Note that TextInfo.getCharSequence() is supposed to be available in API level 21 and later.
private static final Method METHOD_setColor =
CompatUtils.getMethod(Notification.Builder.class, "setColor", int.class);
private static final Method METHOD_setVisibility =
CompatUtils.getMethod(Notification.Builder.class, "setVisibility", int.class);
private static final Method METHOD_setCategory =
CompatUtils.getMethod(Notification.Builder.class, "setCategory", String.class);
private static final Method METHOD_setPriority =
CompatUtils.getMethod(Notification.Builder.class, "setPriority", int.class);
private static final Method METHOD_build =
CompatUtils.getMethod(Notification.Builder.class, "build");
private static final Field FIELD_VISIBILITY_SECRET =
CompatUtils.getField(Notification.class, "VISIBILITY_SECRET");
private static final int VISIBILITY_SECRET = null == FIELD_VISIBILITY_SECRET ? 0
: (Integer) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */,
FIELD_VISIBILITY_SECRET);
private static final Field FIELD_CATEGORY_RECOMMENDATION =
CompatUtils.getField(Notification.class, "CATEGORY_RECOMMENDATION");
private static final String CATEGORY_RECOMMENDATION = null == FIELD_CATEGORY_RECOMMENDATION ? ""
: (String) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */,
FIELD_CATEGORY_RECOMMENDATION);
private static final Field FIELD_PRIORITY_LOW =
CompatUtils.getField(Notification.class, "PRIORITY_LOW");
private static final int PRIORITY_LOW = null == FIELD_PRIORITY_LOW ? 0
: (Integer) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */,
FIELD_PRIORITY_LOW);
private NotificationCompatUtils() {
// This class is non-instantiable.
}
// Sets the accent color
public static void setColor(final Notification.Builder builder, final int color) {
CompatUtils.invoke(builder, null, METHOD_setColor, color);
}
public static void setVisibilityToSecret(final Notification.Builder builder) {
CompatUtils.invoke(builder, null, METHOD_setVisibility, VISIBILITY_SECRET);
}
public static void setCategoryToRecommendation(final Notification.Builder builder) {
CompatUtils.invoke(builder, null, METHOD_setCategory, CATEGORY_RECOMMENDATION);
}
public static void setPriorityToLow(final Notification.Builder builder) {
CompatUtils.invoke(builder, null, METHOD_setPriority, PRIORITY_LOW);
}
@SuppressWarnings("deprecation")
public static Notification build(final Notification.Builder builder) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
// #build was added in API level 16, JELLY_BEAN
return (Notification) CompatUtils.invoke(builder, null, METHOD_build);
}
// #getNotification was deprecated in API level 16, JELLY_BEAN
return builder.getNotification();
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import java.lang.reflect.Field;
public final class SettingsSecureCompatUtils {
// Note that Settings.Secure.ACCESSIBILITY_SPEAK_PASSWORD has been introduced
// in API level 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1).
private static final Field FIELD_ACCESSIBILITY_SPEAK_PASSWORD = CompatUtils.getField(
android.provider.Settings.Secure.class, "ACCESSIBILITY_SPEAK_PASSWORD");
private SettingsSecureCompatUtils() {
// This class is non-instantiable.
}
/**
* Whether to speak passwords while in accessibility mode.
*/
public static final String ACCESSIBILITY_SPEAK_PASSWORD = (String) CompatUtils.getFieldValue(
null /* receiver */, null /* defaultValue */, FIELD_ACCESSIBILITY_SPEAK_PASSWORD);
}

View file

@ -0,0 +1,121 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.content.Context;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.SuggestionSpan;
import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
import org.dslul.openboard.inputmethod.latin.SuggestedWords;
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import org.dslul.openboard.inputmethod.latin.define.DebugFlags;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Locale;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
public final class SuggestionSpanUtils {
// Note that SuggestionSpan.FLAG_AUTO_CORRECTION has been introduced
// in API level 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1).
private static final Field FIELD_FLAG_AUTO_CORRECTION = CompatUtils.getField(
SuggestionSpan.class, "FLAG_AUTO_CORRECTION");
private static final Integer OBJ_FLAG_AUTO_CORRECTION = (Integer) CompatUtils.getFieldValue(
null /* receiver */, null /* defaultValue */, FIELD_FLAG_AUTO_CORRECTION);
static {
if (DebugFlags.DEBUG_ENABLED) {
if (OBJ_FLAG_AUTO_CORRECTION == null) {
throw new RuntimeException("Field is accidentially null.");
}
}
}
private SuggestionSpanUtils() {
// This utility class is not publicly instantiable.
}
@UsedForTesting
public static CharSequence getTextWithAutoCorrectionIndicatorUnderline(
final Context context, final String text, @Nonnull final Locale locale) {
if (TextUtils.isEmpty(text) || OBJ_FLAG_AUTO_CORRECTION == null) {
return text;
}
final Spannable spannable = new SpannableString(text);
final SuggestionSpan suggestionSpan = new SuggestionSpan(context, locale,
new String[] {} /* suggestions */, OBJ_FLAG_AUTO_CORRECTION, null);
spannable.setSpan(suggestionSpan, 0, text.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE | Spanned.SPAN_COMPOSING);
return spannable;
}
@UsedForTesting
public static CharSequence getTextWithSuggestionSpan(final Context context,
final String pickedWord, final SuggestedWords suggestedWords, final Locale locale) {
if (TextUtils.isEmpty(pickedWord) || suggestedWords.isEmpty()
|| suggestedWords.isPrediction() || suggestedWords.isPunctuationSuggestions()) {
return pickedWord;
}
final ArrayList<String> suggestionsList = new ArrayList<>();
for (int i = 0; i < suggestedWords.size(); ++i) {
if (suggestionsList.size() >= SuggestionSpan.SUGGESTIONS_MAX_SIZE) {
break;
}
final SuggestedWordInfo info = suggestedWords.getInfo(i);
if (info.isKindOf(SuggestedWordInfo.KIND_PREDICTION)) {
continue;
}
final String word = suggestedWords.getWord(i);
if (!TextUtils.equals(pickedWord, word)) {
suggestionsList.add(word.toString());
}
}
final SuggestionSpan suggestionSpan = new SuggestionSpan(context, locale,
suggestionsList.toArray(new String[suggestionsList.size()]), 0 /* flags */, null);
final Spannable spannable = new SpannableString(pickedWord);
spannable.setSpan(suggestionSpan, 0, pickedWord.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return spannable;
}
/**
* Returns first {@link Locale} found in the given array of {@link SuggestionSpan}.
* @param suggestionSpans the array of {@link SuggestionSpan} to be examined.
* @return the first {@link Locale} found in {@code suggestionSpans}. {@code null} when not
* found.
*/
@UsedForTesting
@Nullable
public static Locale findFirstLocaleFromSuggestionSpans(
final SuggestionSpan[] suggestionSpans) {
for (final SuggestionSpan suggestionSpan : suggestionSpans) {
final String localeString = suggestionSpan.getLocale();
if (TextUtils.isEmpty(localeString)) {
continue;
}
return LocaleUtils.constructLocaleFromString(localeString);
}
return null;
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.view.textservice.SuggestionsInfo;
import java.lang.reflect.Field;
public final class SuggestionsInfoCompatUtils {
// Note that SuggestionsInfo.RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS has been introduced
// in API level 15 (Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1).
private static final Field FIELD_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS =
CompatUtils.getField(SuggestionsInfo.class, "RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS");
private static final Integer OBJ_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS =
(Integer) CompatUtils.getFieldValue(null /* receiver */, null /* defaultValue */,
FIELD_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS);
private static final int RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS =
OBJ_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS != null
? OBJ_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS : 0;
private SuggestionsInfoCompatUtils() {
// This utility class is not publicly instantiable.
}
/**
* Returns the flag value of the attributes of the suggestions that can be obtained by
* {@link SuggestionsInfo#getSuggestionsAttributes()}: this tells that the text service thinks
* the result suggestions include highly recommended ones.
*/
public static int getValueOf_RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS() {
return RESULT_ATTR_HAS_RECOMMENDED_SUGGESTIONS;
}
}

View file

@ -0,0 +1,67 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.view.textservice.TextInfo;
import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
@UsedForTesting
public final class TextInfoCompatUtils {
// Note that TextInfo.getCharSequence() is supposed to be available in API level 21 and later.
private static final Method TEXT_INFO_GET_CHAR_SEQUENCE =
CompatUtils.getMethod(TextInfo.class, "getCharSequence");
private static final Constructor<?> TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE =
CompatUtils.getConstructor(TextInfo.class, CharSequence.class, int.class, int.class,
int.class, int.class);
@UsedForTesting
public static boolean isCharSequenceSupported() {
return TEXT_INFO_GET_CHAR_SEQUENCE != null &&
TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE != null;
}
@UsedForTesting
public static TextInfo newInstance(CharSequence charSequence, int start, int end, int cookie,
int sequenceNumber) {
if (TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE != null) {
return (TextInfo) CompatUtils.newInstance(TEXT_INFO_CONSTRUCTOR_FOR_CHAR_SEQUENCE,
charSequence, start, end, cookie, sequenceNumber);
}
return new TextInfo(charSequence.subSequence(start, end).toString(), cookie,
sequenceNumber);
}
/**
* Returns the result of {@link TextInfo#getCharSequence()} when available. Otherwise returns
* the result of {@link TextInfo#getText()} as fall back.
* @param textInfo the instance for which {@link TextInfo#getCharSequence()} or
* {@link TextInfo#getText()} is called.
* @return the result of {@link TextInfo#getCharSequence()} when available. Otherwise returns
* the result of {@link TextInfo#getText()} as fall back. If {@code textInfo} is {@code null},
* returns {@code null}.
*/
@UsedForTesting
public static CharSequence getCharSequenceOrString(final TextInfo textInfo) {
final CharSequence defaultValue = (textInfo == null ? null : textInfo.getText());
return (CharSequence) CompatUtils.invoke(textInfo, defaultValue,
TEXT_INFO_GET_CHAR_SEQUENCE);
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.graphics.drawable.Drawable;
import android.widget.TextView;
import java.lang.reflect.Method;
public final class TextViewCompatUtils {
// Note that TextView.setCompoundDrawablesRelativeWithIntrinsicBounds(Drawable,Drawable,
// Drawable,Drawable) has been introduced in API level 17 (Build.VERSION_CODE.JELLY_BEAN_MR1).
private static final Method METHOD_setCompoundDrawablesRelativeWithIntrinsicBounds =
CompatUtils.getMethod(TextView.class, "setCompoundDrawablesRelativeWithIntrinsicBounds",
Drawable.class, Drawable.class, Drawable.class, Drawable.class);
private TextViewCompatUtils() {
// This utility class is not publicly instantiable.
}
public static void setCompoundDrawablesRelativeWithIntrinsicBounds(final TextView textView,
final Drawable start, final Drawable top, final Drawable end, final Drawable bottom) {
if (METHOD_setCompoundDrawablesRelativeWithIntrinsicBounds == null) {
textView.setCompoundDrawablesWithIntrinsicBounds(start, top, end, bottom);
return;
}
CompatUtils.invoke(textView, null, METHOD_setCompoundDrawablesRelativeWithIntrinsicBounds,
start, top, end, bottom);
}
}

View file

@ -0,0 +1,49 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.provider.UserDictionary;
import java.util.Locale;
public final class UserDictionaryCompatUtils {
@SuppressWarnings("deprecation")
public static void addWord(final Context context, final String word,
final int freq, final String shortcut, final Locale locale) {
if (BuildCompatUtils.EFFECTIVE_SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
addWordWithShortcut(context, word, freq, shortcut, locale);
return;
}
// Fall back to the pre-JellyBean method.
final Locale currentLocale = context.getResources().getConfiguration().locale;
final int localeType = currentLocale.equals(locale)
? UserDictionary.Words.LOCALE_TYPE_CURRENT : UserDictionary.Words.LOCALE_TYPE_ALL;
UserDictionary.Words.addWord(context, word, freq, localeType);
}
// {@link UserDictionary.Words#addWord(Context,String,int,String,Locale)} was introduced
// in API level 16 (Build.VERSION_CODES.JELLY_BEAN).
@TargetApi(Build.VERSION_CODES.JELLY_BEAN)
private static void addWordWithShortcut(final Context context, final String word,
final int freq, final String shortcut, final Locale locale) {
UserDictionary.Words.addWord(context, word, freq, shortcut, locale);
}
}

View file

@ -0,0 +1,80 @@
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.content.Context;
import android.os.Build;
import android.os.UserManager;
import androidx.annotation.IntDef;
import java.lang.annotation.Retention;
import java.lang.reflect.Method;
import static java.lang.annotation.RetentionPolicy.SOURCE;
/**
* A temporary solution until {@code UserManagerCompat.isUserUnlocked()} in the support-v4 library
* becomes publicly available.
*/
public final class UserManagerCompatUtils {
private static final Method METHOD_isUserUnlocked;
static {
// We do not try to search the method in Android M and prior.
if (BuildCompatUtils.EFFECTIVE_SDK_INT <= Build.VERSION_CODES.M) {
METHOD_isUserUnlocked = null;
} else {
METHOD_isUserUnlocked = CompatUtils.getMethod(UserManager.class, "isUserUnlocked");
}
}
private UserManagerCompatUtils() {
// This utility class is not publicly instantiable.
}
public static final int LOCK_STATE_UNKNOWN = 0;
public static final int LOCK_STATE_UNLOCKED = 1;
public static final int LOCK_STATE_LOCKED = 2;
@Retention(SOURCE)
@IntDef({LOCK_STATE_UNKNOWN, LOCK_STATE_UNLOCKED, LOCK_STATE_LOCKED})
public @interface LockState {}
/**
* Check if the calling user is running in an "unlocked" state. A user is unlocked only after
* they've entered their credentials (such as a lock pattern or PIN), and credential-encrypted
* private app data storage is available.
* @param context context from which {@link UserManager} should be obtained.
* @return One of {@link LockState}.
*/
@LockState
public static int getUserLockState(final Context context) {
if (METHOD_isUserUnlocked == null) {
return LOCK_STATE_UNKNOWN;
}
final UserManager userManager = context.getSystemService(UserManager.class);
if (userManager == null) {
return LOCK_STATE_UNKNOWN;
}
final Boolean result =
(Boolean) CompatUtils.invoke(userManager, null, METHOD_isUserUnlocked);
if (result == null) {
return LOCK_STATE_UNKNOWN;
}
return result ? LOCK_STATE_UNLOCKED : LOCK_STATE_LOCKED;
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.view.View;
import java.lang.reflect.Method;
// TODO: Use {@link androidx.core.view.ViewCompat} instead of this utility class.
// Currently {@link #getPaddingEnd(View)} and {@link #setPaddingRelative(View,int,int,int,int)}
// are missing from android-support-v4 static library in KitKat SDK.
public final class ViewCompatUtils {
// Note that View.getPaddingEnd(), View.setPaddingRelative(int,int,int,int) have been
// introduced in API level 17 (Build.VERSION_CODE.JELLY_BEAN_MR1).
private static final Method METHOD_getPaddingEnd = CompatUtils.getMethod(
View.class, "getPaddingEnd");
private static final Method METHOD_setPaddingRelative = CompatUtils.getMethod(
View.class, "setPaddingRelative",
int.class, int.class, int.class, int.class);
// Note that View.setTextAlignment(int) has been introduced in API level 17.
private static final Method METHOD_setTextAlignment = CompatUtils.getMethod(
View.class, "setTextAlignment", int.class);
private ViewCompatUtils() {
// This utility class is not publicly instantiable.
}
public static int getPaddingEnd(final View view) {
if (METHOD_getPaddingEnd == null) {
return view.getPaddingRight();
}
return (Integer)CompatUtils.invoke(view, 0, METHOD_getPaddingEnd);
}
public static void setPaddingRelative(final View view, final int start, final int top,
final int end, final int bottom) {
if (METHOD_setPaddingRelative == null) {
view.setPadding(start, top, end, bottom);
return;
}
CompatUtils.invoke(view, null, METHOD_setPaddingRelative, start, top, end, bottom);
}
// These TEXT_ALIGNMENT_* constants have been introduced in API 17.
public static final int TEXT_ALIGNMENT_INHERIT = 0;
public static final int TEXT_ALIGNMENT_GRAVITY = 1;
public static final int TEXT_ALIGNMENT_TEXT_START = 2;
public static final int TEXT_ALIGNMENT_TEXT_END = 3;
public static final int TEXT_ALIGNMENT_CENTER = 4;
public static final int TEXT_ALIGNMENT_VIEW_START = 5;
public static final int TEXT_ALIGNMENT_VIEW_END = 6;
public static void setTextAlignment(final View view, final int textAlignment) {
CompatUtils.invoke(view, null, METHOD_setTextAlignment, textAlignment);
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.inputmethodservice.InputMethodService;
import android.os.Build;
import android.view.View;
public class ViewOutlineProviderCompatUtils {
private ViewOutlineProviderCompatUtils() {
// This utility class is not publicly instantiable.
}
public interface InsetsUpdater {
public void setInsets(final InputMethodService.Insets insets);
}
private static final InsetsUpdater EMPTY_INSETS_UPDATER = new InsetsUpdater() {
@Override
public void setInsets(final InputMethodService.Insets insets) {}
};
public static InsetsUpdater setInsetsOutlineProvider(final View view) {
if (BuildCompatUtils.EFFECTIVE_SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
return EMPTY_INSETS_UPDATER;
}
return ViewOutlineProviderCompatUtilsLXX.setInsetsOutlineProvider(view);
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.compat;
import android.annotation.TargetApi;
import android.graphics.Outline;
import android.inputmethodservice.InputMethodService;
import android.os.Build;
import android.view.View;
import android.view.ViewOutlineProvider;
import org.dslul.openboard.inputmethod.compat.ViewOutlineProviderCompatUtils.InsetsUpdater;
@TargetApi(Build.VERSION_CODES.LOLLIPOP)
class ViewOutlineProviderCompatUtilsLXX {
private ViewOutlineProviderCompatUtilsLXX() {
// This utility class is not publicly instantiable.
}
static InsetsUpdater setInsetsOutlineProvider(final View view) {
final InsetsOutlineProvider provider = new InsetsOutlineProvider(view);
view.setOutlineProvider(provider);
return provider;
}
private static class InsetsOutlineProvider extends ViewOutlineProvider
implements InsetsUpdater {
private final View mView;
private static final int NO_DATA = -1;
private int mLastVisibleTopInsets = NO_DATA;
public InsetsOutlineProvider(final View view) {
mView = view;
view.setOutlineProvider(this);
}
@Override
public void setInsets(final InputMethodService.Insets insets) {
final int visibleTopInsets = insets.visibleTopInsets;
if (mLastVisibleTopInsets != visibleTopInsets) {
mLastVisibleTopInsets = visibleTopInsets;
mView.invalidateOutline();
}
}
@Override
public void getOutline(final View view, final Outline outline) {
if (mLastVisibleTopInsets == NO_DATA) {
// Call default implementation.
ViewOutlineProvider.BACKGROUND.getOutline(view, outline);
return;
}
// TODO: Revisit this when floating/resize keyboard is supported.
outline.setRect(
view.getLeft(), mLastVisibleTopInsets, view.getRight(), view.getBottom());
}
}
}

View file

@ -0,0 +1,625 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.app.DownloadManager.Request;
import android.content.ContentValues;
import android.content.Context;
import android.content.res.Resources;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import org.dslul.openboard.inputmethod.latin.BinaryDictionaryFileDumper;
import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import org.dslul.openboard.inputmethod.latin.utils.ApplicationUtils;
import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils;
import java.util.LinkedList;
import java.util.Queue;
/**
* Object representing an upgrade from one state to another.
*
* This implementation basically encapsulates a list of Runnable objects. In the future
* it may manage dependencies between them. Concretely, it does not use Runnable because the
* actions need an argument.
*/
/*
The state of a word list follows the following scheme.
| ^
MakeAvailable |
| .------------Forget--------'
V |
STATUS_AVAILABLE <-------------------------.
| |
StartDownloadAction FinishDeleteAction
| |
V |
STATUS_DOWNLOADING EnableAction-- STATUS_DELETING
| | ^
InstallAfterDownloadAction | |
| .---------------' StartDeleteAction
| | |
V V |
STATUS_INSTALLED <--EnableAction-- STATUS_DISABLED
--DisableAction-->
It may also be possible that DisableAction or StartDeleteAction or
DownloadAction run when the file is still downloading. This cancels
the download and returns to STATUS_AVAILABLE.
Also, an UpdateDataAction may apply in any state. It does not affect
the state in any way (nor type, local filename, id or version) but
may update other attributes like description or remote filename.
Forget is an DB maintenance action that removes the entry if it is not installed or disabled.
This happens when the word list information disappeared from the server, or when a new version
is available and we should forget about the old one.
*/
public final class ActionBatch {
/**
* A piece of update.
*
* Action is basically like a Runnable that takes an argument.
*/
public interface Action {
/**
* Execute this action NOW.
* @param context the context to get system services, resources, databases
*/
void execute(final Context context);
}
/**
* An action that starts downloading an available word list.
*/
public static final class StartDownloadAction implements Action {
static final String TAG = "DictionaryProvider:" + StartDownloadAction.class.getSimpleName();
private final String mClientId;
// The data to download. May not be null.
final WordListMetadata mWordList;
public StartDownloadAction(final String clientId, final WordListMetadata wordList) {
DebugLogUtils.l("New download action for client ", clientId, " : ", wordList);
mClientId = clientId;
mWordList = wordList;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "UpdateAction with a null parameter!");
return;
}
DebugLogUtils.l("Downloading word list");
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion);
final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
final DownloadManagerWrapper manager = new DownloadManagerWrapper(context);
if (MetadataDbHelper.STATUS_DOWNLOADING == status) {
// The word list is still downloading. Cancel the download and revert the
// word list status to "available".
manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN));
MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
} else if (MetadataDbHelper.STATUS_AVAILABLE != status
&& MetadataDbHelper.STATUS_RETRYING != status) {
// Should never happen
Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : " + status
+ " for an upgrade action. Fall back to download.");
}
// Download it.
DebugLogUtils.l("Upgrade word list, downloading", mWordList.mRemoteFilename);
// This is an upgraded word list: we should download it.
// Adding a disambiguator to circumvent a bug in older versions of DownloadManager.
// DownloadManager also stupidly cuts the extension to replace with its own that it
// gets from the content-type. We need to circumvent this.
final String disambiguator = "#" + System.currentTimeMillis()
+ ApplicationUtils.getVersionName(context) + ".dict";
final Uri uri = Uri.parse(mWordList.mRemoteFilename + disambiguator);
final Request request = new Request(uri);
final Resources res = context.getResources();
request.setAllowedNetworkTypes(Request.NETWORK_WIFI | Request.NETWORK_MOBILE);
request.setTitle(mWordList.mDescription);
request.setNotificationVisibility(Request.VISIBILITY_HIDDEN);
request.setVisibleInDownloadsUi(
res.getBoolean(R.bool.dict_downloads_visible_in_download_UI));
final long downloadId = UpdateHandler.registerDownloadRequest(manager, request, db,
mWordList.mId, mWordList.mVersion);
Log.i(TAG, String.format("Starting the dictionary download with version:"
+ " %d and Url: %s", mWordList.mVersion, uri));
DebugLogUtils.l("Starting download of", uri, "with id", downloadId);
PrivateLog.log("Starting download of " + uri + ", id : " + downloadId);
}
}
/**
* An action that updates the database to reflect the status of a newly installed word list.
*/
public static final class InstallAfterDownloadAction implements Action {
static final String TAG = "DictionaryProvider:"
+ InstallAfterDownloadAction.class.getSimpleName();
private final String mClientId;
// The state to upgrade from. May not be null.
final ContentValues mWordListValues;
public InstallAfterDownloadAction(final String clientId,
final ContentValues wordListValues) {
DebugLogUtils.l("New InstallAfterDownloadAction for client ", clientId, " : ",
wordListValues);
mClientId = clientId;
mWordListValues = wordListValues;
}
@Override
public void execute(final Context context) {
if (null == mWordListValues) {
Log.e(TAG, "InstallAfterDownloadAction with a null parameter!");
return;
}
final int status = mWordListValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
if (MetadataDbHelper.STATUS_DOWNLOADING != status) {
final String id = mWordListValues.getAsString(MetadataDbHelper.WORDLISTID_COLUMN);
Log.e(TAG, "Unexpected state of the word list '" + id + "' : " + status
+ " for an InstallAfterDownload action. Bailing out.");
return;
}
DebugLogUtils.l("Setting word list as installed");
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
MetadataDbHelper.markEntryAsFinishedDownloadingAndInstalled(db, mWordListValues);
// Install the downloaded file by un-compressing and moving it to the staging
// directory. Ideally, we should do this before updating the DB, but the
// installDictToStagingFromContentProvider() relies on the db being updated.
final String localeString = mWordListValues.getAsString(MetadataDbHelper.LOCALE_COLUMN);
BinaryDictionaryFileDumper.installDictToStagingFromContentProvider(
LocaleUtils.constructLocaleFromString(localeString), context, false);
}
}
/**
* An action that enables an existing word list.
*/
public static final class EnableAction implements Action {
static final String TAG = "DictionaryProvider:" + EnableAction.class.getSimpleName();
private final String mClientId;
// The state to upgrade from. May not be null.
final WordListMetadata mWordList;
public EnableAction(final String clientId, final WordListMetadata wordList) {
DebugLogUtils.l("New EnableAction for client ", clientId, " : ", wordList);
mClientId = clientId;
mWordList = wordList;
}
@Override
public void execute(final Context context) {
if (null == mWordList) {
Log.e(TAG, "EnableAction with a null parameter!");
return;
}
DebugLogUtils.l("Enabling word list");
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion);
final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
if (MetadataDbHelper.STATUS_DISABLED != status
&& MetadataDbHelper.STATUS_DELETING != status) {
Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + " : " + status
+ " for an enable action. Cancelling");
return;
}
MetadataDbHelper.markEntryAsEnabled(db, mWordList.mId, mWordList.mVersion);
}
}
/**
* An action that disables a word list.
*/
public static final class DisableAction implements Action {
static final String TAG = "DictionaryProvider:" + DisableAction.class.getSimpleName();
private final String mClientId;
// The word list to disable. May not be null.
final WordListMetadata mWordList;
public DisableAction(final String clientId, final WordListMetadata wordlist) {
DebugLogUtils.l("New Disable action for client ", clientId, " : ", wordlist);
mClientId = clientId;
mWordList = wordlist;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "DisableAction with a null word list!");
return;
}
DebugLogUtils.l("Disabling word list : " + mWordList);
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion);
final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
if (MetadataDbHelper.STATUS_INSTALLED == status) {
// Disabling an installed word list
MetadataDbHelper.markEntryAsDisabled(db, mWordList.mId, mWordList.mVersion);
} else {
if (MetadataDbHelper.STATUS_DOWNLOADING != status) {
Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' : "
+ status + " for a disable action. Fall back to marking as available.");
}
// The word list is still downloading. Cancel the download and revert the
// word list status to "available".
final DownloadManagerWrapper manager = new DownloadManagerWrapper(context);
manager.remove(values.getAsLong(MetadataDbHelper.PENDINGID_COLUMN));
MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
}
}
}
/**
* An action that makes a word list available.
*/
public static final class MakeAvailableAction implements Action {
static final String TAG = "DictionaryProvider:" + MakeAvailableAction.class.getSimpleName();
private final String mClientId;
// The word list to make available. May not be null.
final WordListMetadata mWordList;
public MakeAvailableAction(final String clientId, final WordListMetadata wordlist) {
DebugLogUtils.l("New MakeAvailable action", clientId, " : ", wordlist);
mClientId = clientId;
mWordList = wordlist;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "MakeAvailableAction with a null word list!");
return;
}
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
if (null != MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion)) {
Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' "
+ " for a makeavailable action. Marking as available anyway.");
}
DebugLogUtils.l("Making word list available : " + mWordList);
// If mLocalFilename is null, then it's a remote file that hasn't been downloaded
// yet, so we set the local filename to the empty string.
final ContentValues values = MetadataDbHelper.makeContentValues(0,
MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_AVAILABLE,
mWordList.mId, mWordList.mLocale, mWordList.mDescription,
null == mWordList.mLocalFilename ? "" : mWordList.mLocalFilename,
mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum,
mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize,
mWordList.mVersion, mWordList.mFormatVersion);
PrivateLog.log("Insert 'available' record for " + mWordList.mDescription
+ " and locale " + mWordList.mLocale);
db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values);
}
}
/**
* An action that marks a word list as pre-installed.
*
* This is almost the same as MakeAvailableAction, as it only inserts a line with parameters
* received from outside.
* Unlike MakeAvailableAction, the parameters are not received from a downloaded metadata file
* but from the client directly; it marks a word list as being "installed" and not "available".
* It also explicitly sets the filename to the empty string, so that we don't try to open
* it on our side.
*/
public static final class MarkPreInstalledAction implements Action {
static final String TAG = "DictionaryProvider:"
+ MarkPreInstalledAction.class.getSimpleName();
private final String mClientId;
// The word list to mark pre-installed. May not be null.
final WordListMetadata mWordList;
public MarkPreInstalledAction(final String clientId, final WordListMetadata wordlist) {
DebugLogUtils.l("New MarkPreInstalled action", clientId, " : ", wordlist);
mClientId = clientId;
mWordList = wordlist;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "MarkPreInstalledAction with a null word list!");
return;
}
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
if (null != MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion)) {
Log.e(TAG, "Unexpected state of the word list '" + mWordList.mId + "' "
+ " for a markpreinstalled action. Marking as preinstalled anyway.");
}
DebugLogUtils.l("Marking word list preinstalled : " + mWordList);
// This word list is pre-installed : we don't have its file. We should reset
// the local file name to the empty string so that we don't try to open it
// accidentally. The remote filename may be set by the application if it so wishes.
final ContentValues values = MetadataDbHelper.makeContentValues(0,
MetadataDbHelper.TYPE_BULK, MetadataDbHelper.STATUS_INSTALLED,
mWordList.mId, mWordList.mLocale, mWordList.mDescription,
TextUtils.isEmpty(mWordList.mLocalFilename) ? "" : mWordList.mLocalFilename,
mWordList.mRemoteFilename, mWordList.mLastUpdate,
mWordList.mRawChecksum, mWordList.mChecksum, mWordList.mRetryCount,
mWordList.mFileSize, mWordList.mVersion, mWordList.mFormatVersion);
PrivateLog.log("Insert 'preinstalled' record for " + mWordList.mDescription
+ " and locale " + mWordList.mLocale);
db.insert(MetadataDbHelper.METADATA_TABLE_NAME, null, values);
}
}
/**
* An action that updates information about a word list - description, locale etc
*/
public static final class UpdateDataAction implements Action {
static final String TAG = "DictionaryProvider:" + UpdateDataAction.class.getSimpleName();
private final String mClientId;
final WordListMetadata mWordList;
public UpdateDataAction(final String clientId, final WordListMetadata wordlist) {
DebugLogUtils.l("New UpdateData action for client ", clientId, " : ", wordlist);
mClientId = clientId;
mWordList = wordlist;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "UpdateDataAction with a null word list!");
return;
}
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
ContentValues oldValues = MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion);
if (null == oldValues) {
Log.e(TAG, "Trying to update data about a non-existing word list. Bailing out.");
return;
}
DebugLogUtils.l("Updating data about a word list : " + mWordList);
final ContentValues values = MetadataDbHelper.makeContentValues(
oldValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN),
oldValues.getAsInteger(MetadataDbHelper.TYPE_COLUMN),
oldValues.getAsInteger(MetadataDbHelper.STATUS_COLUMN),
mWordList.mId, mWordList.mLocale, mWordList.mDescription,
oldValues.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN),
mWordList.mRemoteFilename, mWordList.mLastUpdate, mWordList.mRawChecksum,
mWordList.mChecksum, mWordList.mRetryCount, mWordList.mFileSize,
mWordList.mVersion, mWordList.mFormatVersion);
PrivateLog.log("Updating record for " + mWordList.mDescription
+ " and locale " + mWordList.mLocale);
db.update(MetadataDbHelper.METADATA_TABLE_NAME, values,
MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ MetadataDbHelper.VERSION_COLUMN + " = ?",
new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
}
}
/**
* An action that deletes the metadata about a word list if possible.
*
* This is triggered when a specific word list disappeared from the server, or when a fresher
* word list is available and the old one was not installed.
* If the word list has not been installed, it's possible to delete its associated metadata.
* Otherwise, the settings are retained so that the user can still administrate it.
*/
public static final class ForgetAction implements Action {
static final String TAG = "DictionaryProvider:" + ForgetAction.class.getSimpleName();
private final String mClientId;
// The word list to remove. May not be null.
final WordListMetadata mWordList;
final boolean mHasNewerVersion;
public ForgetAction(final String clientId, final WordListMetadata wordlist,
final boolean hasNewerVersion) {
DebugLogUtils.l("New TryRemove action for client ", clientId, " : ", wordlist);
mClientId = clientId;
mWordList = wordlist;
mHasNewerVersion = hasNewerVersion;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "TryRemoveAction with a null word list!");
return;
}
DebugLogUtils.l("Trying to remove word list : " + mWordList);
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion);
if (null == values) {
Log.e(TAG, "Trying to update the metadata of a non-existing wordlist. Cancelling.");
return;
}
final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
if (mHasNewerVersion && MetadataDbHelper.STATUS_AVAILABLE != status) {
// If we have a newer version of this word list, we should be here ONLY if it was
// not installed - else we should be upgrading it.
Log.e(TAG, "Unexpected status for forgetting a word list info : " + status
+ ", removing URL to prevent re-download");
}
if (MetadataDbHelper.STATUS_INSTALLED == status
|| MetadataDbHelper.STATUS_DISABLED == status
|| MetadataDbHelper.STATUS_DELETING == status) {
// If it is installed or disabled, we need to mark it as deleted so that LatinIME
// will remove it next time it enquires for dictionaries.
// If it is deleting and we don't have a new version, then we have to wait until
// LatinIME actually has deleted it before we can remove its metadata.
// In both cases, remove the URI from the database since it is not supposed to
// be accessible any more.
values.put(MetadataDbHelper.REMOTE_FILENAME_COLUMN, "");
values.put(MetadataDbHelper.STATUS_COLUMN, MetadataDbHelper.STATUS_DELETING);
db.update(MetadataDbHelper.METADATA_TABLE_NAME, values,
MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ MetadataDbHelper.VERSION_COLUMN + " = ?",
new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
} else {
// If it's AVAILABLE or DOWNLOADING or even UNKNOWN, delete the entry.
db.delete(MetadataDbHelper.METADATA_TABLE_NAME,
MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ MetadataDbHelper.VERSION_COLUMN + " = ?",
new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
}
}
}
/**
* An action that sets the word list for deletion as soon as possible.
*
* This is triggered when the user requests deletion of a word list. This will mark it as
* deleted in the database, and fire an intent for Android Keyboard to take notice and
* reload its dictionaries right away if it is up. If it is not up now, then it will
* delete the actual file the next time it gets up.
* A file marked as deleted causes the content provider to supply a zero-sized file to
* Android Keyboard, which will overwrite any existing file and provide no words for this
* word list. This is not exactly a "deletion", since there is an actual file which takes up
* a few bytes on the disk, but this allows to override a default dictionary with an empty
* dictionary. This way, there is no need for the user to make a distinction between
* dictionaries installed by default and add-on dictionaries.
*/
public static final class StartDeleteAction implements Action {
static final String TAG = "DictionaryProvider:" + StartDeleteAction.class.getSimpleName();
private final String mClientId;
// The word list to delete. May not be null.
final WordListMetadata mWordList;
public StartDeleteAction(final String clientId, final WordListMetadata wordlist) {
DebugLogUtils.l("New StartDelete action for client ", clientId, " : ", wordlist);
mClientId = clientId;
mWordList = wordlist;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "StartDeleteAction with a null word list!");
return;
}
DebugLogUtils.l("Trying to delete word list : " + mWordList);
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion);
if (null == values) {
Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.");
return;
}
final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
if (MetadataDbHelper.STATUS_DISABLED != status) {
Log.e(TAG, "Unexpected status for deleting a word list info : " + status);
}
MetadataDbHelper.markEntryAsDeleting(db, mWordList.mId, mWordList.mVersion);
}
}
/**
* An action that validates a word list as deleted.
*
* This will restore the word list as available if it still is, or remove the entry if
* it is not any more.
*/
public static final class FinishDeleteAction implements Action {
static final String TAG = "DictionaryProvider:" + FinishDeleteAction.class.getSimpleName();
private final String mClientId;
// The word list to delete. May not be null.
final WordListMetadata mWordList;
public FinishDeleteAction(final String clientId, final WordListMetadata wordlist) {
DebugLogUtils.l("New FinishDelete action for client", clientId, " : ", wordlist);
mClientId = clientId;
mWordList = wordlist;
}
@Override
public void execute(final Context context) {
if (null == mWordList) { // This should never happen
Log.e(TAG, "FinishDeleteAction with a null word list!");
return;
}
DebugLogUtils.l("Trying to delete word list : " + mWordList);
final SQLiteDatabase db = MetadataDbHelper.getDb(context, mClientId);
final ContentValues values = MetadataDbHelper.getContentValuesByWordListId(db,
mWordList.mId, mWordList.mVersion);
if (null == values) {
Log.e(TAG, "Trying to set a non-existing wordlist for removal. Cancelling.");
return;
}
final int status = values.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
if (MetadataDbHelper.STATUS_DELETING != status) {
Log.e(TAG, "Unexpected status for finish-deleting a word list info : " + status);
}
final String remoteFilename =
values.getAsString(MetadataDbHelper.REMOTE_FILENAME_COLUMN);
// If there isn't a remote filename any more, then we don't know where to get the file
// from any more, so we remove the entry entirely. As a matter of fact, if the file was
// marked DELETING but disappeared from the metadata on the server, it ended up
// this way.
if (TextUtils.isEmpty(remoteFilename)) {
db.delete(MetadataDbHelper.METADATA_TABLE_NAME,
MetadataDbHelper.WORDLISTID_COLUMN + " = ? AND "
+ MetadataDbHelper.VERSION_COLUMN + " = ?",
new String[] { mWordList.mId, Integer.toString(mWordList.mVersion) });
} else {
MetadataDbHelper.markEntryAsAvailable(db, mWordList.mId, mWordList.mVersion);
}
}
}
// An action batch consists of an ordered queue of Actions that can execute.
private final Queue<Action> mActions;
public ActionBatch() {
mActions = new LinkedList<>();
}
public void add(final Action a) {
mActions.add(a);
}
/**
* Append all the actions of another action batch.
* @param that the upgrade to merge into this one.
*/
public void append(final ActionBatch that) {
for (final Action a : that.mActions) {
add(a);
}
}
/**
* Execute this batch.
*
* @param context the context for getting resources, databases, system services.
* @param reporter a Reporter to send errors to.
*/
public void execute(final Context context, final ProblemReporter reporter) {
DebugLogUtils.l("Executing a batch of actions");
Queue<Action> remainingActions = mActions;
while (!remainingActions.isEmpty()) {
final Action a = remainingActions.poll();
try {
a.execute(context);
} catch (Exception e) {
if (null != reporter)
reporter.report(e);
}
}
}
}

View file

@ -0,0 +1,66 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import java.io.File;
/**
* Immutable class to hold the address of an asset.
* As opposed to a normal file, an asset is usually represented as a contiguous byte array in
* the package file. Open it correctly thus requires the name of the package it is in, but
* also the offset in the file and the length of this data. This class encapsulates these three.
*/
final class AssetFileAddress {
public final String mFilename;
public final long mOffset;
public final long mLength;
public AssetFileAddress(final String filename, final long offset, final long length) {
mFilename = filename;
mOffset = offset;
mLength = length;
}
/**
* Makes an AssetFileAddress. This may return null.
*
* @param filename the filename.
* @return the address, or null if the file does not exist or the parameters are not valid.
*/
public static AssetFileAddress makeFromFileName(final String filename) {
if (null == filename) return null;
final File f = new File(filename);
if (!f.isFile()) return null;
return new AssetFileAddress(filename, 0l, f.length());
}
/**
* Makes an AssetFileAddress. This may return null.
*
* @param filename the filename.
* @param offset the offset.
* @param length the length.
* @return the address, or null if the file does not exist or the parameters are not valid.
*/
public static AssetFileAddress makeFromFileNameAndOffset(final String filename,
final long offset, final long length) {
if (null == filename) return null;
final File f = new File(filename);
if (!f.isFile()) return null;
return new AssetFileAddress(filename, offset, length);
}
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
/**
* Exception thrown when the metadata for the dictionary does not comply to a known format.
*/
public final class BadFormatException extends Exception {
public BadFormatException() {
super();
}
public BadFormatException(final String message) {
super(message);
}
}

View file

@ -0,0 +1,170 @@
/**
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewPropertyAnimator;
import android.widget.Button;
import android.widget.FrameLayout;
import org.dslul.openboard.inputmethod.latin.R;
/**
* A view that handles buttons inside it according to a status.
*/
public class ButtonSwitcher extends FrameLayout {
public static final int NOT_INITIALIZED = -1;
public static final int STATUS_NO_BUTTON = 0;
public static final int STATUS_INSTALL = 1;
public static final int STATUS_CANCEL = 2;
public static final int STATUS_DELETE = 3;
// One of the above
private int mStatus = NOT_INITIALIZED;
private int mAnimateToStatus = NOT_INITIALIZED;
// Animation directions
public static final int ANIMATION_IN = 1;
public static final int ANIMATION_OUT = 2;
private Button mInstallButton;
private Button mCancelButton;
private Button mDeleteButton;
private DictionaryListInterfaceState mInterfaceState;
private OnClickListener mOnClickListener;
public ButtonSwitcher(Context context, AttributeSet attrs) {
super(context, attrs);
}
public ButtonSwitcher(Context context, AttributeSet attrs, int defStyle) {
super(context, attrs, defStyle);
}
public void reset(final DictionaryListInterfaceState interfaceState) {
mStatus = NOT_INITIALIZED;
mAnimateToStatus = NOT_INITIALIZED;
mInterfaceState = interfaceState;
}
@Override
protected void onLayout(final boolean changed, final int left, final int top, final int right,
final int bottom) {
super.onLayout(changed, left, top, right, bottom);
mInstallButton = (Button)findViewById(R.id.dict_install_button);
mCancelButton = (Button)findViewById(R.id.dict_cancel_button);
mDeleteButton = (Button)findViewById(R.id.dict_delete_button);
setInternalOnClickListener(mOnClickListener);
setButtonPositionWithoutAnimation(mStatus);
if (mAnimateToStatus != NOT_INITIALIZED) {
// We have been asked to animate before we were ready, so we took a note of it.
// We are now ready: launch the animation.
animateButtonPosition(mStatus, mAnimateToStatus);
mStatus = mAnimateToStatus;
mAnimateToStatus = NOT_INITIALIZED;
}
}
private Button getButton(final int status) {
switch(status) {
case STATUS_INSTALL:
return mInstallButton;
case STATUS_CANCEL:
return mCancelButton;
case STATUS_DELETE:
return mDeleteButton;
default:
return null;
}
}
public void setStatusAndUpdateVisuals(final int status) {
if (mStatus == NOT_INITIALIZED) {
setButtonPositionWithoutAnimation(status);
mStatus = status;
} else {
if (null == mInstallButton) {
// We may come here before we have been layout. In this case we don't know our
// size yet so we can't start animations so we need to remember what animation to
// start once layout has gone through.
mAnimateToStatus = status;
} else {
animateButtonPosition(mStatus, status);
mStatus = status;
}
}
}
private void setButtonPositionWithoutAnimation(final int status) {
// This may be called by setStatus() before the layout has come yet.
if (null == mInstallButton) return;
final int width = getWidth();
// Set to out of the screen if that's not the currently displayed status
mInstallButton.setTranslationX(STATUS_INSTALL == status ? 0 : width);
mCancelButton.setTranslationX(STATUS_CANCEL == status ? 0 : width);
mDeleteButton.setTranslationX(STATUS_DELETE == status ? 0 : width);
}
// The helper method for {@link AnimatorListenerAdapter}.
void animateButtonIfStatusIsEqual(final View newButton, final int newStatus) {
if (newStatus != mStatus) return;
animateButton(newButton, ANIMATION_IN);
}
private void animateButtonPosition(final int oldStatus, final int newStatus) {
final View oldButton = getButton(oldStatus);
final View newButton = getButton(newStatus);
if (null != oldButton && null != newButton) {
// Transition between two buttons : animate out, then in
animateButton(oldButton, ANIMATION_OUT).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(final Animator animation) {
animateButtonIfStatusIsEqual(newButton, newStatus);
}
});
} else if (null != oldButton) {
animateButton(oldButton, ANIMATION_OUT);
} else if (null != newButton) {
animateButton(newButton, ANIMATION_IN);
}
}
public void setInternalOnClickListener(final OnClickListener listener) {
mOnClickListener = listener;
if (null != mInstallButton) {
// Already laid out : do it now
mInstallButton.setOnClickListener(mOnClickListener);
mCancelButton.setOnClickListener(mOnClickListener);
mDeleteButton.setOnClickListener(mOnClickListener);
}
}
private ViewPropertyAnimator animateButton(final View button, final int direction) {
final float outerX = getWidth();
final float innerX = button.getX() - button.getTranslationX();
mInterfaceState.removeFromCache((View)getParent());
if (ANIMATION_IN == direction) {
button.setClickable(true);
return button.animate().translationX(0);
}
button.setClickable(false);
return button.animate().translationX(outerX - innerX);
}
}

View file

@ -0,0 +1,40 @@
/**
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.content.Context;
import android.content.SharedPreferences;
public final class CommonPreferences {
private static final String COMMON_PREFERENCES_NAME = "LatinImeDictPrefs";
public static SharedPreferences getCommonPreferences(final Context context) {
return context.getSharedPreferences(COMMON_PREFERENCES_NAME, 0);
}
public static void enable(final SharedPreferences pref, final String id) {
final SharedPreferences.Editor editor = pref.edit();
editor.putBoolean(id, true);
editor.apply();
}
public static void disable(final SharedPreferences pref, final String id) {
final SharedPreferences.Editor editor = pref.edit();
editor.putBoolean(id, false);
editor.apply();
}
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.app.DownloadManager;
/**
* Struct class to encapsulate the result of a completed download.
*/
public class CompletedDownloadInfo {
final String mUri;
final long mDownloadId;
final int mStatus;
public CompletedDownloadInfo(final String uri, final long downloadId, final int status) {
mUri = uri;
mDownloadId = downloadId;
mStatus = status;
}
public boolean wasSuccessful() {
return DownloadManager.STATUS_SUCCESSFUL == mStatus;
}
}

View file

@ -0,0 +1,173 @@
/**
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.app.DownloadManager;
import android.app.DownloadManager.Query;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.os.Handler;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;
import android.widget.ProgressBar;
public class DictionaryDownloadProgressBar extends ProgressBar {
private static final String TAG = DictionaryDownloadProgressBar.class.getSimpleName();
private static final int NOT_A_DOWNLOADMANAGER_PENDING_ID = 0;
private String mClientId;
private String mWordlistId;
private boolean mIsCurrentlyAttachedToWindow = false;
private Thread mReporterThread = null;
public DictionaryDownloadProgressBar(final Context context) {
super(context);
}
public DictionaryDownloadProgressBar(final Context context, final AttributeSet attrs) {
super(context, attrs);
}
public void setIds(final String clientId, final String wordlistId) {
mClientId = clientId;
mWordlistId = wordlistId;
}
static private int getDownloadManagerPendingIdFromWordlistId(final Context context,
final String clientId, final String wordlistId) {
final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
final ContentValues wordlistValues =
MetadataDbHelper.getContentValuesOfLatestAvailableWordlistById(db, wordlistId);
if (null == wordlistValues) {
// We don't know anything about a word list with this id. Bug? This should never
// happen, but still return to prevent a crash.
Log.e(TAG, "Unexpected word list ID: " + wordlistId);
return NOT_A_DOWNLOADMANAGER_PENDING_ID;
}
return wordlistValues.getAsInteger(MetadataDbHelper.PENDINGID_COLUMN);
}
/*
* This method will stop any running updater thread for this progress bar and create and run
* a new one only if the progress bar is visible.
* Hence, as a result of calling this method, the progress bar will have an updater thread
* running if and only if the progress bar is visible.
*/
private void updateReporterThreadRunningStatusAccordingToVisibility() {
if (null != mReporterThread) mReporterThread.interrupt();
if (mIsCurrentlyAttachedToWindow && View.VISIBLE == getVisibility()) {
final int downloadManagerPendingId =
getDownloadManagerPendingIdFromWordlistId(getContext(), mClientId, mWordlistId);
if (NOT_A_DOWNLOADMANAGER_PENDING_ID == downloadManagerPendingId) {
// Can't get the ID. This is never supposed to happen, but still clear the updater
// thread and return to avoid a crash.
mReporterThread = null;
return;
}
final UpdaterThread updaterThread =
new UpdaterThread(getContext(), downloadManagerPendingId);
updaterThread.start();
mReporterThread = updaterThread;
} else {
// We're not going to restart the thread anyway, so we may as well garbage collect it.
mReporterThread = null;
}
}
@Override
protected void onAttachedToWindow() {
mIsCurrentlyAttachedToWindow = true;
updateReporterThreadRunningStatusAccordingToVisibility();
}
@Override
protected void onDetachedFromWindow() {
super.onDetachedFromWindow();
mIsCurrentlyAttachedToWindow = false;
updateReporterThreadRunningStatusAccordingToVisibility();
}
private class UpdaterThread extends Thread {
private final static int REPORT_PERIOD = 150; // how often to report progress, in ms
final DownloadManagerWrapper mDownloadManagerWrapper;
final int mId;
public UpdaterThread(final Context context, final int id) {
super();
mDownloadManagerWrapper = new DownloadManagerWrapper(context);
mId = id;
}
@Override
public void run() {
try {
final UpdateHelper updateHelper = new UpdateHelper();
final Query query = new Query().setFilterById(mId);
setIndeterminate(true);
while (!isInterrupted()) {
final Cursor cursor = mDownloadManagerWrapper.query(query);
if (null == cursor) {
// Can't contact DownloadManager: this should never happen.
return;
}
try {
if (cursor.moveToNext()) {
final int columnBytesDownloadedSoFar = cursor.getColumnIndex(
DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR);
final int bytesDownloadedSoFar =
cursor.getInt(columnBytesDownloadedSoFar);
updateHelper.setProgressFromAnotherThread(bytesDownloadedSoFar);
} else {
// Download has finished and DownloadManager has already been asked to
// clean up the db entry.
updateHelper.setProgressFromAnotherThread(getMax());
return;
}
} finally {
cursor.close();
}
Thread.sleep(REPORT_PERIOD);
}
} catch (InterruptedException e) {
// Do nothing and terminate normally.
}
}
class UpdateHelper implements Runnable {
private int mProgress;
@Override
public void run() {
setIndeterminate(false);
setProgress(mProgress);
}
public void setProgressFromAnotherThread(final int progress) {
if (mProgress != progress) {
mProgress = progress;
// For some unknown reason, setProgress just does not work from a separate
// thread, although the code in ProgressBar looks like it should. Thus, we
// resort to a runnable posted to the handler of the view.
final Handler handler = getHandler();
// It's possible to come here before this view has been laid out. If so,
// just ignore the call - it will be updated again later.
if (null == handler) return;
handler.post(this);
}
}
}
}
}

View file

@ -0,0 +1,85 @@
/**
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.view.View;
import java.util.ArrayList;
import java.util.HashMap;
/**
* Helper class to maintain the interface state of word list preferences.
*
* This is necessary because the views are created on-demand by calling code. There are many
* situations where views are renewed with little relation with user interaction. For example,
* when scrolling, the view is reused so it doesn't keep its state, which means we need to keep
* it separately. Also whenever the underlying dictionary list undergoes a change (for example,
* update the metadata, or finish downloading) the whole list has to be thrown out and recreated
* in case some dictionaries appeared, disappeared, changed states etc.
*/
public class DictionaryListInterfaceState {
static class State {
public boolean mOpen = false;
public int mStatus = MetadataDbHelper.STATUS_UNKNOWN;
}
private HashMap<String, State> mWordlistToState = new HashMap<>();
private ArrayList<View> mViewCache = new ArrayList<>();
public boolean isOpen(final String wordlistId) {
final State state = mWordlistToState.get(wordlistId);
if (null == state) return false;
return state.mOpen;
}
public int getStatus(final String wordlistId) {
final State state = mWordlistToState.get(wordlistId);
if (null == state) return MetadataDbHelper.STATUS_UNKNOWN;
return state.mStatus;
}
public void setOpen(final String wordlistId, final int status) {
final State newState;
final State state = mWordlistToState.get(wordlistId);
newState = null == state ? new State() : state;
newState.mOpen = true;
newState.mStatus = status;
mWordlistToState.put(wordlistId, newState);
}
public void closeAll() {
for (final State state : mWordlistToState.values()) {
state.mOpen = false;
}
}
public View findFirstOrphanedView() {
for (final View v : mViewCache) {
if (null == v.getParent()) return v;
}
return null;
}
public View addToCacheAndReturnView(final View view) {
mViewCache.add(view);
return view;
}
public void removeFromCache(final View view) {
mViewCache.remove(view);
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
/**
* A class to group constants for dictionary pack usage.
*
* This class only defines constants. It should not make any references to outside code as far as
* possible, as it's used to separate cleanly the keyboard code from the dictionary pack code; this
* is needed in particular to cleanly compile regression tests.
*/
public class DictionaryPackConstants {
/**
* The root domain for the dictionary pack, upon which authorities and actions will append
* their own distinctive strings.
*/
private static final String DICTIONARY_DOMAIN = "org.dslul.openboard.inputmethod.dictionarypack.aosp";
/**
* Authority for the ContentProvider protocol.
*/
// TODO: find some way to factorize this string with the one in the resources
public static final String AUTHORITY = DICTIONARY_DOMAIN;
/**
* The action of the intent for publishing that new dictionary data is available.
*/
// TODO: make this different across different packages. A suggested course of action is
// to use the package name inside this string.
// NOTE: The appended string should be uppercase like all other actions, but it's not for
// historical reasons.
public static final String NEW_DICTIONARY_INTENT_ACTION = DICTIONARY_DOMAIN + ".newdict";
/**
* The action of the intent sent by the dictionary pack to ask for a client to make
* itself known. This is used when the settings activity is brought up for a client the
* dictionary pack does not know about.
*/
public static final String UNKNOWN_DICTIONARY_PROVIDER_CLIENT = DICTIONARY_DOMAIN
+ ".UNKNOWN_CLIENT";
// In the above intents, the name of the string extra that contains the name of the client
// we want information about.
public static final String DICTIONARY_PROVIDER_CLIENT_EXTRA = "client";
/**
* The action of the intent to tell the dictionary provider to update now.
*/
public static final String UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN
+ ".UPDATE_NOW";
/**
* The intent action to inform the dictionary provider to initialize the db
* and update now.
*/
public static final String INIT_AND_UPDATE_NOW_INTENT_ACTION = DICTIONARY_DOMAIN
+ ".INIT_AND_UPDATE_NOW";
}

View file

@ -0,0 +1,541 @@
/**
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.content.ContentProvider;
import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.content.res.AssetFileDescriptor;
import android.database.AbstractCursor;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.text.TextUtils;
import android.util.Log;
import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import org.dslul.openboard.inputmethod.latin.utils.DebugLogUtils;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
/**
* Provider for dictionaries.
*
* This class is a ContentProvider exposing all available dictionary data as managed by
* the dictionary pack.
*/
public final class DictionaryProvider extends ContentProvider {
private static final String TAG = DictionaryProvider.class.getSimpleName();
public static final boolean DEBUG = false;
public static final Uri CONTENT_URI =
Uri.parse(ContentResolver.SCHEME_CONTENT + "://" + DictionaryPackConstants.AUTHORITY);
private static final String QUERY_PARAMETER_MAY_PROMPT_USER = "mayPrompt";
private static final String QUERY_PARAMETER_TRUE = "true";
private static final String QUERY_PARAMETER_DELETE_RESULT = "result";
private static final String QUERY_PARAMETER_FAILURE = "failure";
public static final String QUERY_PARAMETER_PROTOCOL_VERSION = "protocol";
private static final int NO_MATCH = 0;
private static final int DICTIONARY_V1_WHOLE_LIST = 1;
private static final int DICTIONARY_V1_DICT_INFO = 2;
private static final int DICTIONARY_V2_METADATA = 3;
private static final int DICTIONARY_V2_WHOLE_LIST = 4;
private static final int DICTIONARY_V2_DICT_INFO = 5;
private static final int DICTIONARY_V2_DATAFILE = 6;
private static final UriMatcher sUriMatcherV1 = new UriMatcher(NO_MATCH);
private static final UriMatcher sUriMatcherV2 = new UriMatcher(NO_MATCH);
static
{
sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "list", DICTIONARY_V1_WHOLE_LIST);
sUriMatcherV1.addURI(DictionaryPackConstants.AUTHORITY, "*", DICTIONARY_V1_DICT_INFO);
sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/metadata",
DICTIONARY_V2_METADATA);
sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/list", DICTIONARY_V2_WHOLE_LIST);
sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/dict/*",
DICTIONARY_V2_DICT_INFO);
sUriMatcherV2.addURI(DictionaryPackConstants.AUTHORITY, "*/datafile/*",
DICTIONARY_V2_DATAFILE);
}
// MIME types for dictionary and dictionary list, as required by ContentProvider contract.
public static final String DICT_LIST_MIME_TYPE =
"vnd.android.cursor.item/vnd.google.dictionarylist";
public static final String DICT_DATAFILE_MIME_TYPE =
"vnd.android.cursor.item/vnd.google.dictionary";
public static final String ID_CATEGORY_SEPARATOR = ":";
private static final class WordListInfo {
public final String mId;
public final String mLocale;
public final String mRawChecksum;
public final int mMatchLevel;
public WordListInfo(final String id, final String locale, final String rawChecksum,
final int matchLevel) {
mId = id;
mLocale = locale;
mRawChecksum = rawChecksum;
mMatchLevel = matchLevel;
}
}
/**
* A cursor for returning a list of file ids from a List of strings.
*
* This simulates only the necessary methods. It has no error handling to speak of,
* and does not support everything a database does, only a few select necessary methods.
*/
private static final class ResourcePathCursor extends AbstractCursor {
// Column names for the cursor returned by this content provider.
static private final String[] columnNames = { MetadataDbHelper.WORDLISTID_COLUMN,
MetadataDbHelper.LOCALE_COLUMN, MetadataDbHelper.RAW_CHECKSUM_COLUMN };
// The list of word lists served by this provider that match the client request.
final WordListInfo[] mWordLists;
// Note : the cursor also uses mPos, which is defined in AbstractCursor.
public ResourcePathCursor(final Collection<WordListInfo> wordLists) {
// Allocating a 0-size WordListInfo here allows the toArray() method
// to ensure we have a strongly-typed array. It's thrown out. That's
// what the documentation of #toArray says to do in order to get a
// new strongly typed array of the correct size.
mWordLists = wordLists.toArray(new WordListInfo[0]);
mPos = 0;
}
@Override
public String[] getColumnNames() {
return columnNames;
}
@Override
public int getCount() {
return mWordLists.length;
}
@Override public double getDouble(int column) { return 0; }
@Override public float getFloat(int column) { return 0; }
@Override public int getInt(int column) { return 0; }
@Override public short getShort(int column) { return 0; }
@Override public long getLong(int column) { return 0; }
@Override public String getString(final int column) {
switch (column) {
case 0: return mWordLists[mPos].mId;
case 1: return mWordLists[mPos].mLocale;
case 2: return mWordLists[mPos].mRawChecksum;
default : return null;
}
}
@Override
public boolean isNull(final int column) {
if (mPos >= mWordLists.length) return true;
return column != 0;
}
}
@Override
public boolean onCreate() {
return true;
}
private static int matchUri(final Uri uri) {
int protocolVersion = 1;
final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
if ("2".equals(protocolVersionArg)) protocolVersion = 2;
switch (protocolVersion) {
case 1: return sUriMatcherV1.match(uri);
case 2: return sUriMatcherV2.match(uri);
default: return NO_MATCH;
}
}
private static String getClientId(final Uri uri) {
int protocolVersion = 1;
final String protocolVersionArg = uri.getQueryParameter(QUERY_PARAMETER_PROTOCOL_VERSION);
if ("2".equals(protocolVersionArg)) protocolVersion = 2;
switch (protocolVersion) {
case 1: return null; // In protocol 1, the client ID is always null.
case 2: return uri.getPathSegments().get(0);
default: return null;
}
}
/**
* Returns the MIME type of the content associated with an Uri
*
* @see android.content.ContentProvider#getType(android.net.Uri)
*
* @param uri the URI of the content the type of which should be returned.
* @return the MIME type, or null if the URL is not recognized.
*/
@Override
public String getType(final Uri uri) {
PrivateLog.log("Asked for type of : " + uri);
final int match = matchUri(uri);
switch (match) {
case NO_MATCH: return null;
case DICTIONARY_V1_WHOLE_LIST:
case DICTIONARY_V1_DICT_INFO:
case DICTIONARY_V2_WHOLE_LIST:
case DICTIONARY_V2_DICT_INFO: return DICT_LIST_MIME_TYPE;
case DICTIONARY_V2_DATAFILE: return DICT_DATAFILE_MIME_TYPE;
default: return null;
}
}
/**
* Query the provider for dictionary files.
*
* This version dispatches the query according to the protocol version found in the
* ?protocol= query parameter. If absent or not well-formed, it defaults to 1.
* @see android.content.ContentProvider#query(Uri, String[], String, String[], String)
*
* @param uri a content uri (see sUriMatcherV{1,2} at the top of this file for format)
* @param projection ignored. All columns are always returned.
* @param selection ignored.
* @param selectionArgs ignored.
* @param sortOrder ignored. The results are always returned in no particular order.
* @return a cursor matching the uri, or null if the URI was not recognized.
*/
@Override
public Cursor query(final Uri uri, final String[] projection, final String selection,
final String[] selectionArgs, final String sortOrder) {
DebugLogUtils.l("Uri =", uri);
PrivateLog.log("Query : " + uri);
final String clientId = getClientId(uri);
final int match = matchUri(uri);
switch (match) {
case DICTIONARY_V1_WHOLE_LIST:
case DICTIONARY_V2_WHOLE_LIST:
final Cursor c = MetadataDbHelper.queryDictionaries(getContext(), clientId);
DebugLogUtils.l("List of dictionaries with count", c.getCount());
PrivateLog.log("Returned a list of " + c.getCount() + " items");
return c;
case DICTIONARY_V2_DICT_INFO:
// In protocol version 2, we return null if the client is unknown. Otherwise
// we behave exactly like for protocol 1.
if (!MetadataDbHelper.isClientKnown(getContext(), clientId)) return null;
// Fall through
case DICTIONARY_V1_DICT_INFO:
final String locale = uri.getLastPathSegment();
final Collection<WordListInfo> dictFiles =
getDictionaryWordListsForLocale(clientId, locale);
// TODO: pass clientId to the following function
DictionaryService.updateNowIfNotUpdatedInAVeryLongTime(getContext());
if (null != dictFiles && dictFiles.size() > 0) {
PrivateLog.log("Returned " + dictFiles.size() + " files");
return new ResourcePathCursor(dictFiles);
}
PrivateLog.log("No dictionary files for this URL");
return new ResourcePathCursor(Collections.<WordListInfo>emptyList());
// V2_METADATA and V2_DATAFILE are not supported for query()
default:
return null;
}
}
/**
* Helper method to get the wordlist metadata associated with a wordlist ID.
*
* @param clientId the ID of the client
* @param wordlistId the ID of the wordlist for which to get the metadata.
* @return the metadata for this wordlist ID, or null if none could be found.
*/
private ContentValues getWordlistMetadataForWordlistId(final String clientId,
final String wordlistId) {
final Context context = getContext();
if (TextUtils.isEmpty(wordlistId)) return null;
final SQLiteDatabase db = MetadataDbHelper.getDb(context, clientId);
return MetadataDbHelper.getInstalledOrDeletingWordListContentValuesByWordListId(
db, wordlistId);
}
/**
* Opens an asset file for an URI.
*
* Called by {@link android.content.ContentResolver#openAssetFileDescriptor(Uri, String)} or
* {@link android.content.ContentResolver#openInputStream(Uri)} from a client requesting a
* dictionary.
* @see android.content.ContentProvider#openAssetFile(Uri, String)
*
* @param uri the URI the file is for.
* @param mode the mode to read the file. MUST be "r" for readonly.
* @return the descriptor, or null if the file is not found or if mode is not equals to "r".
*/
@Override
public AssetFileDescriptor openAssetFile(final Uri uri, final String mode) {
if (null == mode || !"r".equals(mode)) return null;
final int match = matchUri(uri);
if (DICTIONARY_V1_DICT_INFO != match && DICTIONARY_V2_DATAFILE != match) {
// Unsupported URI for openAssetFile
Log.w(TAG, "Unsupported URI for openAssetFile : " + uri);
return null;
}
final String wordlistId = uri.getLastPathSegment();
final String clientId = getClientId(uri);
final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
if (null == wordList) return null;
try {
final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
if (MetadataDbHelper.STATUS_DELETING == status) {
// This will return an empty file (R.raw.empty points at an empty dictionary)
// This is how we "delete" the files. It allows Android Keyboard to fake deleting
// a default dictionary - which is actually in its assets and can't be really
// deleted.
final AssetFileDescriptor afd = getContext().getResources().openRawResourceFd(
R.raw.empty);
return afd;
}
final String localFilename =
wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
final File f = getContext().getFileStreamPath(localFilename);
final ParcelFileDescriptor pfd =
ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY);
return new AssetFileDescriptor(pfd, 0, pfd.getStatSize());
} catch (FileNotFoundException e) {
// No file : fall through and return null
}
return null;
}
/**
* Reads the metadata and returns the collection of dictionaries for a given locale.
*
* Word list IDs are expected to be in the form category:manual_id. This method
* will select only one word list for each category: the one with the most specific
* locale matching the locale specified in the URI. The manual id serves only to
* distinguish a word list from another for the purpose of updating, and is arbitrary
* but may not contain a colon.
*
* @param clientId the ID of the client requesting the list
* @param locale the locale for which we want the list, as a String
* @return a collection of ids. It is guaranteed to be non-null, but may be empty.
*/
private Collection<WordListInfo> getDictionaryWordListsForLocale(final String clientId,
final String locale) {
final Context context = getContext();
final Cursor results =
MetadataDbHelper.queryInstalledOrDeletingOrAvailableDictionaryMetadata(context,
clientId);
if (null == results) {
return Collections.<WordListInfo>emptyList();
}
try {
final HashMap<String, WordListInfo> dicts = new HashMap<>();
final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
final int localeIndex = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
final int localFileNameIndex =
results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
final int rawChecksumIndex =
results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN);
final int statusIndex = results.getColumnIndex(MetadataDbHelper.STATUS_COLUMN);
if (results.moveToFirst()) {
do {
final String wordListId = results.getString(idIndex);
if (TextUtils.isEmpty(wordListId)) continue;
final String[] wordListIdArray =
TextUtils.split(wordListId, ID_CATEGORY_SEPARATOR);
final String wordListCategory;
if (2 == wordListIdArray.length) {
// This is at the category:manual_id format.
wordListCategory = wordListIdArray[0];
// We don't need to read wordListIdArray[1] here, because it's irrelevant to
// word list selection - it's just a name we use to identify which data file
// is a newer version of which word list. We do however return the full id
// string for each selected word list, so in this sense we are 'using' it.
} else {
// This does not contain a colon, like the old format does. Old-format IDs
// always point to main dictionaries, so we force the main category upon it.
wordListCategory = UpdateHandler.MAIN_DICTIONARY_CATEGORY;
}
final String wordListLocale = results.getString(localeIndex);
final String wordListLocalFilename = results.getString(localFileNameIndex);
final String wordListRawChecksum = results.getString(rawChecksumIndex);
final int wordListStatus = results.getInt(statusIndex);
// Test the requested locale against this wordlist locale. The requested locale
// has to either match exactly or be more specific than the dictionary - a
// dictionary for "en" would match both a request for "en" or for "en_US", but a
// dictionary for "en_GB" would not match a request for "en_US". Thus if all
// three of "en" "en_US" and "en_GB" dictionaries are installed, a request for
// "en_US" would match "en" and "en_US", and a request for "en" only would only
// match the generic "en" dictionary. For more details, see the documentation
// for LocaleUtils#getMatchLevel.
final int matchLevel = LocaleUtils.getMatchLevel(wordListLocale, locale);
if (!LocaleUtils.isMatch(matchLevel)) {
// The locale of this wordlist does not match the required locale.
// Skip this wordlist and go to the next.
continue;
}
if (MetadataDbHelper.STATUS_INSTALLED == wordListStatus) {
// If the file does not exist, it has been deleted and the IME should
// already have it. Do not return it. However, this only applies if the
// word list is INSTALLED, for if it is DELETING we should return it always
// so that Android Keyboard can perform the actual deletion.
final File f = getContext().getFileStreamPath(wordListLocalFilename);
if (!f.isFile()) {
continue;
}
} else if (MetadataDbHelper.STATUS_AVAILABLE == wordListStatus) {
// The locale is the id for the main dictionary.
UpdateHandler.installIfNeverRequested(context, clientId, wordListId);
continue;
}
final WordListInfo currentBestMatch = dicts.get(wordListCategory);
if (null == currentBestMatch
|| currentBestMatch.mMatchLevel < matchLevel) {
dicts.put(wordListCategory, new WordListInfo(wordListId, wordListLocale,
wordListRawChecksum, matchLevel));
}
} while (results.moveToNext());
}
return Collections.unmodifiableCollection(dicts.values());
} finally {
results.close();
}
}
/**
* Deletes the file pointed by Uri, as returned by openAssetFile.
*
* @param uri the URI the file is for.
* @param selection ignored
* @param selectionArgs ignored
* @return the number of files deleted (0 or 1 in the current implementation)
* @see android.content.ContentProvider#delete(Uri, String, String[])
*/
@Override
public int delete(final Uri uri, final String selection, final String[] selectionArgs)
throws UnsupportedOperationException {
final int match = matchUri(uri);
if (DICTIONARY_V1_DICT_INFO == match || DICTIONARY_V2_DATAFILE == match) {
return deleteDataFile(uri);
}
if (DICTIONARY_V2_METADATA == match) {
if (MetadataDbHelper.deleteClient(getContext(), getClientId(uri))) {
return 1;
}
return 0;
}
// Unsupported URI for delete
return 0;
}
private int deleteDataFile(final Uri uri) {
final String wordlistId = uri.getLastPathSegment();
final String clientId = getClientId(uri);
final ContentValues wordList = getWordlistMetadataForWordlistId(clientId, wordlistId);
if (null == wordList) {
return 0;
}
final int status = wordList.getAsInteger(MetadataDbHelper.STATUS_COLUMN);
final int version = wordList.getAsInteger(MetadataDbHelper.VERSION_COLUMN);
if (MetadataDbHelper.STATUS_DELETING == status) {
UpdateHandler.markAsDeleted(getContext(), clientId, wordlistId, version, status);
return 1;
}
if (MetadataDbHelper.STATUS_INSTALLED == status) {
final String result = uri.getQueryParameter(QUERY_PARAMETER_DELETE_RESULT);
if (QUERY_PARAMETER_FAILURE.equals(result)) {
if (DEBUG) {
Log.d(TAG,
"Dictionary is broken, attempting to retry download & installation.");
}
UpdateHandler.markAsBrokenOrRetrying(getContext(), clientId, wordlistId, version);
}
final String localFilename =
wordList.getAsString(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
final File f = getContext().getFileStreamPath(localFilename);
// f.delete() returns true if the file was successfully deleted, false otherwise
return f.delete() ? 1 : 0;
}
Log.e(TAG, "Attempt to delete a file whose status is " + status);
return 0;
}
/**
* Insert data into the provider. May be either a metadata source URL or some dictionary info.
*
* @param uri the designated content URI. See sUriMatcherV{1,2} for available URIs.
* @param values the values to insert for this content uri
* @return the URI for the newly inserted item. May be null if arguments don't allow for insert
*/
@Override
public Uri insert(final Uri uri, final ContentValues values)
throws UnsupportedOperationException {
if (null == uri || null == values) return null; // Should never happen but let's be safe
PrivateLog.log("Insert, uri = " + uri.toString());
final String clientId = getClientId(uri);
switch (matchUri(uri)) {
case DICTIONARY_V2_METADATA:
// The values should contain a valid client ID and a valid URI for the metadata.
// The client ID may not be null, nor may it be empty because the empty client ID
// is reserved for internal use.
// The metadata URI may not be null, but it may be empty if the client does not
// want the dictionary pack to update the metadata automatically.
MetadataDbHelper.updateClientInfo(getContext(), clientId, values);
break;
case DICTIONARY_V2_DICT_INFO:
try {
final WordListMetadata newDictionaryMetadata =
WordListMetadata.createFromContentValues(
MetadataDbHelper.completeWithDefaultValues(values));
new ActionBatch.MarkPreInstalledAction(clientId, newDictionaryMetadata)
.execute(getContext());
} catch (final BadFormatException e) {
Log.w(TAG, "Not enough information to insert this dictionary " + values, e);
}
// We just received new information about the list of dictionary for this client.
// For all intents and purposes, this is new metadata, so we should publish it
// so that any listeners (like the Settings interface for example) can update
// themselves.
UpdateHandler.publishUpdateMetadataCompleted(getContext(), true);
break;
case DICTIONARY_V1_WHOLE_LIST:
case DICTIONARY_V1_DICT_INFO:
PrivateLog.log("Attempt to insert : " + uri);
throw new UnsupportedOperationException(
"Insertion in the dictionary is not supported in this version");
}
return uri;
}
/**
* Updating data is not supported, and will throw an exception.
* @see android.content.ContentProvider#update(Uri, ContentValues, String, String[])
* @see android.content.ContentProvider#insert(Uri, ContentValues)
*/
@Override
public int update(final Uri uri, final ContentValues values, final String selection,
final String[] selectionArgs) throws UnsupportedOperationException {
PrivateLog.log("Attempt to update : " + uri);
throw new UnsupportedOperationException("Updating dictionary words is not supported");
}
}

View file

@ -0,0 +1,274 @@
/**
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.IBinder;
import android.util.Log;
import android.widget.Toast;
import org.dslul.openboard.inputmethod.latin.BinaryDictionaryFileDumper;
import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import java.util.Locale;
import java.util.Random;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import javax.annotation.Nonnull;
/**
* Service that handles background tasks for the dictionary provider.
*
* This service provides the context for the long-running operations done by the
* dictionary provider. Those include:
* - Checking for the last update date and scheduling the next update. This runs every
* day around midnight, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast.
* Every four days, it schedules an update of the metadata with the alarm manager.
* - Issuing the order to update the metadata. This runs every four days, between 0 and
* 6, upon reception of the UPDATE_NOW_INTENT_ACTION broadcast sent by the alarm manager
* as a result of the above action.
* - Handling a download that just ended. These come in two flavors:
* - Metadata is finished downloading. We should check whether there are new dictionaries
* available, and download those that we need that have new versions.
* - A dictionary file finished downloading. We should put the file ready for a client IME
* to access, and mark the current state as such.
*/
public final class DictionaryService extends Service {
private static final String TAG = DictionaryService.class.getSimpleName();
/**
* The package name, to use in the intent actions.
*/
private static final String PACKAGE_NAME = "org.dslul.openboard.inputmethod.latin";
/**
* The action of the date changing, used to schedule a periodic freshness check
*/
private static final String DATE_CHANGED_INTENT_ACTION =
Intent.ACTION_DATE_CHANGED;
/**
* The action of displaying a toast to warn the user an automatic download is starting.
*/
/* package */ static final String SHOW_DOWNLOAD_TOAST_INTENT_ACTION =
PACKAGE_NAME + ".SHOW_DOWNLOAD_TOAST_INTENT_ACTION";
/**
* A locale argument, as a String.
*/
/* package */ static final String LOCALE_INTENT_ARGUMENT = "locale";
/**
* How often, in milliseconds, we want to update the metadata. This is a
* floor value; actually, it may happen several hours later, or even more.
*/
private static final long UPDATE_FREQUENCY_MILLIS = TimeUnit.DAYS.toMillis(4);
/**
* We are waked around midnight, local time. We want to wake between midnight and 6 am,
* roughly. So use a random time between 0 and this delay.
*/
private static final int MAX_ALARM_DELAY_MILLIS = (int)TimeUnit.HOURS.toMillis(6);
/**
* How long we consider a "very long time". If no update took place in this time,
* the content provider will trigger an update in the background.
*/
private static final long VERY_LONG_TIME_MILLIS = TimeUnit.DAYS.toMillis(14);
/**
* After starting a download, how long we wait before considering it may be stuck. After this
* period is elapsed, if the keyboard tries to download again, then we cancel and re-register
* the request; if it's within this time, we just leave it be.
* It's important to note that we do not re-submit the request merely because the time is up.
* This is only to decide whether to cancel the old one and re-requesting when the keyboard
* fires a new request for the same data.
*/
public static final long NO_CANCEL_DOWNLOAD_PERIOD_MILLIS = TimeUnit.SECONDS.toMillis(30);
/**
* An executor that serializes tasks given to it.
*/
private ThreadPoolExecutor mExecutor;
private static final int WORKER_THREAD_TIMEOUT_SECONDS = 15;
@Override
public void onCreate() {
// By default, a thread pool executor does not timeout its core threads, so it will
// never kill them when there isn't any work to do any more. That would mean the service
// can never die! By creating it this way and calling allowCoreThreadTimeOut, we allow
// the single thread to time out after WORKER_THREAD_TIMEOUT_SECONDS = 15 seconds, allowing
// the process to be reclaimed by the system any time after that if it's not doing
// anything else.
// Executors#newSingleThreadExecutor creates a ThreadPoolExecutor but it returns the
// superclass ExecutorService which does not have the #allowCoreThreadTimeOut method,
// so we can't use that.
mExecutor = new ThreadPoolExecutor(1 /* corePoolSize */, 1 /* maximumPoolSize */,
WORKER_THREAD_TIMEOUT_SECONDS /* keepAliveTime */,
TimeUnit.SECONDS /* unit for keepAliveTime */,
new LinkedBlockingQueue<Runnable>() /* workQueue */);
mExecutor.allowCoreThreadTimeOut(true);
}
@Override
public void onDestroy() {
}
@Override
public IBinder onBind(Intent intent) {
// This service cannot be bound
return null;
}
/**
* Executes an explicit command.
*
* This is the entry point for arbitrary commands that are executed upon reception of certain
* events that should be executed on the context of this service. The supported commands are:
* - Check last update time and possibly schedule an update of the data for later.
* This is triggered every day, upon reception of the DATE_CHANGED_INTENT_ACTION broadcast.
* - Update data NOW.
* This is normally received upon trigger of the scheduled update.
* - Handle a finished download.
* This executes the actions that must be taken after a file (metadata or dictionary data
* has been downloaded (or failed to download).
* The commands that can be spun an another thread will be executed serially, in order, on
* a worker thread that is created on demand and terminates after a short while if there isn't
* any work left to do.
*/
@Override
public synchronized int onStartCommand(final Intent intent, final int flags,
final int startId) {
final DictionaryService self = this;
if (SHOW_DOWNLOAD_TOAST_INTENT_ACTION.equals(intent.getAction())) {
final String localeString = intent.getStringExtra(LOCALE_INTENT_ARGUMENT);
if (localeString == null) {
Log.e(TAG, "Received " + intent.getAction() + " without locale; skipped");
} else {
// This is a UI action, it can't be run in another thread
showStartDownloadingToast(
this, LocaleUtils.constructLocaleFromString(localeString));
}
} else {
// If it's a command that does not require UI, arrange for the work to be done on a
// separate thread, so that we can return right away. The executor will spawn a thread
// if necessary, or reuse a thread that has become idle as appropriate.
// DATE_CHANGED or UPDATE_NOW are examples of commands that can be done on another
// thread.
mExecutor.submit(new Runnable() {
@Override
public void run() {
dispatchBroadcast(self, intent);
// Since calls to onStartCommand are serialized, the submissions to the executor
// are serialized. That means we are guaranteed to call the stopSelfResult()
// in the same order that we got them, so we don't need to take care of the
// order.
stopSelfResult(startId);
}
});
}
return Service.START_REDELIVER_INTENT;
}
static void dispatchBroadcast(final Context context, final Intent intent) {
final String action = intent.getAction();
if (DATE_CHANGED_INTENT_ACTION.equals(action)) {
// This happens when the date of the device changes. This normally happens
// at midnight local time, but it may happen if the user changes the date
// by hand or something similar happens.
checkTimeAndMaybeSetupUpdateAlarm(context);
} else if (DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION.equals(action)) {
// Intent to trigger an update now.
UpdateHandler.tryUpdate(context);
} else if (DictionaryPackConstants.INIT_AND_UPDATE_NOW_INTENT_ACTION.equals(action)) {
// Initialize the client Db.
final String mClientId = context.getString(R.string.dictionary_pack_client_id);
BinaryDictionaryFileDumper.initializeClientRecordHelper(context, mClientId);
// Updates the metadata and the download the dictionaries.
UpdateHandler.tryUpdate(context);
} else {
UpdateHandler.downloadFinished(context, intent);
}
}
/**
* Setups an alarm to check for updates if an update is due.
*/
private static void checkTimeAndMaybeSetupUpdateAlarm(final Context context) {
// Of all clients, if the one that hasn't been updated for the longest
// is still more recent than UPDATE_FREQUENCY_MILLIS, do nothing.
if (!isLastUpdateAtLeastThisOld(context, UPDATE_FREQUENCY_MILLIS)) return;
PrivateLog.log("Date changed - registering alarm");
AlarmManager alarmManager = (AlarmManager)context.getSystemService(Context.ALARM_SERVICE);
// Best effort to wake between midnight and MAX_ALARM_DELAY_MILLIS in the morning.
// It doesn't matter too much if this is very inexact.
final long now = System.currentTimeMillis();
final long alarmTime = now + new Random().nextInt(MAX_ALARM_DELAY_MILLIS);
final Intent updateIntent = new Intent(DictionaryPackConstants.UPDATE_NOW_INTENT_ACTION);
final PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0,
updateIntent, PendingIntent.FLAG_CANCEL_CURRENT);
// We set the alarm in the type that doesn't forcefully wake the device
// from sleep, but fires the next time the device actually wakes for any
// other reason.
if (null != alarmManager) alarmManager.set(AlarmManager.RTC, alarmTime, pendingIntent);
}
/**
* Utility method to decide whether the last update is older than a certain time.
*
* @return true if at least `time' milliseconds have elapsed since last update, false otherwise.
*/
private static boolean isLastUpdateAtLeastThisOld(final Context context, final long time) {
final long now = System.currentTimeMillis();
final long lastUpdate = MetadataDbHelper.getOldestUpdateTime(context);
PrivateLog.log("Last update was " + lastUpdate);
return lastUpdate + time < now;
}
/**
* Refreshes data if it hasn't been refreshed in a very long time.
*
* This will check the last update time, and if it's been more than VERY_LONG_TIME_MILLIS,
* update metadata now - and possibly take subsequent update actions.
*/
public static void updateNowIfNotUpdatedInAVeryLongTime(final Context context) {
if (!isLastUpdateAtLeastThisOld(context, VERY_LONG_TIME_MILLIS)) return;
UpdateHandler.tryUpdate(context);
}
/**
* Shows a toast informing the user that an automatic dictionary download is starting.
*/
private static void showStartDownloadingToast(final Context context,
@Nonnull final Locale locale) {
final String toastText = String.format(
context.getString(R.string.toast_downloading_suggestions),
locale.getDisplayName());
Toast.makeText(context, toastText, Toast.LENGTH_LONG).show();
}
}

View file

@ -0,0 +1,54 @@
/**
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import org.dslul.openboard.inputmethod.latin.utils.FragmentUtils;
import android.annotation.TargetApi;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.preference.PreferenceActivity;
/**
* Preference screen.
*/
public final class DictionarySettingsActivity extends PreferenceActivity {
private static final String DEFAULT_FRAGMENT = DictionarySettingsFragment.class.getName();
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
}
@Override
public Intent getIntent() {
final Intent modIntent = new Intent(super.getIntent());
modIntent.putExtra(EXTRA_SHOW_FRAGMENT, DEFAULT_FRAGMENT);
modIntent.putExtra(EXTRA_NO_HEADERS, true);
// Important note : the original intent should contain a String extra with the key
// DictionarySettingsFragment.DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT so that the
// fragment can know who the client is.
return modIntent;
}
@TargetApi(Build.VERSION_CODES.KITKAT)
@Override
public boolean isValidFragment(String fragmentName) {
return FragmentUtils.isValidFragment(fragmentName);
}
}

View file

@ -0,0 +1,438 @@
/**
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy
* of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.IntentFilter;
import android.database.Cursor;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.preference.Preference;
import android.preference.PreferenceFragment;
import android.preference.PreferenceGroup;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AnimationUtils;
import org.dslul.openboard.inputmethod.latin.R;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Locale;
import java.util.TreeMap;
/**
* Preference screen.
*/
public final class DictionarySettingsFragment extends PreferenceFragment
implements UpdateHandler.UpdateEventListener {
private static final String TAG = DictionarySettingsFragment.class.getSimpleName();
static final private String DICT_LIST_ID = "list";
static final public String DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT = "clientId";
static final private int MENU_UPDATE_NOW = Menu.FIRST;
private View mLoadingView;
private String mClientId;
private ConnectivityManager mConnectivityManager;
private MenuItem mUpdateNowMenu;
private boolean mChangedSettings;
private DictionaryListInterfaceState mDictionaryListInterfaceState =
new DictionaryListInterfaceState();
// never null
private TreeMap<String, WordListPreference> mCurrentPreferenceMap = new TreeMap<>();
private final BroadcastReceiver mConnectivityChangedReceiver = new BroadcastReceiver() {
@Override
public void onReceive(final Context context, final Intent intent) {
refreshNetworkState();
}
};
/**
* Empty constructor for fragment generation.
*/
public DictionarySettingsFragment() {
}
@Override
public View onCreateView(final LayoutInflater inflater, final ViewGroup container,
final Bundle savedInstanceState) {
final View v = inflater.inflate(R.layout.loading_page, container, true);
mLoadingView = v.findViewById(R.id.loading_container);
return super.onCreateView(inflater, container, savedInstanceState);
}
@Override
public void onActivityCreated(final Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
final Activity activity = getActivity();
mClientId = activity.getIntent().getStringExtra(DICT_SETTINGS_FRAGMENT_CLIENT_ID_ARGUMENT);
mConnectivityManager =
(ConnectivityManager)activity.getSystemService(Context.CONNECTIVITY_SERVICE);
addPreferencesFromResource(R.xml.dictionary_settings);
refreshInterface();
setHasOptionsMenu(true);
}
@Override
public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) {
new AsyncTask<Void, Void, String>() {
@Override
protected String doInBackground(Void... params) {
return MetadataDbHelper.getMetadataUriAsString(getActivity(), mClientId);
}
@Override
protected void onPostExecute(String metadataUri) {
// We only add the "Refresh" button if we have a non-empty URL to refresh from. If
// the URL is empty, of course we can't refresh so it makes no sense to display
// this.
if (!TextUtils.isEmpty(metadataUri)) {
if (mUpdateNowMenu == null) {
mUpdateNowMenu = menu.add(Menu.NONE, MENU_UPDATE_NOW, 0,
R.string.check_for_updates_now);
mUpdateNowMenu.setShowAsAction(MenuItem.SHOW_AS_ACTION_IF_ROOM);
}
refreshNetworkState();
}
}
}.execute();
}
@Override
public void onResume() {
super.onResume();
mChangedSettings = false;
UpdateHandler.registerUpdateEventListener(this);
final Activity activity = getActivity();
final IntentFilter filter = new IntentFilter();
filter.addAction(ConnectivityManager.CONNECTIVITY_ACTION);
getActivity().registerReceiver(mConnectivityChangedReceiver, filter);
refreshNetworkState();
new Thread("onResume") {
@Override
public void run() {
if (!MetadataDbHelper.isClientKnown(activity, mClientId)) {
Log.i(TAG, "Unknown dictionary pack client: " + mClientId
+ ". Requesting info.");
final Intent unknownClientBroadcast =
new Intent(DictionaryPackConstants.UNKNOWN_DICTIONARY_PROVIDER_CLIENT);
unknownClientBroadcast.putExtra(
DictionaryPackConstants.DICTIONARY_PROVIDER_CLIENT_EXTRA, mClientId);
activity.sendBroadcast(unknownClientBroadcast);
}
}
}.start();
}
@Override
public void onPause() {
super.onPause();
final Activity activity = getActivity();
UpdateHandler.unregisterUpdateEventListener(this);
activity.unregisterReceiver(mConnectivityChangedReceiver);
if (mChangedSettings) {
final Intent newDictBroadcast =
new Intent(DictionaryPackConstants.NEW_DICTIONARY_INTENT_ACTION);
activity.sendBroadcast(newDictBroadcast);
mChangedSettings = false;
}
}
@Override
public void downloadedMetadata(final boolean succeeded) {
stopLoadingAnimation();
if (!succeeded) return; // If the download failed nothing changed, so no need to refresh
new Thread("refreshInterface") {
@Override
public void run() {
refreshInterface();
}
}.start();
}
@Override
public void wordListDownloadFinished(final String wordListId, final boolean succeeded) {
final WordListPreference pref = findWordListPreference(wordListId);
if (null == pref) return;
// TODO: Report to the user if !succeeded
final Activity activity = getActivity();
if (null == activity) return;
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
// We have to re-read the db in case the description has changed, and to
// find out what state it ended up if the download wasn't successful
// TODO: don't redo everything, only re-read and set this word list status
refreshInterface();
}
});
}
private WordListPreference findWordListPreference(final String id) {
final PreferenceGroup prefScreen = getPreferenceScreen();
if (null == prefScreen) {
Log.e(TAG, "Could not find the preference group");
return null;
}
for (int i = prefScreen.getPreferenceCount() - 1; i >= 0; --i) {
final Preference pref = prefScreen.getPreference(i);
if (pref instanceof WordListPreference) {
final WordListPreference wlPref = (WordListPreference)pref;
if (id.equals(wlPref.mWordlistId)) {
return wlPref;
}
}
}
Log.e(TAG, "Could not find the preference for a word list id " + id);
return null;
}
@Override
public void updateCycleCompleted() {}
void refreshNetworkState() {
NetworkInfo info = mConnectivityManager.getActiveNetworkInfo();
boolean isConnected = null == info ? false : info.isConnected();
if (null != mUpdateNowMenu) mUpdateNowMenu.setEnabled(isConnected);
}
void refreshInterface() {
final Activity activity = getActivity();
if (null == activity) return;
final PreferenceGroup prefScreen = getPreferenceScreen();
final Collection<? extends Preference> prefList =
createInstalledDictSettingsCollection(mClientId);
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
// TODO: display this somewhere
// if (0 != lastUpdate) mUpdateNowPreference.setSummary(updateNowSummary);
refreshNetworkState();
removeAnyDictSettings(prefScreen);
int i = 0;
for (Preference preference : prefList) {
preference.setOrder(i++);
prefScreen.addPreference(preference);
}
}
});
}
private static Preference createErrorMessage(final Activity activity, final int messageResource) {
final Preference message = new Preference(activity);
message.setTitle(messageResource);
message.setEnabled(false);
return message;
}
static void removeAnyDictSettings(final PreferenceGroup prefGroup) {
for (int i = prefGroup.getPreferenceCount() - 1; i >= 0; --i) {
prefGroup.removePreference(prefGroup.getPreference(i));
}
}
/**
* Creates a WordListPreference list to be added to the screen.
*
* This method only creates the preferences but does not add them.
* Thus, it can be called on another thread.
*
* @param clientId the id of the client for which we want to display the dictionary list
* @return A collection of preferences ready to add to the interface.
*/
private Collection<? extends Preference> createInstalledDictSettingsCollection(
final String clientId) {
// This will directly contact the DictionaryProvider and request the list exactly like
// any regular client would do.
// Considering the respective value of the respective constants used here for each path,
// segment, the url generated by this is of the form (assuming "clientId" as a clientId)
// content://org.dslul.openboard.inputmethod.latin.dictionarypack/clientId/list?procotol=2
final Uri contentUri = new Uri.Builder().scheme(ContentResolver.SCHEME_CONTENT)
.authority(getString(R.string.authority))
.appendPath(clientId)
.appendPath(DICT_LIST_ID)
// Need to use version 2 to get this client's list
.appendQueryParameter(DictionaryProvider.QUERY_PARAMETER_PROTOCOL_VERSION, "2")
.build();
final Activity activity = getActivity();
final Cursor cursor = (null == activity) ? null
: activity.getContentResolver().query(contentUri, null, null, null, null);
if (null == cursor) {
final ArrayList<Preference> result = new ArrayList<>();
result.add(createErrorMessage(activity, R.string.cannot_connect_to_dict_service));
return result;
}
try {
if (!cursor.moveToFirst()) {
final ArrayList<Preference> result = new ArrayList<>();
result.add(createErrorMessage(activity, R.string.no_dictionaries_available));
return result;
}
final String systemLocaleString = Locale.getDefault().toString();
final TreeMap<String, WordListPreference> prefMap = new TreeMap<>();
final int idIndex = cursor.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
final int versionIndex = cursor.getColumnIndex(MetadataDbHelper.VERSION_COLUMN);
final int localeIndex = cursor.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
final int descriptionIndex = cursor.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN);
final int statusIndex = cursor.getColumnIndex(MetadataDbHelper.STATUS_COLUMN);
final int filesizeIndex = cursor.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN);
do {
final String wordlistId = cursor.getString(idIndex);
final int version = cursor.getInt(versionIndex);
final String localeString = cursor.getString(localeIndex);
final Locale locale = new Locale(localeString);
final String description = cursor.getString(descriptionIndex);
final int status = cursor.getInt(statusIndex);
final int matchLevel = LocaleUtils.getMatchLevel(systemLocaleString, localeString);
final String matchLevelString = LocaleUtils.getMatchLevelSortedString(matchLevel);
final int filesize = cursor.getInt(filesizeIndex);
// The key is sorted in lexicographic order, according to the match level, then
// the description.
final String key = matchLevelString + "." + description + "." + wordlistId;
final WordListPreference existingPref = prefMap.get(key);
if (null == existingPref || existingPref.hasPriorityOver(status)) {
final WordListPreference oldPreference = mCurrentPreferenceMap.get(key);
final WordListPreference pref;
if (null != oldPreference
&& oldPreference.mVersion == version
&& oldPreference.hasStatus(status)
&& oldPreference.mLocale.equals(locale)) {
// If the old preference has all the new attributes, reuse it. Ideally,
// we should reuse the old pref even if its status is different and call
// setStatus here, but setStatus calls Preference#setSummary() which
// needs to be done on the UI thread and we're not on the UI thread
// here. We could do all this work on the UI thread, but in this case
// it's probably lighter to stay on a background thread and throw this
// old preference out.
pref = oldPreference;
} else {
// Otherwise, discard it and create a new one instead.
// TODO: when the status is different from the old one, we need to
// animate the old one out before animating the new one in.
pref = new WordListPreference(activity, mDictionaryListInterfaceState,
mClientId, wordlistId, version, locale, description, status,
filesize);
}
prefMap.put(key, pref);
}
} while (cursor.moveToNext());
mCurrentPreferenceMap = prefMap;
return prefMap.values();
} finally {
cursor.close();
}
}
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
switch (item.getItemId()) {
case MENU_UPDATE_NOW:
if (View.GONE == mLoadingView.getVisibility()) {
startRefresh();
} else {
cancelRefresh();
}
return true;
}
return false;
}
private void startRefresh() {
startLoadingAnimation();
mChangedSettings = true;
UpdateHandler.registerUpdateEventListener(this);
final Activity activity = getActivity();
new Thread("updateByHand") {
@Override
public void run() {
// We call tryUpdate(), which returns whether we could successfully start an update.
// If we couldn't, we'll never receive the end callback, so we stop the loading
// animation and return to the previous screen.
if (!UpdateHandler.tryUpdate(activity)) {
stopLoadingAnimation();
}
}
}.start();
}
private void cancelRefresh() {
UpdateHandler.unregisterUpdateEventListener(this);
final Context context = getActivity();
new Thread("cancelByHand") {
@Override
public void run() {
UpdateHandler.cancelUpdate(context, mClientId);
stopLoadingAnimation();
}
}.start();
}
private void startLoadingAnimation() {
mLoadingView.setVisibility(View.VISIBLE);
getView().setVisibility(View.GONE);
// We come here when the menu element is pressed so presumably it can't be null. But
// better safe than sorry.
if (null != mUpdateNowMenu) mUpdateNowMenu.setTitle(R.string.cancel);
}
void stopLoadingAnimation() {
final View preferenceView = getView();
final Activity activity = getActivity();
if (null == activity) return;
final View loadingView = mLoadingView;
final MenuItem updateNowMenu = mUpdateNowMenu;
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
loadingView.setVisibility(View.GONE);
preferenceView.setVisibility(View.VISIBLE);
loadingView.startAnimation(AnimationUtils.loadAnimation(
activity, android.R.anim.fade_out));
preferenceView.startAnimation(AnimationUtils.loadAnimation(
activity, android.R.anim.fade_in));
// The menu is created by the framework asynchronously after the activity,
// which means it's possible to have the activity running but the menu not
// created yet - hence the necessity for a null check here.
if (null != updateNowMenu) {
updateNowMenu.setTitle(R.string.check_for_updates_now);
}
}
});
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
/**
* A simple container of download ID and download start date.
*/
public class DownloadIdAndStartDate {
public final long mId;
public final long mStartDate;
public DownloadIdAndStartDate(final long id, final long startDate) {
mId = id;
mStartDate = startDate;
}
}

View file

@ -0,0 +1,110 @@
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.app.DownloadManager;
import android.app.DownloadManager.Query;
import android.app.DownloadManager.Request;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteException;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import java.io.FileNotFoundException;
import javax.annotation.Nullable;
/**
* A class to help with calling DownloadManager methods.
*
* Mostly, the problem here is that most methods from DownloadManager may throw SQL exceptions if
* they can't open the database on disk. We want to avoid crashing in these cases but can't do
* much more, so this class insulates the callers from these. SQLiteException also inherit from
* RuntimeException so they are unchecked :(
* While we're at it, we also insulate callers from the cases where DownloadManager is disabled,
* and getSystemService returns null.
*/
public class DownloadManagerWrapper {
private final static String TAG = DownloadManagerWrapper.class.getSimpleName();
private final DownloadManager mDownloadManager;
public DownloadManagerWrapper(final Context context) {
this((DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE));
}
private DownloadManagerWrapper(final DownloadManager downloadManager) {
mDownloadManager = downloadManager;
}
public void remove(final long... ids) {
try {
if (null != mDownloadManager) {
mDownloadManager.remove(ids);
}
} catch (IllegalArgumentException e) {
// This is expected to happen on boot when the device is encrypted.
} catch (SQLiteException e) {
// We couldn't remove the file from DownloadManager. Apparently, the database can't
// be opened. It may be a problem with file system corruption. In any case, there is
// not much we can do apart from avoiding crashing.
Log.e(TAG, "Can't remove files with ID " + ids + " from download manager", e);
}
}
public ParcelFileDescriptor openDownloadedFile(final long fileId) throws FileNotFoundException {
try {
if (null != mDownloadManager) {
return mDownloadManager.openDownloadedFile(fileId);
}
} catch (IllegalArgumentException e) {
// This is expected to happen on boot when the device is encrypted.
} catch (SQLiteException e) {
Log.e(TAG, "Can't open downloaded file with ID " + fileId, e);
}
// We come here if mDownloadManager is null or if an exception was thrown.
throw new FileNotFoundException();
}
@Nullable
public Cursor query(final Query query) {
try {
if (null != mDownloadManager) {
return mDownloadManager.query(query);
}
} catch (IllegalArgumentException e) {
// This is expected to happen on boot when the device is encrypted.
} catch (SQLiteException e) {
Log.e(TAG, "Can't query the download manager", e);
}
// We come here if mDownloadManager is null or if an exception was thrown.
return null;
}
public long enqueue(final Request request) {
try {
if (null != mDownloadManager) {
return mDownloadManager.enqueue(request);
}
} catch (IllegalArgumentException e) {
// This is expected to happen on boot when the device is encrypted.
} catch (SQLiteException e) {
Log.e(TAG, "Can't enqueue a request with the download manager", e);
}
return 0;
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.app.Activity;
import android.content.Intent;
import android.os.Bundle;
import android.text.Html;
import android.view.View;
import android.widget.Button;
import android.widget.TextView;
import org.dslul.openboard.inputmethod.annotations.ExternallyReferenced;
import org.dslul.openboard.inputmethod.latin.R;
import org.dslul.openboard.inputmethod.latin.common.LocaleUtils;
import javax.annotation.Nullable;
/**
* This implements the dialog for asking the user whether it's okay to download dictionaries over
* a metered connection or not (e.g. their mobile data plan).
*/
public final class DownloadOverMeteredDialog extends Activity {
final public static String CLIENT_ID_KEY = "client_id";
final public static String WORDLIST_TO_DOWNLOAD_KEY = "wordlist_to_download";
final public static String SIZE_KEY = "size";
final public static String LOCALE_KEY = "locale";
private String mClientId;
private String mWordListToDownload;
@Override
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
final Intent intent = getIntent();
mClientId = intent.getStringExtra(CLIENT_ID_KEY);
mWordListToDownload = intent.getStringExtra(WORDLIST_TO_DOWNLOAD_KEY);
final String localeString = intent.getStringExtra(LOCALE_KEY);
final long size = intent.getIntExtra(SIZE_KEY, 0);
setContentView(R.layout.download_over_metered);
setTexts(localeString, size);
}
private void setTexts(@Nullable final String localeString, final long size) {
final String promptFormat = getString(R.string.should_download_over_metered_prompt);
final String allowButtonFormat = getString(R.string.download_over_metered);
final String language = (null == localeString) ? ""
: LocaleUtils.constructLocaleFromString(localeString).getDisplayLanguage();
final TextView prompt = (TextView)findViewById(R.id.download_over_metered_prompt);
prompt.setText(Html.fromHtml(String.format(promptFormat, language)));
final Button allowButton = (Button)findViewById(R.id.allow_button);
allowButton.setText(String.format(allowButtonFormat, ((float)size)/(1024*1024)));
}
// This method is externally referenced from layout/download_over_metered.xml using onClick
// attribute of Button.
@ExternallyReferenced
@SuppressWarnings("unused")
public void onClickDeny(final View v) {
UpdateHandler.setDownloadOverMeteredSetting(this, false);
finish();
}
// This method is externally referenced from layout/download_over_metered.xml using onClick
// attribute of Button.
@ExternallyReferenced
@SuppressWarnings("unused")
public void onClickAllow(final View v) {
UpdateHandler.setDownloadOverMeteredSetting(this, true);
UpdateHandler.installIfNeverRequested(this, mClientId, mWordListToDownload);
finish();
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.content.ContentValues;
/**
* Struct class to encapsulate a client ID with content values about a download.
*/
public class DownloadRecord {
public final String mClientId;
// Only word lists have attributes, and the ContentValues should contain the same
// keys as they do for all MetadataDbHelper functions. Since only word lists have
// attributes, a null pointer here means this record represents metadata.
public final ContentValues mAttributes;
public DownloadRecord(final String clientId, final ContentValues attributes) {
mClientId = clientId;
mAttributes = attributes;
}
public boolean isMetadata() {
return null == mAttributes;
}
}

View file

@ -0,0 +1,46 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public final class EventHandler extends BroadcastReceiver {
/**
* Receives a intent broadcast.
*
* We receive every day a broadcast indicating that date changed.
* Then we wait a random amount of time before actually registering
* the download, to avoid concentrating too many accesses around
* midnight in more populated timezones.
* We receive all broadcasts here, so this can be either the DATE_CHANGED broadcast, the
* UPDATE_NOW private broadcast that we receive when the time-randomizing alarm triggers
* for regular update or from applications that want to test the dictionary pack, or a
* broadcast from DownloadManager telling that a download has finished.
* See inside of AndroidManifest.xml to see which events are caught.
* Also @see {@link BroadcastReceiver#onReceive(Context, Intent)}
*
* @param context the context of the application.
* @param intent the intent that was broadcast.
*/
@Override
public void onReceive(final Context context, final Intent intent) {
intent.setClass(context, DictionaryService.class);
context.startService(intent);
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.util.Log;
/**
* A very simple problem reporter.
*/
final class LogProblemReporter implements ProblemReporter {
private final String TAG;
public LogProblemReporter(final String tag) {
TAG = tag;
}
@Override
public void report(final Exception e) {
Log.e(TAG, "Reporting problem", e);
}
}

View file

@ -0,0 +1,46 @@
/**
* Copyright (C) 2012 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import java.io.InputStream;
import java.io.IOException;
import java.security.MessageDigest;
public final class MD5Calculator {
private MD5Calculator() {} // This helper class is not instantiable
public static String checksum(final InputStream in) throws IOException {
// This code from the Android documentation for MessageDigest. Nearly verbatim.
MessageDigest digester;
try {
digester = MessageDigest.getInstance("MD5");
} catch (java.security.NoSuchAlgorithmException e) {
return null; // Platform does not support MD5 : can't check, so return null
}
final byte[] bytes = new byte[8192];
int byteCount;
while ((byteCount = in.read(bytes)) > 0) {
digester.update(bytes, 0, byteCount);
}
final byte[] digest = digester.digest();
final StringBuilder s = new StringBuilder();
for (int i = 0; i < digest.length; ++i) {
s.append(String.format("%1$02x", digest[i]));
}
return s.toString();
}
}

View file

@ -0,0 +1,173 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.util.Log;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.Collections;
import java.util.ArrayList;
import java.util.List;
/**
* Helper class to easy up manipulation of dictionary pack metadata.
*/
public class MetadataHandler {
public static final String TAG = MetadataHandler.class.getSimpleName();
// The canonical file name for metadata. This is not the name of a real file on the
// device, but a symbolic name used in the database and in metadata handling. It is never
// tested against, only used for human-readability as the file name for the metadata.
public static final String METADATA_FILENAME = "metadata.json";
/**
* Reads the data from the cursor and store it in metadata objects.
* @param results the cursor to read data from.
* @return the constructed list of wordlist metadata.
*/
private static List<WordListMetadata> makeMetadataObject(final Cursor results) {
final ArrayList<WordListMetadata> buildingMetadata = new ArrayList<>();
if (null != results && results.moveToFirst()) {
final int localeColumn = results.getColumnIndex(MetadataDbHelper.LOCALE_COLUMN);
final int typeColumn = results.getColumnIndex(MetadataDbHelper.TYPE_COLUMN);
final int descriptionColumn =
results.getColumnIndex(MetadataDbHelper.DESCRIPTION_COLUMN);
final int idIndex = results.getColumnIndex(MetadataDbHelper.WORDLISTID_COLUMN);
final int updateIndex = results.getColumnIndex(MetadataDbHelper.DATE_COLUMN);
final int fileSizeIndex = results.getColumnIndex(MetadataDbHelper.FILESIZE_COLUMN);
final int rawChecksumIndex =
results.getColumnIndex(MetadataDbHelper.RAW_CHECKSUM_COLUMN);
final int checksumIndex = results.getColumnIndex(MetadataDbHelper.CHECKSUM_COLUMN);
final int retryCountIndex = results.getColumnIndex(MetadataDbHelper.RETRY_COUNT_COLUMN);
final int localFilenameIndex =
results.getColumnIndex(MetadataDbHelper.LOCAL_FILENAME_COLUMN);
final int remoteFilenameIndex =
results.getColumnIndex(MetadataDbHelper.REMOTE_FILENAME_COLUMN);
final int versionIndex = results.getColumnIndex(MetadataDbHelper.VERSION_COLUMN);
final int formatVersionIndex =
results.getColumnIndex(MetadataDbHelper.FORMATVERSION_COLUMN);
do {
buildingMetadata.add(new WordListMetadata(results.getString(idIndex),
results.getInt(typeColumn),
results.getString(descriptionColumn),
results.getLong(updateIndex),
results.getLong(fileSizeIndex),
results.getString(rawChecksumIndex),
results.getString(checksumIndex),
results.getInt(retryCountIndex),
results.getString(localFilenameIndex),
results.getString(remoteFilenameIndex),
results.getInt(versionIndex),
results.getInt(formatVersionIndex),
0, results.getString(localeColumn)));
} while (results.moveToNext());
}
return Collections.unmodifiableList(buildingMetadata);
}
/**
* Gets the whole metadata, for installed and not installed dictionaries.
* @param context The context to open files over.
* @param clientId the client id for retrieving the database. null for default (deprecated)
* @return The current metadata.
*/
public static List<WordListMetadata> getCurrentMetadata(final Context context,
final String clientId) {
// If clientId is null, we get a cursor on the default database (see
// MetadataDbHelper#getInstance() for more on this)
final Cursor results = MetadataDbHelper.queryCurrentMetadata(context, clientId);
// If null, we should return makeMetadataObject(null), so we go through.
try {
return makeMetadataObject(results);
} finally {
if (null != results) {
results.close();
}
}
}
/**
* Gets the metadata, for a specific dictionary.
*
* @param context The context to open files over.
* @param clientId the client id for retrieving the database. null for default (deprecated).
* @param wordListId the word list ID.
* @param version the word list version.
* @return the current metaData
*/
public static WordListMetadata getCurrentMetadataForWordList(final Context context,
final String clientId, final String wordListId, final int version) {
final ContentValues contentValues = MetadataDbHelper.getContentValuesByWordListId(
MetadataDbHelper.getDb(context, clientId), wordListId, version);
if (contentValues == null) {
// TODO: Figure out why this would happen.
// Check if this happens when the metadata gets updated in the background.
Log.e(TAG, String.format( "Unable to find the current metadata for wordlist "
+ "(clientId=%s, wordListId=%s, version=%d) on the database",
clientId, wordListId, version));
return null;
}
return WordListMetadata.createFromContentValues(contentValues);
}
/**
* Read metadata from a stream.
* @param input The stream to read from.
* @return The read metadata.
* @throws IOException if the input stream cannot be read
* @throws BadFormatException if the stream is not in a known format
*/
public static List<WordListMetadata> readMetadata(final InputStreamReader input)
throws IOException, BadFormatException {
return MetadataParser.parseMetadata(input);
}
/**
* Finds a single WordListMetadata inside a whole metadata chunk.
*
* Searches through the whole passed metadata for the first WordListMetadata associated
* with the passed ID. If several metadata chunks with the same id are found, it will
* always return the one with the bigger FormatVersion that is less or equal than the
* maximum supported format version (as listed in UpdateHandler).
* This will NEVER return the metadata with a FormatVersion bigger than what is supported,
* even if it is the only word list with this ID.
*
* @param metadata the metadata to search into.
* @param id the word list ID of the metadata to find.
* @return the associated metadata, or null if not found.
*/
public static WordListMetadata findWordListById(final List<WordListMetadata> metadata,
final String id) {
WordListMetadata bestWordList = null;
int bestFormatVersion = Integer.MIN_VALUE; // To be sure we can't be inadvertently smaller
for (WordListMetadata wordList : metadata) {
if (id.equals(wordList.mId)
&& wordList.mFormatVersion <= UpdateHandler.MAXIMUM_SUPPORTED_FORMAT_VERSION
&& wordList.mFormatVersion > bestFormatVersion) {
bestWordList = wordList;
bestFormatVersion = wordList.mFormatVersion;
}
}
// If we didn't find any match we'll return null.
return bestWordList;
}
}

View file

@ -0,0 +1,114 @@
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.text.TextUtils;
import android.util.JsonReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.TreeMap;
/**
* Helper class containing functions to parse the dictionary metadata.
*/
public class MetadataParser {
// Name of the fields in the JSON-formatted file.
private static final String ID_FIELD_NAME = MetadataDbHelper.WORDLISTID_COLUMN;
private static final String LOCALE_FIELD_NAME = "locale";
private static final String DESCRIPTION_FIELD_NAME = MetadataDbHelper.DESCRIPTION_COLUMN;
private static final String UPDATE_FIELD_NAME = "update";
private static final String FILESIZE_FIELD_NAME = MetadataDbHelper.FILESIZE_COLUMN;
private static final String RAW_CHECKSUM_FIELD_NAME = MetadataDbHelper.RAW_CHECKSUM_COLUMN;
private static final String CHECKSUM_FIELD_NAME = MetadataDbHelper.CHECKSUM_COLUMN;
private static final String REMOTE_FILENAME_FIELD_NAME =
MetadataDbHelper.REMOTE_FILENAME_COLUMN;
private static final String VERSION_FIELD_NAME = MetadataDbHelper.VERSION_COLUMN;
private static final String FORMATVERSION_FIELD_NAME = MetadataDbHelper.FORMATVERSION_COLUMN;
/**
* Parse one JSON-formatted word list metadata.
* @param reader the reader containing the data.
* @return a WordListMetadata object from the parsed data.
* @throws IOException if the underlying reader throws IOException during reading.
*/
private static WordListMetadata parseOneWordList(final JsonReader reader)
throws IOException, BadFormatException {
final TreeMap<String, String> arguments = new TreeMap<>();
reader.beginObject();
while (reader.hasNext()) {
final String name = reader.nextName();
if (!TextUtils.isEmpty(name)) {
arguments.put(name, reader.nextString());
}
}
reader.endObject();
if (TextUtils.isEmpty(arguments.get(ID_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(LOCALE_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(DESCRIPTION_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(UPDATE_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(FILESIZE_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(CHECKSUM_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(REMOTE_FILENAME_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(VERSION_FIELD_NAME))
|| TextUtils.isEmpty(arguments.get(FORMATVERSION_FIELD_NAME))) {
throw new BadFormatException(arguments.toString());
}
// TODO: need to find out whether it's bulk or update
// The null argument is the local file name, which is not known at this time and will
// be decided later.
return new WordListMetadata(
arguments.get(ID_FIELD_NAME),
MetadataDbHelper.TYPE_BULK,
arguments.get(DESCRIPTION_FIELD_NAME),
Long.parseLong(arguments.get(UPDATE_FIELD_NAME)),
Long.parseLong(arguments.get(FILESIZE_FIELD_NAME)),
arguments.get(RAW_CHECKSUM_FIELD_NAME),
arguments.get(CHECKSUM_FIELD_NAME),
MetadataDbHelper.DICTIONARY_RETRY_THRESHOLD /* retryCount */,
null,
arguments.get(REMOTE_FILENAME_FIELD_NAME),
Integer.parseInt(arguments.get(VERSION_FIELD_NAME)),
Integer.parseInt(arguments.get(FORMATVERSION_FIELD_NAME)),
0, arguments.get(LOCALE_FIELD_NAME));
}
/**
* Parses metadata in the JSON format.
* @param input a stream reader expected to contain JSON formatted metadata.
* @return dictionary metadata, as an array of WordListMetadata objects.
* @throws IOException if the underlying reader throws IOException during reading.
* @throws BadFormatException if the data was not in the expected format.
*/
public static List<WordListMetadata> parseMetadata(final InputStreamReader input)
throws IOException, BadFormatException {
JsonReader reader = new JsonReader(input);
final ArrayList<WordListMetadata> readInfo = new ArrayList<>();
reader.beginArray();
while (reader.hasNext()) {
final WordListMetadata thisMetadata = parseOneWordList(reader);
if (!TextUtils.isEmpty(thisMetadata.mLocale))
readInfo.add(thisMetadata);
}
return Collections.unmodifiableList(readInfo);
}
}

View file

@ -0,0 +1,29 @@
/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package org.dslul.openboard.inputmethod.dictionarypack;
import android.content.Context;
/**
* Helper to get the metadata URI from its base URI.
*/
@SuppressWarnings("unused")
public class MetadataUriGetter {
public static String getUri(final Context context, final String baseUri) {
return baseUri;
}
}

Some files were not shown because too many files have changed in this diff Show more