diff options
Diffstat (limited to 'java/src/com/android/inputmethod/voice')
10 files changed, 1294 insertions, 135 deletions
diff --git a/java/src/com/android/inputmethod/voice/FieldContext.java b/java/src/com/android/inputmethod/voice/FieldContext.java index 5fbacfb6c..dfdfbaa9f 100644 --- a/java/src/com/android/inputmethod/voice/FieldContext.java +++ b/java/src/com/android/inputmethod/voice/FieldContext.java @@ -73,6 +73,7 @@ public class FieldContext { bundle.putInt(IME_OPTIONS, info.imeOptions); } + @SuppressWarnings("static-access") private static void addInputConnectionToBundle( InputConnection conn, Bundle bundle) { if (conn == null) { @@ -96,6 +97,7 @@ public class FieldContext { return mFieldInfo; } + @Override public String toString() { return mFieldInfo.toString(); } diff --git a/java/src/com/android/inputmethod/voice/Hints.java b/java/src/com/android/inputmethod/voice/Hints.java new file mode 100644 index 000000000..d11d3b042 --- /dev/null +++ b/java/src/com/android/inputmethod/voice/Hints.java @@ -0,0 +1,188 @@ +/* + * Copyright (C) 2009 Google Inc. + * + * 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 com.android.inputmethod.voice; + +import com.android.inputmethod.latin.R; +import com.android.inputmethod.latin.SharedPreferencesCompat; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.SharedPreferences; +import android.view.inputmethod.InputConnection; + +import java.util.Calendar; +import java.util.HashMap; +import java.util.Map; + +/** + * Logic to determine when to display hints on usage to the user. + */ +public class Hints { + public interface Display { + public void showHint(int viewResource); + } + + private static final String PREF_VOICE_HINT_NUM_UNIQUE_DAYS_SHOWN = + "voice_hint_num_unique_days_shown"; + private static final String PREF_VOICE_HINT_LAST_TIME_SHOWN = + "voice_hint_last_time_shown"; + private static final String PREF_VOICE_INPUT_LAST_TIME_USED = + "voice_input_last_time_used"; + private static final String PREF_VOICE_PUNCTUATION_HINT_VIEW_COUNT = + "voice_punctuation_hint_view_count"; + private static final int DEFAULT_SWIPE_HINT_MAX_DAYS_TO_SHOW = 7; + private static final int DEFAULT_PUNCTUATION_HINT_MAX_DISPLAYS = 7; + + private final Context mContext; + private final SharedPreferences mPrefs; + private final Display mDisplay; + private boolean mVoiceResultContainedPunctuation; + private int mSwipeHintMaxDaysToShow; + private int mPunctuationHintMaxDisplays; + + // Only show punctuation hint if voice result did not contain punctuation. + static final Map<CharSequence, String> SPEAKABLE_PUNCTUATION + = new HashMap<CharSequence, String>(); + static { + SPEAKABLE_PUNCTUATION.put(",", "comma"); + SPEAKABLE_PUNCTUATION.put(".", "period"); + SPEAKABLE_PUNCTUATION.put("?", "question mark"); + } + + public Hints(Context context, SharedPreferences prefs, Display display) { + mContext = context; + mPrefs = prefs; + mDisplay = display; + + ContentResolver cr = mContext.getContentResolver(); + mSwipeHintMaxDaysToShow = SettingsUtil.getSettingsInt( + cr, + SettingsUtil.LATIN_IME_VOICE_INPUT_SWIPE_HINT_MAX_DAYS, + DEFAULT_SWIPE_HINT_MAX_DAYS_TO_SHOW); + mPunctuationHintMaxDisplays = SettingsUtil.getSettingsInt( + cr, + SettingsUtil.LATIN_IME_VOICE_INPUT_PUNCTUATION_HINT_MAX_DISPLAYS, + DEFAULT_PUNCTUATION_HINT_MAX_DISPLAYS); + } + + public boolean showSwipeHintIfNecessary(boolean fieldRecommended) { + if (fieldRecommended && shouldShowSwipeHint()) { + showHint(R.layout.voice_swipe_hint); + return true; + } + + return false; + } + + public boolean showPunctuationHintIfNecessary(InputConnection ic) { + if (!mVoiceResultContainedPunctuation + && ic != null + && getAndIncrementPref(PREF_VOICE_PUNCTUATION_HINT_VIEW_COUNT) + < mPunctuationHintMaxDisplays) { + CharSequence charBeforeCursor = ic.getTextBeforeCursor(1, 0); + if (SPEAKABLE_PUNCTUATION.containsKey(charBeforeCursor)) { + showHint(R.layout.voice_punctuation_hint); + return true; + } + } + + return false; + } + + public void registerVoiceResult(String text) { + // Update the current time as the last time voice input was used. + SharedPreferences.Editor editor = mPrefs.edit(); + editor.putLong(PREF_VOICE_INPUT_LAST_TIME_USED, System.currentTimeMillis()); + SharedPreferencesCompat.apply(editor); + + mVoiceResultContainedPunctuation = false; + for (CharSequence s : SPEAKABLE_PUNCTUATION.keySet()) { + if (text.indexOf(s.toString()) >= 0) { + mVoiceResultContainedPunctuation = true; + break; + } + } + } + + private boolean shouldShowSwipeHint() { + final SharedPreferences prefs = mPrefs; + + int numUniqueDaysShown = prefs.getInt(PREF_VOICE_HINT_NUM_UNIQUE_DAYS_SHOWN, 0); + + // If we've already shown the hint for enough days, we'll return false. + if (numUniqueDaysShown < mSwipeHintMaxDaysToShow) { + + long lastTimeVoiceWasUsed = prefs.getLong(PREF_VOICE_INPUT_LAST_TIME_USED, 0); + + // If the user has used voice today, we'll return false. (We don't show the hint on + // any day that the user has already used voice.) + if (!isFromToday(lastTimeVoiceWasUsed)) { + return true; + } + } + + return false; + } + + /** + * Determines whether the provided time is from some time today (i.e., this day, month, + * and year). + */ + private boolean isFromToday(long timeInMillis) { + if (timeInMillis == 0) return false; + + Calendar today = Calendar.getInstance(); + today.setTimeInMillis(System.currentTimeMillis()); + + Calendar timestamp = Calendar.getInstance(); + timestamp.setTimeInMillis(timeInMillis); + + return (today.get(Calendar.YEAR) == timestamp.get(Calendar.YEAR) && + today.get(Calendar.DAY_OF_MONTH) == timestamp.get(Calendar.DAY_OF_MONTH) && + today.get(Calendar.MONTH) == timestamp.get(Calendar.MONTH)); + } + + private void showHint(int hintViewResource) { + final SharedPreferences prefs = mPrefs; + + int numUniqueDaysShown = prefs.getInt(PREF_VOICE_HINT_NUM_UNIQUE_DAYS_SHOWN, 0); + long lastTimeHintWasShown = prefs.getLong(PREF_VOICE_HINT_LAST_TIME_SHOWN, 0); + + // If this is the first time the hint is being shown today, increase the saved values + // to represent that. We don't need to increase the last time the hint was shown unless + // it is a different day from the current value. + if (!isFromToday(lastTimeHintWasShown)) { + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(PREF_VOICE_HINT_NUM_UNIQUE_DAYS_SHOWN, numUniqueDaysShown + 1); + editor.putLong(PREF_VOICE_HINT_LAST_TIME_SHOWN, System.currentTimeMillis()); + SharedPreferencesCompat.apply(editor); + } + + if (mDisplay != null) { + mDisplay.showHint(hintViewResource); + } + } + + private int getAndIncrementPref(String pref) { + final SharedPreferences prefs = mPrefs; + int value = prefs.getInt(pref, 0); + SharedPreferences.Editor editor = prefs.edit(); + editor.putInt(pref, value + 1); + SharedPreferencesCompat.apply(editor); + return value; + } +} diff --git a/java/src/com/android/inputmethod/voice/RecognitionView.java b/java/src/com/android/inputmethod/voice/RecognitionView.java index 7cec0b04a..95a79f463 100644 --- a/java/src/com/android/inputmethod/voice/RecognitionView.java +++ b/java/src/com/android/inputmethod/voice/RecognitionView.java @@ -16,14 +16,9 @@ package com.android.inputmethod.voice; -import java.io.ByteArrayOutputStream; -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.ShortBuffer; -import java.util.ArrayList; -import java.util.List; +import com.android.inputmethod.latin.R; +import com.android.inputmethod.latin.SubtypeSwitcher; -import android.content.ContentResolver; import android.content.Context; import android.content.res.Resources; import android.graphics.Bitmap; @@ -34,16 +29,20 @@ import android.graphics.Path; import android.graphics.PathEffect; import android.graphics.drawable.Drawable; import android.os.Handler; -import android.util.TypedValue; +import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.View.OnClickListener; -import android.view.ViewGroup.MarginLayoutParams; +import android.widget.Button; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; -import com.android.inputmethod.latin.R; +import java.io.ByteArrayOutputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.nio.ShortBuffer; +import java.util.Locale; /** * The user interface for the "Speak now" and "working" states. @@ -51,86 +50,64 @@ import com.android.inputmethod.latin.R; * plays beeps, shows errors, etc. */ public class RecognitionView { + @SuppressWarnings("unused") private static final String TAG = "RecognitionView"; private Handler mUiHandler; // Reference to UI thread private View mView; private Context mContext; - private ImageView mImage; private TextView mText; - private View mButton; - private TextView mButtonText; + private ImageView mImage; private View mProgress; + private SoundIndicator mSoundIndicator; + private TextView mLanguage; + private Button mButton; private Drawable mInitializing; private Drawable mError; - private List<Drawable> mSpeakNow; - - private float mVolume = 0.0f; - private int mLevel = 0; - - private enum State {LISTENING, WORKING, READY} - private State mState = State.READY; - - private float mMinMicrophoneLevel; - private float mMaxMicrophoneLevel; - /** Updates the microphone icon to show user their volume.*/ - private Runnable mUpdateVolumeRunnable = new Runnable() { - public void run() { - if (mState != State.LISTENING) { - return; - } - - final float min = mMinMicrophoneLevel; - final float max = mMaxMicrophoneLevel; - final int maxLevel = mSpeakNow.size() - 1; + private static final int INIT = 0; + private static final int LISTENING = 1; + private static final int WORKING = 2; + private static final int READY = 3; + + private int mState = INIT; - int index = (int) ((mVolume - min) / (max - min) * maxLevel); - final int level = Math.min(Math.max(0, index), maxLevel); + private final View mPopupLayout; - if (level != mLevel) { - mImage.setImageDrawable(mSpeakNow.get(level)); - mLevel = level; - } - mUiHandler.postDelayed(mUpdateVolumeRunnable, 50); - } - }; + private final Drawable mListeningBorder; + private final Drawable mWorkingBorder; + private final Drawable mErrorBorder; public RecognitionView(Context context, OnClickListener clickListener) { mUiHandler = new Handler(); LayoutInflater inflater = (LayoutInflater) context.getSystemService( - Context.LAYOUT_INFLATER_SERVICE); + Context.LAYOUT_INFLATER_SERVICE); + mView = inflater.inflate(R.layout.recognition_status, null); - ContentResolver cr = context.getContentResolver(); - mMinMicrophoneLevel = SettingsUtil.getSettingsFloat( - cr, SettingsUtil.LATIN_IME_MIN_MICROPHONE_LEVEL, 15.f); - mMaxMicrophoneLevel = SettingsUtil.getSettingsFloat( - cr, SettingsUtil.LATIN_IME_MAX_MICROPHONE_LEVEL, 30.f); + + mPopupLayout= mView.findViewById(R.id.popup_layout); // Pre-load volume level images Resources r = context.getResources(); - mSpeakNow = new ArrayList<Drawable>(); - mSpeakNow.add(r.getDrawable(R.drawable.speak_now_level0)); - mSpeakNow.add(r.getDrawable(R.drawable.speak_now_level1)); - mSpeakNow.add(r.getDrawable(R.drawable.speak_now_level2)); - mSpeakNow.add(r.getDrawable(R.drawable.speak_now_level3)); - mSpeakNow.add(r.getDrawable(R.drawable.speak_now_level4)); - mSpeakNow.add(r.getDrawable(R.drawable.speak_now_level5)); - mSpeakNow.add(r.getDrawable(R.drawable.speak_now_level6)); + mListeningBorder = r.getDrawable(R.drawable.vs_dialog_red); + mWorkingBorder = r.getDrawable(R.drawable.vs_dialog_blue); + mErrorBorder = r.getDrawable(R.drawable.vs_dialog_yellow); mInitializing = r.getDrawable(R.drawable.mic_slash); mError = r.getDrawable(R.drawable.caution); mImage = (ImageView) mView.findViewById(R.id.image); - mButton = mView.findViewById(R.id.button); + mProgress = mView.findViewById(R.id.progress); + mSoundIndicator = (SoundIndicator) mView.findViewById(R.id.sound_indicator); + + mButton = (Button) mView.findViewById(R.id.button); mButton.setOnClickListener(clickListener); mText = (TextView) mView.findViewById(R.id.text); - mButtonText = (TextView) mView.findViewById(R.id.button_text); - mProgress = mView.findViewById(R.id.progress); + mLanguage = (TextView) mView.findViewById(R.id.language); mContext = context; } @@ -141,11 +118,12 @@ public class RecognitionView { public void restoreState() { mUiHandler.post(new Runnable() { + @Override public void run() { // Restart the spinner - if (mState == State.WORKING) { - ((ProgressBar)mProgress).setIndeterminate(false); - ((ProgressBar)mProgress).setIndeterminate(true); + if (mState == WORKING) { + ((ProgressBar) mProgress).setIndeterminate(false); + ((ProgressBar) mProgress).setIndeterminate(true); } } }); @@ -153,46 +131,50 @@ public class RecognitionView { public void showInitializing() { mUiHandler.post(new Runnable() { + @Override public void run() { - prepareDialog(false, mContext.getText(R.string.voice_initializing), mInitializing, - mContext.getText(R.string.cancel)); + mState = INIT; + prepareDialog(mContext.getText(R.string.voice_initializing), mInitializing, + mContext.getText(R.string.cancel)); } }); } public void showListening() { + Log.d(TAG, "#showListening"); mUiHandler.post(new Runnable() { + @Override public void run() { - mState = State.LISTENING; - prepareDialog(false, mContext.getText(R.string.voice_listening), mSpeakNow.get(0), + mState = LISTENING; + prepareDialog(mContext.getText(R.string.voice_listening), null, mContext.getText(R.string.cancel)); } }); - mUiHandler.postDelayed(mUpdateVolumeRunnable, 50); } - public void updateVoiceMeter(final float rmsdB) { - mVolume = rmsdB; + public void updateVoiceMeter(float rmsdB) { + mSoundIndicator.setRmsdB(rmsdB); } public void showError(final String message) { mUiHandler.post(new Runnable() { + @Override public void run() { - mState = State.READY; - prepareDialog(false, message, mError, mContext.getText(R.string.ok)); + mState = READY; + prepareDialog(message, mError, mContext.getText(R.string.ok)); } - }); + }); } public void showWorking( final ByteArrayOutputStream waveBuffer, final int speechStartPosition, final int speechEndPosition) { - mUiHandler.post(new Runnable() { + @Override public void run() { - mState = State.WORKING; - prepareDialog(true, mContext.getText(R.string.voice_working), null, mContext + mState = WORKING; + prepareDialog(mContext.getText(R.string.voice_working), null, mContext .getText(R.string.cancel)); final ShortBuffer buf = ByteBuffer.wrap(waveBuffer.toByteArray()).order( ByteOrder.nativeOrder()).asShortBuffer(); @@ -200,21 +182,87 @@ public class RecognitionView { waveBuffer.reset(); showWave(buf, speechStartPosition / 2, speechEndPosition / 2); } - }); + }); } - private void prepareDialog(boolean spinVisible, CharSequence text, Drawable image, + private void prepareDialog(CharSequence text, Drawable image, CharSequence btnTxt) { - if (spinVisible) { - mProgress.setVisibility(View.VISIBLE); - mImage.setVisibility(View.GONE); - } else { - mProgress.setVisibility(View.GONE); - mImage.setImageDrawable(image); - mImage.setVisibility(View.VISIBLE); + + /* + * The mic of INIT and of LISTENING has to be displayed in the same position. To accomplish + * that, some text visibility are not set as GONE but as INVISIBLE. + */ + switch (mState) { + case INIT: + mText.setVisibility(View.INVISIBLE); + + mProgress.setVisibility(View.GONE); + + mImage.setVisibility(View.VISIBLE); + mImage.setImageResource(R.drawable.mic_slash); + + mSoundIndicator.setVisibility(View.GONE); + mSoundIndicator.stop(); + + mLanguage.setVisibility(View.INVISIBLE); + + mPopupLayout.setBackgroundDrawable(mListeningBorder); + break; + case LISTENING: + mText.setVisibility(View.VISIBLE); + mText.setText(text); + + mProgress.setVisibility(View.GONE); + + mImage.setVisibility(View.GONE); + + mSoundIndicator.setVisibility(View.VISIBLE); + mSoundIndicator.start(); + + Locale locale = SubtypeSwitcher.getInstance().getInputLocale(); + + mLanguage.setVisibility(View.VISIBLE); + mLanguage.setText(SubtypeSwitcher.getFullDisplayName(locale, true)); + + mPopupLayout.setBackgroundDrawable(mListeningBorder); + break; + case WORKING: + + mText.setVisibility(View.VISIBLE); + mText.setText(text); + + mProgress.setVisibility(View.VISIBLE); + + mImage.setVisibility(View.VISIBLE); + + mSoundIndicator.setVisibility(View.GONE); + mSoundIndicator.stop(); + + mLanguage.setVisibility(View.GONE); + + mPopupLayout.setBackgroundDrawable(mWorkingBorder); + break; + case READY: + mText.setVisibility(View.VISIBLE); + mText.setText(text); + + mProgress.setVisibility(View.GONE); + + mImage.setVisibility(View.VISIBLE); + mImage.setImageResource(R.drawable.caution); + + mSoundIndicator.setVisibility(View.GONE); + mSoundIndicator.stop(); + + mLanguage.setVisibility(View.GONE); + + mPopupLayout.setBackgroundDrawable(mErrorBorder); + break; + default: + Log.w(TAG, "Unknown state " + mState); } - mText.setText(text); - mButtonText.setText(btnTxt); + mPopupLayout.requestLayout(); + mButton.setText(btnTxt); } /** @@ -241,7 +289,7 @@ public class RecognitionView { */ private void showWave(ShortBuffer waveBuffer, int startPosition, int endPosition) { final int w = ((View) mImage.getParent()).getWidth(); - final int h = mImage.getHeight(); + final int h = ((View) mImage.getParent()).getHeight(); if (w <= 0 || h <= 0) { // view is not visible this time. Skip drawing. return; @@ -252,7 +300,7 @@ public class RecognitionView { paint.setColor(0xFFFFFFFF); // 0xAARRGGBB paint.setAntiAlias(true); paint.setStyle(Paint.Style.STROKE); - paint.setAlpha(0x90); + paint.setAlpha(80); final PathEffect effect = new CornerPathEffect(3); paint.setPathEffect(effect); @@ -274,7 +322,7 @@ public class RecognitionView { final int count = (endIndex - startIndex) / numSamplePerWave; final float deltaX = 1.0f * w / count; - int yMax = h / 2 - 8; + int yMax = h / 2; Path path = new Path(); c.translate(0, yMax); float x = 0; @@ -288,36 +336,20 @@ public class RecognitionView { path.lineTo(x, y); } if (deltaX > 4) { - paint.setStrokeWidth(3); + paint.setStrokeWidth(2); } else { - paint.setStrokeWidth(Math.max(1, (int) (deltaX -.05))); + paint.setStrokeWidth(Math.max(0, (int) (deltaX -.05))); } c.drawPath(path, paint); mImage.setImageBitmap(b); - mImage.setVisibility(View.VISIBLE); - MarginLayoutParams mProgressParams = (MarginLayoutParams)mProgress.getLayoutParams(); - mProgressParams.topMargin = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_PX, - -h , mContext.getResources().getDisplayMetrics()); - - // Tweak the padding manually to fill out the whole view horizontally. - // TODO: Do this in the xml layout instead. - ((View) mImage.getParent()).setPadding(4, ((View) mImage.getParent()).getPaddingTop(), 3, - ((View) mImage.getParent()).getPaddingBottom()); - mProgress.setLayoutParams(mProgressParams); } - public void finish() { mUiHandler.post(new Runnable() { + @Override public void run() { - mState = State.READY; - exitWorking(); + mSoundIndicator.stop(); } - }); - } - - private void exitWorking() { - mProgress.setVisibility(View.GONE); - mImage.setVisibility(View.VISIBLE); + }); } } diff --git a/java/src/com/android/inputmethod/voice/SettingsUtil.java b/java/src/com/android/inputmethod/voice/SettingsUtil.java index abf52047f..4d746e120 100644 --- a/java/src/com/android/inputmethod/voice/SettingsUtil.java +++ b/java/src/com/android/inputmethod/voice/SettingsUtil.java @@ -17,10 +17,7 @@ package com.android.inputmethod.voice; import android.content.ContentResolver; -import android.database.Cursor; -import android.net.Uri; import android.provider.Settings; -import android.util.Log; /** * Utility for retrieving settings from Settings.Secure. diff --git a/java/src/com/android/inputmethod/voice/SoundIndicator.java b/java/src/com/android/inputmethod/voice/SoundIndicator.java new file mode 100644 index 000000000..543290b32 --- /dev/null +++ b/java/src/com/android/inputmethod/voice/SoundIndicator.java @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2011 Google Inc. + * + * 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 com.android.inputmethod.voice; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Bitmap.Config; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.os.Handler; +import android.util.AttributeSet; +import android.widget.ImageView; + +import com.android.inputmethod.latin.R; + +/** + * A widget which shows the volume of audio using a microphone icon + */ +public class SoundIndicator extends ImageView { + @SuppressWarnings("unused") + private static final String TAG = "SoundIndicator"; + + private static final float UP_SMOOTHING_FACTOR = 0.9f; + private static final float DOWN_SMOOTHING_FACTOR = 0.4f; + + private static final float AUDIO_METER_MIN_DB = 7.0f; + private static final float AUDIO_METER_DB_RANGE = 20.0f; + + private static final long FRAME_DELAY = 50; + + private Bitmap mDrawingBuffer; + private Canvas mBufferCanvas; + private Bitmap mEdgeBitmap; + private float mLevel = 0.0f; + private Drawable mFrontDrawable; + private Paint mClearPaint; + private Paint mMultPaint; + private int mEdgeBitmapOffset; + + private Handler mHandler; + + private Runnable mDrawFrame = new Runnable() { + public void run() { + invalidate(); + mHandler.postDelayed(mDrawFrame, FRAME_DELAY); + } + }; + + public SoundIndicator(Context context) { + this(context, null); + } + + public SoundIndicator(Context context, AttributeSet attrs) { + super(context, attrs); + + mFrontDrawable = getDrawable(); + BitmapDrawable edgeDrawable = + (BitmapDrawable) context.getResources().getDrawable(R.drawable.vs_popup_mic_edge); + mEdgeBitmap = edgeDrawable.getBitmap(); + mEdgeBitmapOffset = mEdgeBitmap.getHeight() / 2; + + mDrawingBuffer = + Bitmap.createBitmap(mFrontDrawable.getIntrinsicWidth(), + mFrontDrawable.getIntrinsicHeight(), Config.ARGB_8888); + + mBufferCanvas = new Canvas(mDrawingBuffer); + + // Initialize Paints. + mClearPaint = new Paint(); + mClearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR)); + + mMultPaint = new Paint(); + mMultPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY)); + + mHandler = new Handler(); + } + + @Override + public void onDraw(Canvas canvas) { + //super.onDraw(canvas); + + float w = getWidth(); + float h = getHeight(); + + // Clear the buffer canvas + mBufferCanvas.drawRect(0, 0, w, h, mClearPaint); + + // Set its clip so we don't draw the front image all the way to the top + Rect clip = new Rect(0, + (int) ((1.0 - mLevel) * (h + mEdgeBitmapOffset)) - mEdgeBitmapOffset, + (int) w, + (int) h); + + mBufferCanvas.save(); + mBufferCanvas.clipRect(clip); + + // Draw the front image + mFrontDrawable.setBounds(new Rect(0, 0, (int) w, (int) h)); + mFrontDrawable.draw(mBufferCanvas); + + mBufferCanvas.restore(); + + // Draw the edge image on top of the buffer image with a multiply mode + mBufferCanvas.drawBitmap(mEdgeBitmap, 0, clip.top, mMultPaint); + + // Draw the buffer image (on top of the background image) + canvas.drawBitmap(mDrawingBuffer, 0, 0, null); + } + + /** + * Sets the sound level + * + * @param rmsdB The level of the sound, in dB. + */ + public void setRmsdB(float rmsdB) { + float level = ((rmsdB - AUDIO_METER_MIN_DB) / AUDIO_METER_DB_RANGE); + + level = Math.min(Math.max(0.0f, level), 1.0f); + + // We smooth towards the new level + if (level > mLevel) { + mLevel = (level - mLevel) * UP_SMOOTHING_FACTOR + mLevel; + } else { + mLevel = (level - mLevel) * DOWN_SMOOTHING_FACTOR + mLevel; + } + invalidate(); + } + + public void start() { + mHandler.post(mDrawFrame); + } + + public void stop() { + mHandler.removeCallbacks(mDrawFrame); + } +} diff --git a/java/src/com/android/inputmethod/voice/VoiceIMEConnector.java b/java/src/com/android/inputmethod/voice/VoiceIMEConnector.java new file mode 100644 index 000000000..61a194a8d --- /dev/null +++ b/java/src/com/android/inputmethod/voice/VoiceIMEConnector.java @@ -0,0 +1,743 @@ +/* + * Copyright (C) 2010 Google Inc. + * + * 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 com.android.inputmethod.voice; + +import com.android.inputmethod.keyboard.KeyboardSwitcher; +import com.android.inputmethod.latin.EditingUtils; +import com.android.inputmethod.latin.LatinIME; +import com.android.inputmethod.latin.LatinIME.UIHandler; +import com.android.inputmethod.latin.LatinImeLogger; +import com.android.inputmethod.latin.R; +import com.android.inputmethod.latin.SharedPreferencesCompat; +import com.android.inputmethod.latin.SubtypeSwitcher; +import com.android.inputmethod.latin.SuggestedWords; + +import android.app.AlertDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.net.Uri; +import android.os.AsyncTask; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.provider.Browser; +import android.speech.SpeechRecognizer; +import android.text.Layout; +import android.text.Selection; +import android.text.Spannable; +import android.text.TextUtils; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.URLSpan; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.ExtractedTextRequest; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.TextView; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class VoiceIMEConnector implements VoiceInput.UiListener { + private static final VoiceIMEConnector sInstance = new VoiceIMEConnector(); + + public static final boolean VOICE_INSTALLED = true; + private static final boolean ENABLE_VOICE_BUTTON = true; + private static final String PREF_VOICE_MODE = "voice_mode"; + // Whether or not the user has used voice input before (and thus, whether to show the + // first-run warning dialog or not). + private static final String PREF_HAS_USED_VOICE_INPUT = "has_used_voice_input"; + // Whether or not the user has used voice input from an unsupported locale UI before. + // For example, the user has a Chinese UI but activates voice input. + private static final String PREF_HAS_USED_VOICE_INPUT_UNSUPPORTED_LOCALE = + "has_used_voice_input_unsupported_locale"; + // The private IME option used to indicate that no microphone should be shown for a + // given text field. For instance this is specified by the search dialog when the + // dialog is already showing a voice search button. + private static final String IME_OPTION_NO_MICROPHONE = "nm"; + private static final int RECOGNITIONVIEW_HEIGHT_THRESHOLD_RATIO = 6; + + @SuppressWarnings("unused") + private static final String TAG = "VoiceIMEConnector"; + private static boolean DEBUG = LatinImeLogger.sDBG; + + private boolean mAfterVoiceInput; + private boolean mHasUsedVoiceInput; + private boolean mHasUsedVoiceInputUnsupportedLocale; + private boolean mImmediatelyAfterVoiceInput; + private boolean mIsShowingHint; + private boolean mLocaleSupportedForVoiceInput; + private boolean mPasswordText; + private boolean mRecognizing; + private boolean mShowingVoiceSuggestions; + private boolean mVoiceButtonEnabled; + private boolean mVoiceButtonOnPrimary; + private boolean mVoiceInputHighlighted; + + private InputMethodManager mImm; + private LatinIME mService; + private AlertDialog mVoiceWarningDialog; + private VoiceInput mVoiceInput; + private final VoiceResults mVoiceResults = new VoiceResults(); + private Hints mHints; + private UIHandler mHandler; + private SubtypeSwitcher mSubtypeSwitcher; + // For each word, a list of potential replacements, usually from voice. + private final Map<String, List<CharSequence>> mWordToSuggestions = + new HashMap<String, List<CharSequence>>(); + + public static VoiceIMEConnector init(LatinIME context, SharedPreferences prefs, UIHandler h) { + sInstance.initInternal(context, prefs, h); + return sInstance; + } + + public static VoiceIMEConnector getInstance() { + return sInstance; + } + + private void initInternal(LatinIME service, SharedPreferences prefs, UIHandler h) { + mService = service; + mHandler = h; + mImm = (InputMethodManager) service.getSystemService(Context.INPUT_METHOD_SERVICE); + mSubtypeSwitcher = SubtypeSwitcher.getInstance(); + if (VOICE_INSTALLED) { + mVoiceInput = new VoiceInput(service, this); + mHints = new Hints(service, prefs, new Hints.Display() { + @Override + public void showHint(int viewResource) { + View view = LayoutInflater.from(mService).inflate(viewResource, null); + mService.setCandidatesView(view); + mService.setCandidatesViewShown(true); + mIsShowingHint = true; + } + }); + } + } + + private VoiceIMEConnector() { + // Intentional empty constructor for singleton. + } + + public void resetVoiceStates(boolean isPasswordText) { + mAfterVoiceInput = false; + mImmediatelyAfterVoiceInput = false; + mShowingVoiceSuggestions = false; + mVoiceInputHighlighted = false; + mPasswordText = isPasswordText; + } + + public void flushVoiceInputLogs(boolean configurationChanged) { + if (VOICE_INSTALLED && !configurationChanged) { + if (mAfterVoiceInput) { + mVoiceInput.flushAllTextModificationCounters(); + mVoiceInput.logInputEnded(); + } + mVoiceInput.flushLogs(); + mVoiceInput.cancel(); + } + } + + public void flushAndLogAllTextModificationCounters(int index, CharSequence suggestion, + String wordSeparators) { + if (mAfterVoiceInput && mShowingVoiceSuggestions) { + mVoiceInput.flushAllTextModificationCounters(); + // send this intent AFTER logging any prior aggregated edits. + mVoiceInput.logTextModifiedByChooseSuggestion(suggestion.toString(), index, + wordSeparators, mService.getCurrentInputConnection()); + } + } + + private void showVoiceWarningDialog(final boolean swipe, IBinder token) { + if (mVoiceWarningDialog != null && mVoiceWarningDialog.isShowing()) { + return; + } + AlertDialog.Builder builder = new AlertDialog.Builder(mService); + builder.setCancelable(true); + builder.setIcon(R.drawable.ic_mic_dialog); + builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + mVoiceInput.logKeyboardWarningDialogOk(); + reallyStartListening(swipe); + } + }); + builder.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int whichButton) { + mVoiceInput.logKeyboardWarningDialogCancel(); + switchToLastInputMethod(); + } + }); + // When the dialog is dismissed by user's cancellation, switch back to the last input method + builder.setOnCancelListener(new DialogInterface.OnCancelListener() { + @Override + public void onCancel(DialogInterface arg0) { + mVoiceInput.logKeyboardWarningDialogCancel(); + switchToLastInputMethod(); + } + }); + + final CharSequence message; + if (mLocaleSupportedForVoiceInput) { + message = TextUtils.concat( + mService.getText(R.string.voice_warning_may_not_understand), "\n\n", + mService.getText(R.string.voice_warning_how_to_turn_off)); + } else { + message = TextUtils.concat( + mService.getText(R.string.voice_warning_locale_not_supported), "\n\n", + mService.getText(R.string.voice_warning_may_not_understand), "\n\n", + mService.getText(R.string.voice_warning_how_to_turn_off)); + } + builder.setMessage(message); + + builder.setTitle(R.string.voice_warning_title); + mVoiceWarningDialog = builder.create(); + Window window = mVoiceWarningDialog.getWindow(); + WindowManager.LayoutParams lp = window.getAttributes(); + lp.token = token; + lp.type = WindowManager.LayoutParams.TYPE_APPLICATION_ATTACHED_DIALOG; + window.setAttributes(lp); + window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM); + mVoiceInput.logKeyboardWarningDialogShown(); + mVoiceWarningDialog.show(); + // Make URL in the dialog message clickable + TextView textView = (TextView) mVoiceWarningDialog.findViewById(android.R.id.message); + if (textView != null) { + final CustomLinkMovementMethod method = CustomLinkMovementMethod.getInstance(); + method.setVoiceWarningDialog(mVoiceWarningDialog); + textView.setMovementMethod(method); + } + } + + private static class CustomLinkMovementMethod extends LinkMovementMethod { + private static CustomLinkMovementMethod sLinkMovementMethodInstance = + new CustomLinkMovementMethod(); + private AlertDialog mAlertDialog; + + public void setVoiceWarningDialog(AlertDialog alertDialog) { + mAlertDialog = alertDialog; + } + + public static CustomLinkMovementMethod getInstance() { + return sLinkMovementMethodInstance; + } + + // Almost the same as LinkMovementMethod.onTouchEvent(), but overrides it for + // FLAG_ACTIVITY_NEW_TASK and mAlertDialog.cancel(). + @Override + public boolean onTouchEvent(TextView widget, Spannable buffer, MotionEvent event) { + int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= widget.getTotalPaddingLeft(); + y -= widget.getTotalPaddingTop(); + + x += widget.getScrollX(); + y += widget.getScrollY(); + + Layout layout = widget.getLayout(); + int line = layout.getLineForVertical(y); + int off = layout.getOffsetForHorizontal(line, x); + + ClickableSpan[] link = buffer.getSpans(off, off, ClickableSpan.class); + + if (link.length != 0) { + if (action == MotionEvent.ACTION_UP) { + if (link[0] instanceof URLSpan) { + URLSpan urlSpan = (URLSpan) link[0]; + Uri uri = Uri.parse(urlSpan.getURL()); + Context context = widget.getContext(); + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.putExtra(Browser.EXTRA_APPLICATION_ID, context.getPackageName()); + if (mAlertDialog != null) { + // Go back to the previous IME for now. + // TODO: If we can find a way to bring the new activity to front + // while keeping the warning dialog, we don't need to cancel here. + mAlertDialog.cancel(); + } + context.startActivity(intent); + } else { + link[0].onClick(widget); + } + } else if (action == MotionEvent.ACTION_DOWN) { + Selection.setSelection(buffer, buffer.getSpanStart(link[0]), + buffer.getSpanEnd(link[0])); + } + return true; + } else { + Selection.removeSelection(buffer); + } + } + return super.onTouchEvent(widget, buffer, event); + } + } + + public void showPunctuationHintIfNecessary() { + InputConnection ic = mService.getCurrentInputConnection(); + if (!mImmediatelyAfterVoiceInput && mAfterVoiceInput && ic != null) { + if (mHints.showPunctuationHintIfNecessary(ic)) { + mVoiceInput.logPunctuationHintDisplayed(); + } + } + mImmediatelyAfterVoiceInput = false; + } + + public void hideVoiceWindow(boolean configurationChanging) { + if (!configurationChanging) { + if (mAfterVoiceInput) + mVoiceInput.logInputEnded(); + if (mVoiceWarningDialog != null && mVoiceWarningDialog.isShowing()) { + mVoiceInput.logKeyboardWarningDialogDismissed(); + mVoiceWarningDialog.dismiss(); + mVoiceWarningDialog = null; + } + if (VOICE_INSTALLED & mRecognizing) { + mVoiceInput.cancel(); + } + } + mWordToSuggestions.clear(); + } + + public void setCursorAndSelection(int newSelEnd, int newSelStart) { + if (mAfterVoiceInput) { + mVoiceInput.setCursorPos(newSelEnd); + mVoiceInput.setSelectionSpan(newSelEnd - newSelStart); + } + } + + public void setVoiceInputHighlighted(boolean b) { + mVoiceInputHighlighted = b; + } + + public void setShowingVoiceSuggestions(boolean b) { + mShowingVoiceSuggestions = b; + } + + public boolean isVoiceButtonEnabled() { + return mVoiceButtonEnabled; + } + + public boolean isVoiceButtonOnPrimary() { + return mVoiceButtonOnPrimary; + } + + public boolean isVoiceInputHighlighted() { + return mVoiceInputHighlighted; + } + + public boolean isRecognizing() { + return mRecognizing; + } + + public boolean needsToShowWarningDialog() { + return !mHasUsedVoiceInput + || (!mLocaleSupportedForVoiceInput && !mHasUsedVoiceInputUnsupportedLocale); + } + + public boolean getAndResetIsShowingHint() { + boolean ret = mIsShowingHint; + mIsShowingHint = false; + return ret; + } + + private void revertVoiceInput() { + InputConnection ic = mService.getCurrentInputConnection(); + if (ic != null) ic.commitText("", 1); + mService.updateSuggestions(); + mVoiceInputHighlighted = false; + } + + public void commitVoiceInput() { + if (VOICE_INSTALLED && mVoiceInputHighlighted) { + InputConnection ic = mService.getCurrentInputConnection(); + if (ic != null) ic.finishComposingText(); + mService.updateSuggestions(); + mVoiceInputHighlighted = false; + } + } + + public boolean logAndRevertVoiceInput() { + if (VOICE_INSTALLED && mVoiceInputHighlighted) { + mVoiceInput.incrementTextModificationDeleteCount( + mVoiceResults.candidates.get(0).toString().length()); + revertVoiceInput(); + return true; + } else { + return false; + } + } + + public void rememberReplacedWord(CharSequence suggestion,String wordSeparators) { + if (mShowingVoiceSuggestions) { + // Retain the replaced word in the alternatives array. + String wordToBeReplaced = EditingUtils.getWordAtCursor( + mService.getCurrentInputConnection(), wordSeparators); + if (!mWordToSuggestions.containsKey(wordToBeReplaced)) { + wordToBeReplaced = wordToBeReplaced.toLowerCase(); + } + if (mWordToSuggestions.containsKey(wordToBeReplaced)) { + List<CharSequence> suggestions = mWordToSuggestions.get(wordToBeReplaced); + if (suggestions.contains(suggestion)) { + suggestions.remove(suggestion); + } + suggestions.add(wordToBeReplaced); + mWordToSuggestions.remove(wordToBeReplaced); + mWordToSuggestions.put(suggestion.toString(), suggestions); + } + } + } + + /** + * Tries to apply any voice alternatives for the word if this was a spoken word and + * there are voice alternatives. + * @param touching The word that the cursor is touching, with position information + * @return true if an alternative was found, false otherwise. + */ + public boolean applyVoiceAlternatives(EditingUtils.SelectedWord touching) { + // Search for result in spoken word alternatives + String selectedWord = touching.mWord.toString().trim(); + if (!mWordToSuggestions.containsKey(selectedWord)) { + selectedWord = selectedWord.toLowerCase(); + } + if (mWordToSuggestions.containsKey(selectedWord)) { + mShowingVoiceSuggestions = true; + List<CharSequence> suggestions = mWordToSuggestions.get(selectedWord); + SuggestedWords.Builder builder = new SuggestedWords.Builder(); + // If the first letter of touching is capitalized, make all the suggestions + // start with a capital letter. + if (Character.isUpperCase(touching.mWord.charAt(0))) { + for (CharSequence word : suggestions) { + String str = word.toString(); + word = Character.toUpperCase(str.charAt(0)) + str.substring(1); + builder.addWord(word); + } + } else { + builder.addWords(suggestions, null); + } + builder.setTypedWordValid(true).setHasMinimalSuggestion(true); + mService.setSuggestions(builder.build()); + mService.setCandidatesViewShown(true); + return true; + } + return false; + } + + public void handleBackspace() { + if (mAfterVoiceInput) { + // Don't log delete if the user is pressing delete at + // the beginning of the text box (hence not deleting anything) + if (mVoiceInput.getCursorPos() > 0) { + // If anything was selected before the delete was pressed, increment the + // delete count by the length of the selection + int deleteLen = mVoiceInput.getSelectionSpan() > 0 ? + mVoiceInput.getSelectionSpan() : 1; + mVoiceInput.incrementTextModificationDeleteCount(deleteLen); + } + } + } + + public void handleCharacter() { + commitVoiceInput(); + if (mAfterVoiceInput) { + // Assume input length is 1. This assumption fails for smiley face insertions. + mVoiceInput.incrementTextModificationInsertCount(1); + } + } + + public void handleSeparator() { + commitVoiceInput(); + if (mAfterVoiceInput){ + // Assume input length is 1. This assumption fails for smiley face insertions. + mVoiceInput.incrementTextModificationInsertPunctuationCount(1); + } + } + + public void handleClose() { + if (VOICE_INSTALLED & mRecognizing) { + mVoiceInput.cancel(); + } + } + + + public void handleVoiceResults(boolean capitalizeFirstWord) { + mAfterVoiceInput = true; + mImmediatelyAfterVoiceInput = true; + + InputConnection ic = mService.getCurrentInputConnection(); + if (!mService.isFullscreenMode()) { + // Start listening for updates to the text from typing, etc. + if (ic != null) { + ExtractedTextRequest req = new ExtractedTextRequest(); + ic.getExtractedText(req, InputConnection.GET_EXTRACTED_TEXT_MONITOR); + } + } + mService.vibrate(); + + final List<CharSequence> nBest = new ArrayList<CharSequence>(); + for (String c : mVoiceResults.candidates) { + if (capitalizeFirstWord) { + c = Character.toUpperCase(c.charAt(0)) + c.substring(1, c.length()); + } + nBest.add(c); + } + if (nBest.size() == 0) { + return; + } + String bestResult = nBest.get(0).toString(); + mVoiceInput.logVoiceInputDelivered(bestResult.length()); + mHints.registerVoiceResult(bestResult); + + if (ic != null) ic.beginBatchEdit(); // To avoid extra updates on committing older text + mService.commitTyped(ic); + EditingUtils.appendText(ic, bestResult); + if (ic != null) ic.endBatchEdit(); + + mVoiceInputHighlighted = true; + mWordToSuggestions.putAll(mVoiceResults.alternatives); + onCancelVoice(); + } + + public void switchToRecognitionStatusView(final Configuration configuration) { + mHandler.post(new Runnable() { + @Override + public void run() { + mService.setCandidatesViewShown(false); + mRecognizing = true; + mVoiceInput.newView(); + View v = mVoiceInput.getView(); + + ViewParent p = v.getParent(); + if (p != null && p instanceof ViewGroup) { + ((ViewGroup) p).removeView(v); + } + + View keyboardView = KeyboardSwitcher.getInstance().getInputView(); + + // The full height of the keyboard is difficult to calculate + // as the dimension is expressed in "mm" and not in "pixel" + // As we add mm, we don't know how the rounding is going to work + // thus we may end up with few pixels extra (or less). + if (keyboardView != null) { + View popupLayout = v.findViewById(R.id.popup_layout); + final int displayHeight = + mService.getResources().getDisplayMetrics().heightPixels; + final int currentHeight = popupLayout.getLayoutParams().height; + final int keyboardHeight = keyboardView.getHeight(); + if (keyboardHeight > currentHeight || keyboardHeight + > (displayHeight / RECOGNITIONVIEW_HEIGHT_THRESHOLD_RATIO)) { + popupLayout.getLayoutParams().height = keyboardHeight; + } + } + mService.setInputView(v); + mService.updateInputViewShown(); + + if (configuration != null) { + mVoiceInput.onConfigurationChanged(configuration); + } + }}); + } + + private void switchToLastInputMethod() { + final IBinder token = mService.getWindow().getWindow().getAttributes().token; + new AsyncTask<Void, Void, Boolean>() { + @Override + protected Boolean doInBackground(Void... params) { + return mImm.switchToLastInputMethod(token); + } + + @Override + protected void onPostExecute(Boolean result) { + if (!result) { + if (DEBUG) { + Log.d(TAG, "Couldn't switch back to last IME."); + } + // Needs to reset here because LatinIME failed to back to any IME and + // the same voice subtype will be triggered in the next time. + mVoiceInput.reset(); + mService.requestHideSelf(0); + } + } + }.execute(); + } + + private void reallyStartListening(boolean swipe) { + if (!VOICE_INSTALLED) { + return; + } + if (!mHasUsedVoiceInput) { + // The user has started a voice input, so remember that in the + // future (so we don't show the warning dialog after the first run). + SharedPreferences.Editor editor = + PreferenceManager.getDefaultSharedPreferences(mService).edit(); + editor.putBoolean(PREF_HAS_USED_VOICE_INPUT, true); + SharedPreferencesCompat.apply(editor); + mHasUsedVoiceInput = true; + } + + if (!mLocaleSupportedForVoiceInput && !mHasUsedVoiceInputUnsupportedLocale) { + // The user has started a voice input from an unsupported locale, so remember that + // in the future (so we don't show the warning dialog the next time they do this). + SharedPreferences.Editor editor = + PreferenceManager.getDefaultSharedPreferences(mService).edit(); + editor.putBoolean(PREF_HAS_USED_VOICE_INPUT_UNSUPPORTED_LOCALE, true); + SharedPreferencesCompat.apply(editor); + mHasUsedVoiceInputUnsupportedLocale = true; + } + + // Clear N-best suggestions + mService.clearSuggestions(); + + FieldContext context = makeFieldContext(); + mVoiceInput.startListening(context, swipe); + switchToRecognitionStatusView(null); + } + + public void startListening(final boolean swipe, IBinder token) { + // TODO: remove swipe which is no longer used. + if (VOICE_INSTALLED) { + if (needsToShowWarningDialog()) { + // Calls reallyStartListening if user clicks OK, does nothing if user clicks Cancel. + showVoiceWarningDialog(swipe, token); + } else { + reallyStartListening(swipe); + } + } + } + + private boolean fieldCanDoVoice(FieldContext fieldContext) { + return !mPasswordText + && mVoiceInput != null + && !mVoiceInput.isBlacklistedField(fieldContext); + } + + private boolean shouldShowVoiceButton(FieldContext fieldContext, EditorInfo attribute) { + return ENABLE_VOICE_BUTTON && fieldCanDoVoice(fieldContext) + && !(attribute != null + && IME_OPTION_NO_MICROPHONE.equals(attribute.privateImeOptions)) + && SpeechRecognizer.isRecognitionAvailable(mService); + } + + public void loadSettings(EditorInfo attribute, SharedPreferences sp) { + mHasUsedVoiceInput = sp.getBoolean(PREF_HAS_USED_VOICE_INPUT, false); + mHasUsedVoiceInputUnsupportedLocale = + sp.getBoolean(PREF_HAS_USED_VOICE_INPUT_UNSUPPORTED_LOCALE, false); + + mLocaleSupportedForVoiceInput = SubtypeSwitcher.getInstance().isVoiceSupported( + SubtypeSwitcher.getInstance().getInputLocaleStr()); + + if (VOICE_INSTALLED) { + final String voiceMode = sp.getString(PREF_VOICE_MODE, + mService.getString(R.string.voice_mode_main)); + mVoiceButtonEnabled = !voiceMode.equals(mService.getString(R.string.voice_mode_off)) + && shouldShowVoiceButton(makeFieldContext(), attribute); + mVoiceButtonOnPrimary = voiceMode.equals(mService.getString(R.string.voice_mode_main)); + } + } + + public void destroy() { + if (VOICE_INSTALLED && mVoiceInput != null) { + mVoiceInput.destroy(); + } + } + + public void onStartInputView(IBinder keyboardViewToken) { + // If keyboardViewToken is null, keyboardView is not attached but voiceView is attached. + IBinder windowToken = keyboardViewToken != null ? keyboardViewToken + : mVoiceInput.getView().getWindowToken(); + // If IME is in voice mode, but still needs to show the voice warning dialog, + // keep showing the warning. + if (mSubtypeSwitcher.isVoiceMode() && windowToken != null) { + // Close keyboard view if it is been shown. + if (KeyboardSwitcher.getInstance().isInputViewShown()) + KeyboardSwitcher.getInstance().getInputView().purgeKeyboardAndClosing(); + startListening(false, windowToken); + } + // If we have no token, onAttachedToWindow will take care of showing dialog and start + // listening. + } + + public void onAttachedToWindow() { + // After onAttachedToWindow, we can show the voice warning dialog. See startListening() + // above. + mSubtypeSwitcher.setVoiceInput(mVoiceInput); + } + + public void onConfigurationChanged(Configuration configuration) { + if (mRecognizing) { + switchToRecognitionStatusView(configuration); + } + } + + @Override + public void onCancelVoice() { + if (mRecognizing) { + if (mSubtypeSwitcher.isVoiceMode()) { + // If voice mode is being canceled within LatinIME (i.e. time-out or user + // cancellation etc.), onCancelVoice() will be called first. LatinIME thinks it's + // still in voice mode. LatinIME needs to call switchToLastInputMethod(). + // Note that onCancelVoice() will be called again from SubtypeSwitcher. + switchToLastInputMethod(); + } else if (mSubtypeSwitcher.isKeyboardMode()) { + // If voice mode is being canceled out of LatinIME (i.e. by user's IME switching or + // as a result of switchToLastInputMethod() etc.), + // onCurrentInputMethodSubtypeChanged() will be called first. LatinIME will know + // that it's in keyboard mode and SubtypeSwitcher will call onCancelVoice(). + mRecognizing = false; + mService.switchToKeyboardView(); + } + } + } + + @Override + public void onVoiceResults(List<String> candidates, + Map<String, List<CharSequence>> alternatives) { + if (!mRecognizing) { + return; + } + mVoiceResults.candidates = candidates; + mVoiceResults.alternatives = alternatives; + mHandler.updateVoiceResults(); + } + + public FieldContext makeFieldContext() { + SubtypeSwitcher switcher = SubtypeSwitcher.getInstance(); + return new FieldContext(mService.getCurrentInputConnection(), + mService.getCurrentInputEditorInfo(), switcher.getInputLocaleStr(), + switcher.getEnabledLanguages()); + } + + private class VoiceResults { + List<String> candidates; + Map<String, List<CharSequence>> alternatives; + } +} diff --git a/java/src/com/android/inputmethod/voice/VoiceInput.java b/java/src/com/android/inputmethod/voice/VoiceInput.java index 4c54dd3c5..2df9e8588 100644 --- a/java/src/com/android/inputmethod/voice/VoiceInput.java +++ b/java/src/com/android/inputmethod/voice/VoiceInput.java @@ -16,24 +16,26 @@ package com.android.inputmethod.voice; -import com.android.inputmethod.latin.EditingUtil; +import com.android.inputmethod.latin.EditingUtils; +import com.android.inputmethod.latin.LatinImeLogger; import com.android.inputmethod.latin.R; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; +import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Message; import android.os.Parcelable; import android.speech.RecognitionListener; -import android.speech.SpeechRecognizer; import android.speech.RecognizerIntent; +import android.speech.SpeechRecognizer; import android.util.Log; -import android.view.inputmethod.InputConnection; import android.view.View; import android.view.View.OnClickListener; +import android.view.inputmethod.InputConnection; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -57,6 +59,7 @@ public class VoiceInput implements OnClickListener { private static final String EXTRA_CALLING_PACKAGE = "calling_package"; private static final String EXTRA_ALTERNATES = "android.speech.extra.ALTERNATES"; private static final int MAX_ALT_LIST_LENGTH = 6; + private static boolean DBG = LatinImeLogger.sDBG; private static final String DEFAULT_RECOMMENDED_PACKAGES = "com.android.mms " + @@ -86,6 +89,7 @@ public class VoiceInput implements OnClickListener { private static final String ALTERNATES_BUNDLE = "alternates_bundle"; // This is copied from the VoiceSearch app. + @SuppressWarnings("unused") private static final class AlternatesBundleKeys { public static final String ALTERNATES = "alternates"; public static final String CONFIDENCE = "confidence"; @@ -126,12 +130,12 @@ public class VoiceInput implements OnClickListener { private int mState = DEFAULT; - private final static int MSG_CLOSE_ERROR_DIALOG = 1; + private final static int MSG_RESET = 1; private final Handler mHandler = new Handler() { @Override public void handleMessage(Message msg) { - if (msg.what == MSG_CLOSE_ERROR_DIALOG) { + if (msg.what == MSG_RESET) { mState = DEFAULT; mRecognitionView.finish(); mUiListener.onCancelVoice(); @@ -240,7 +244,7 @@ public class VoiceInput implements OnClickListener { } public void incrementTextModificationInsertPunctuationCount(int count){ - mAfterVoiceInputInsertPunctuationCount += 1; + mAfterVoiceInputInsertPunctuationCount += count; if (mAfterVoiceInputSelectionSpan > 0) { // If text was highlighted before inserting the char, count this as // a delete. @@ -276,8 +280,9 @@ public class VoiceInput implements OnClickListener { * The configuration of the IME changed and may have caused the views to be layed out * again. Restore the state of the recognition view. */ - public void onConfigurationChanged() { + public void onConfigurationChanged(Configuration configuration) { mRecognitionView.restoreState(); + mRecognitionView.getView().dispatchConfigurationChanged(configuration); } /** @@ -305,8 +310,18 @@ public class VoiceInput implements OnClickListener { * @param swipe whether this voice input was started by swipe, for logging purposes */ public void startListening(FieldContext context, boolean swipe) { - mState = DEFAULT; - + if (DBG) { + Log.d(TAG, "startListening: " + context); + } + + if (mState != DEFAULT) { + Log.w(TAG, "startListening in the wrong status " + mState); + } + + // If everything works ok, the voice input should be already in the correct state. As this + // class can be called by third-party, we call reset just to be on the safe side. + reset(); + Locale locale = Locale.getDefault(); String localeString = locale.getLanguage() + "-" + locale.getCountry(); @@ -405,6 +420,7 @@ public class VoiceInput implements OnClickListener { /** * Handle the cancel button. */ + @Override public void onClick(View view) { switch(view.getId()) { case R.id.button: @@ -427,8 +443,7 @@ public class VoiceInput implements OnClickListener { public void logTextModifiedByChooseSuggestion(String suggestion, int index, String wordSeparators, InputConnection ic) { - EditingUtil.Range range = new EditingUtil.Range(); - String wordToBeReplaced = EditingUtil.getWordAtCursor(ic, wordSeparators, range); + String wordToBeReplaced = EditingUtils.getWordAtCursor(ic, wordSeparators); // If we enable phrase-based alternatives, only send up the first word // in suggestion and wordToBeReplaced. mLogger.textModifiedByChooseSuggestion(suggestion.length(), wordToBeReplaced.length(), @@ -491,6 +506,21 @@ public class VoiceInput implements OnClickListener { } /** + * Reset the current voice recognition. + */ + public void reset() { + if (mState != DEFAULT) { + mState = DEFAULT; + + // Remove all pending tasks (e.g., timers to cancel voice input) + mHandler.removeMessages(MSG_RESET); + + mSpeechRecognizer.cancel(); + mRecognitionView.finish(); + } + } + + /** * Cancel in-progress speech recognition. */ public void cancel() { @@ -505,14 +535,9 @@ public class VoiceInput implements OnClickListener { mLogger.cancelDuringError(); break; } - mState = DEFAULT; - - // Remove all pending tasks (e.g., timers to cancel voice input) - mHandler.removeMessages(MSG_CLOSE_ERROR_DIALOG); - mSpeechRecognizer.cancel(); + reset(); mUiListener.onCancelVoice(); - mRecognitionView.finish(); } private int getErrorStringId(int errorType, boolean endpointed) { @@ -547,7 +572,7 @@ public class VoiceInput implements OnClickListener { mState = ERROR; mRecognitionView.showError(error); // Wait a couple seconds and then automatically dismiss message. - mHandler.sendMessageDelayed(Message.obtain(mHandler, MSG_CLOSE_ERROR_DIALOG), 2000); + mHandler.sendMessageDelayed(Message.obtain(mHandler, MSG_RESET), 2000); } private class ImeRecognitionListener implements RecognitionListener { @@ -556,36 +581,45 @@ public class VoiceInput implements OnClickListener { int mSpeechStart; private boolean mEndpointed = false; + @Override public void onReadyForSpeech(Bundle noiseParams) { mRecognitionView.showListening(); } + @Override public void onBeginningOfSpeech() { mEndpointed = false; mSpeechStart = mWaveBuffer.size(); } + @Override public void onRmsChanged(float rmsdB) { mRecognitionView.updateVoiceMeter(rmsdB); } + @Override public void onBufferReceived(byte[] buf) { try { mWaveBuffer.write(buf); - } catch (IOException e) {} + } catch (IOException e) { + // ignore. + } } + @Override public void onEndOfSpeech() { mEndpointed = true; mState = WORKING; mRecognitionView.showWorking(mWaveBuffer, mSpeechStart, mWaveBuffer.size()); } + @Override public void onError(int errorType) { mState = ERROR; VoiceInput.this.onError(errorType, mEndpointed); } + @Override public void onResults(Bundle resultsBundle) { List<String> results = resultsBundle .getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION); @@ -638,10 +672,12 @@ public class VoiceInput implements OnClickListener { mRecognitionView.finish(); } + @Override public void onPartialResults(final Bundle partialResults) { // currently - do nothing } + @Override public void onEvent(int eventType, Bundle params) { // do nothing - reserved for events that might be added in the future } diff --git a/java/src/com/android/inputmethod/voice/VoiceInputLogger.java b/java/src/com/android/inputmethod/voice/VoiceInputLogger.java index 0d21133e0..3e65434a2 100644 --- a/java/src/com/android/inputmethod/voice/VoiceInputLogger.java +++ b/java/src/com/android/inputmethod/voice/VoiceInputLogger.java @@ -31,6 +31,7 @@ import android.content.Intent; * on on the VoiceSearch side. */ public class VoiceInputLogger { + @SuppressWarnings("unused") private static final String TAG = VoiceInputLogger.class.getSimpleName(); private static VoiceInputLogger sVoiceInputLogger; @@ -205,14 +206,16 @@ public class VoiceInputLogger { mContext.sendBroadcast(i); } + public void textModifiedByChooseSuggestion(int suggestionLength, int replacedPhraseLength, int index, String before, String after) { setHasLoggingInfo(true); Intent i = newLoggingBroadcast(LoggingEvents.VoiceIme.TEXT_MODIFIED); - i.putExtra(LoggingEvents.VoiceIme.EXTRA_TEXT_MODIFIED_TYPE, - LoggingEvents.VoiceIme.TEXT_MODIFIED_TYPE_CHOOSE_SUGGESTION); i.putExtra(LoggingEvents.VoiceIme.EXTRA_TEXT_MODIFIED_LENGTH, suggestionLength); i.putExtra(LoggingEvents.VoiceIme.EXTRA_TEXT_REPLACED_LENGTH, replacedPhraseLength); + i.putExtra(LoggingEvents.VoiceIme.EXTRA_TEXT_MODIFIED_TYPE, + LoggingEvents.VoiceIme.TEXT_MODIFIED_TYPE_CHOOSE_SUGGESTION); + i.putExtra(LoggingEvents.VoiceIme.EXTRA_N_BEST_CHOOSE_INDEX, index); i.putExtra(LoggingEvents.VoiceIme.EXTRA_BEFORE_N_BEST_CHOOSE, before); i.putExtra(LoggingEvents.VoiceIme.EXTRA_AFTER_N_BEST_CHOOSE, after); diff --git a/java/src/com/android/inputmethod/voice/WaveformImage.java b/java/src/com/android/inputmethod/voice/WaveformImage.java index 08d87c8f3..8bac669fc 100644 --- a/java/src/com/android/inputmethod/voice/WaveformImage.java +++ b/java/src/com/android/inputmethod/voice/WaveformImage.java @@ -33,7 +33,9 @@ import java.nio.ShortBuffer; public class WaveformImage { private static final int SAMPLING_RATE = 8000; - private WaveformImage() {} + private WaveformImage() { + // Intentional empty constructor. + } public static Bitmap drawWaveform( ByteArrayOutputStream waveBuffer, int w, int h, int start, int end) { diff --git a/java/src/com/android/inputmethod/voice/Whitelist.java b/java/src/com/android/inputmethod/voice/Whitelist.java index 167b688ca..f4c24de0c 100644 --- a/java/src/com/android/inputmethod/voice/Whitelist.java +++ b/java/src/com/android/inputmethod/voice/Whitelist.java @@ -17,6 +17,7 @@ package com.android.inputmethod.voice; import android.os.Bundle; + import java.util.ArrayList; import java.util.List; |