| /* |
| * Copyright (C) 2019 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 com.example.android.autofillkeyboard; |
| |
| import static android.util.TypedValue.COMPLEX_UNIT_DIP; |
| |
| import android.graphics.Color; |
| import android.graphics.drawable.Icon; |
| import android.inputmethodservice.InputMethodService; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.util.Log; |
| import android.util.Size; |
| import android.util.TypedValue; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.inputmethod.EditorInfo; |
| import android.widget.inline.InlineContentView; |
| import android.widget.inline.InlinePresentationSpec; |
| import android.view.inputmethod.InlineSuggestion; |
| import android.view.inputmethod.InlineSuggestionsRequest; |
| import android.view.inputmethod.InlineSuggestionsResponse; |
| import android.widget.Toast; |
| |
| import androidx.autofill.inline.UiVersions; |
| import androidx.autofill.inline.UiVersions.StylesBuilder; |
| import androidx.autofill.inline.common.ImageViewStyle; |
| import androidx.autofill.inline.common.TextViewStyle; |
| import androidx.autofill.inline.common.ViewStyle; |
| import androidx.autofill.inline.v1.InlineSuggestionUi; |
| import androidx.autofill.inline.v1.InlineSuggestionUi.Style; |
| |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.TreeMap; |
| import java.util.concurrent.ExecutorService; |
| import java.util.concurrent.Executors; |
| |
| /** The {@link InputMethodService} implementation for Autofill keyboard. */ |
| public class AutofillImeService extends InputMethodService { |
| private static final boolean SHOWCASE_BG_FG_TRANSITION = false; |
| // To test this you need to change KeyboardArea style layout_height to 400dp |
| private static final boolean SHOWCASE_UP_DOWN_TRANSITION = false; |
| |
| private static final long MOVE_SUGGESTIONS_TO_BG_TIMEOUT = 5000; |
| private static final long MOVE_SUGGESTIONS_TO_FG_TIMEOUT = 15000; |
| |
| private static final long MOVE_SUGGESTIONS_UP_TIMEOUT = 5000; |
| private static final long MOVE_SUGGESTIONS_DOWN_TIMEOUT = 10000; |
| |
| private InputView mInputView; |
| private Keyboard mKeyboard; |
| private Decoder mDecoder; |
| |
| private ViewGroup mSuggestionStrip; |
| private ViewGroup mPinnedSuggestionsStart; |
| private ViewGroup mPinnedSuggestionsEnd; |
| private InlineContentClipView mScrollableSuggestionsClip; |
| private ViewGroup mScrollableSuggestions; |
| |
| private final Handler mHandler = new Handler(Looper.getMainLooper()); |
| |
| private final Runnable mMoveScrollableSuggestionsToBg = () -> { |
| mScrollableSuggestionsClip.setZOrderedOnTop(false); |
| Toast.makeText(AutofillImeService.this, "Chips moved to bg - not clickable", |
| Toast.LENGTH_SHORT).show(); |
| }; |
| |
| private final Runnable mMoveScrollableSuggestionsToFg = () -> { |
| mScrollableSuggestionsClip.setZOrderedOnTop(true); |
| Toast.makeText(AutofillImeService.this, "Chips moved to fg - clickable", |
| Toast.LENGTH_SHORT).show(); |
| }; |
| |
| private final Runnable mMoveScrollableSuggestionsUp = () -> { |
| mSuggestionStrip.animate().translationY(-50).setDuration(500).start(); |
| Toast.makeText(AutofillImeService.this, "Animating up", |
| Toast.LENGTH_SHORT).show(); |
| }; |
| |
| private final Runnable mMoveScrollableSuggestionsDown = () -> { |
| mSuggestionStrip.animate().translationY(0).setDuration(500).start(); |
| Toast.makeText(AutofillImeService.this, "Animating down", |
| Toast.LENGTH_SHORT).show(); |
| }; |
| |
| private ResponseState mResponseState = ResponseState.RESET; |
| private Runnable mDelayedDeletion; |
| private Runnable mPendingResponse; |
| |
| @Override |
| public View onCreateInputView() { |
| mInputView = (InputView) LayoutInflater.from(this).inflate(R.layout.input_view, null); |
| mKeyboard = Keyboard.qwerty(this); |
| mInputView.addView(mKeyboard.inflateKeyboardView(LayoutInflater.from(this), mInputView)); |
| mSuggestionStrip = mInputView.findViewById(R.id.suggestion_strip); |
| mPinnedSuggestionsStart = mInputView.findViewById(R.id.pinned_suggestions_start); |
| mPinnedSuggestionsEnd = mInputView.findViewById(R.id.pinned_suggestions_end); |
| mScrollableSuggestionsClip = mInputView.findViewById(R.id.scrollable_suggestions_clip); |
| mScrollableSuggestions = mInputView.findViewById(R.id.scrollable_suggestions); |
| return mInputView; |
| } |
| |
| @Override |
| public void onStartInput(EditorInfo attribute, boolean restarting) { |
| super.onStartInput(attribute, restarting); |
| mDecoder = new Decoder(getCurrentInputConnection()); |
| if(mKeyboard != null) { |
| mKeyboard.reset(); |
| } |
| if (mResponseState == ResponseState.RECEIVE_RESPONSE) { |
| mResponseState = ResponseState.START_INPUT; |
| } else { |
| mResponseState = ResponseState.RESET; |
| } |
| } |
| |
| @Override |
| public void onFinishInput() { |
| super.onFinishInput(); |
| } |
| |
| private void cancelPendingResponse() { |
| if (mPendingResponse != null) { |
| Log.d(TAG, "Canceling pending response"); |
| mHandler.removeCallbacks(mPendingResponse); |
| mPendingResponse = null; |
| } |
| } |
| |
| private void postPendingResponse(InlineSuggestionsResponse response) { |
| cancelPendingResponse(); |
| final List<InlineSuggestion> inlineSuggestions = response.getInlineSuggestions(); |
| mResponseState = ResponseState.RECEIVE_RESPONSE; |
| mPendingResponse = () -> { |
| mPendingResponse = null; |
| if (mResponseState == ResponseState.START_INPUT && inlineSuggestions.isEmpty()) { |
| scheduleDelayedDeletion(); |
| } else { |
| inflateThenShowSuggestions(inlineSuggestions); |
| } |
| mResponseState = ResponseState.RESET; |
| }; |
| mHandler.post(mPendingResponse); |
| } |
| |
| private void cancelDelayedDeletion(String msg) { |
| if(mDelayedDeletion != null) { |
| Log.d(TAG, msg + " canceling delayed deletion"); |
| mHandler.removeCallbacks(mDelayedDeletion); |
| mDelayedDeletion = null; |
| } |
| } |
| |
| private void scheduleDelayedDeletion() { |
| if (mInputView != null && mDelayedDeletion == null) { |
| // We delay the deletion of the suggestions from previous input connection, to avoid |
| // the flicker caused by deleting them and immediately showing new suggestions for |
| // the current input connection. |
| Log.d(TAG, "Scheduling a delayed deletion of inline suggestions"); |
| mDelayedDeletion = () -> { |
| Log.d(TAG, "Executing scheduled deleting inline suggestions"); |
| mDelayedDeletion = null; |
| clearInlineSuggestionStrip(); |
| }; |
| mHandler.postDelayed(mDelayedDeletion, 200); |
| } |
| } |
| |
| private void clearInlineSuggestionStrip() { |
| if (mInputView != null) { |
| updateInlineSuggestionStrip(Collections.emptyList()); |
| } |
| } |
| |
| @Override |
| public void onStartInputView(EditorInfo info, boolean restarting) { |
| super.onStartInputView(info, restarting); |
| } |
| |
| @Override |
| public void onFinishInputView(boolean finishingInput) { |
| super.onFinishInputView(finishingInput); |
| if (!finishingInput) { |
| // This runs when the IME is hide (but not finished). We need to clear the suggestions. |
| // Otherwise, they will stay on the screen for a bit after the IME window disappears. |
| // TODO: right now the framework resends the suggestions when onStartInputView is |
| // called. If the framework is changed to not resend, then we need to cache the |
| // inline suggestion views locally and re-attach them when the IME is shown again by |
| // onStartInputView. |
| clearInlineSuggestionStrip(); |
| } |
| } |
| |
| @Override |
| public void onComputeInsets(Insets outInsets) { |
| super.onComputeInsets(outInsets); |
| if (mInputView != null) { |
| outInsets.contentTopInsets += mInputView.getTopInsets(); |
| } |
| outInsets.touchableInsets = Insets.TOUCHABLE_INSETS_CONTENT; |
| } |
| |
| /***************** Inline Suggestions Demo Code *****************/ |
| |
| private static final String TAG = "AutofillImeService"; |
| |
| @Override |
| public InlineSuggestionsRequest onCreateInlineSuggestionsRequest(Bundle uiExtras) { |
| Log.d(TAG, "onCreateInlineSuggestionsRequest() called"); |
| StylesBuilder stylesBuilder = UiVersions.newStylesBuilder(); |
| Style style = InlineSuggestionUi.newStyleBuilder() |
| .setSingleIconChipStyle( |
| new ViewStyle.Builder() |
| .setBackground( |
| Icon.createWithResource(this, R.drawable.chip_background)) |
| .setPadding(0, 0, 0, 0) |
| .build()) |
| .setChipStyle( |
| new ViewStyle.Builder() |
| .setBackground( |
| Icon.createWithResource(this, R.drawable.chip_background)) |
| .setPadding(toPixel(5 + 8), 0, toPixel(5 + 8), 0) |
| .build()) |
| .setStartIconStyle(new ImageViewStyle.Builder().setLayoutMargin(0, 0, 0, 0).build()) |
| .setTitleStyle( |
| new TextViewStyle.Builder() |
| .setLayoutMargin(toPixel(4), 0, toPixel(4), 0) |
| .setTextColor(Color.parseColor("#FF202124")) |
| .setTextSize(16) |
| .build()) |
| .setSubtitleStyle( |
| new TextViewStyle.Builder() |
| .setLayoutMargin(0, 0, toPixel(4), 0) |
| .setTextColor(Color.parseColor("#99202124")) // 60% opacity |
| .setTextSize(14) |
| .build()) |
| .setEndIconStyle(new ImageViewStyle.Builder().setLayoutMargin(0, 0, 0, 0).build()) |
| .build(); |
| stylesBuilder.addStyle(style); |
| Bundle stylesBundle = stylesBuilder.build(); |
| |
| final ArrayList<InlinePresentationSpec> presentationSpecs = new ArrayList<>(); |
| presentationSpecs.add(new InlinePresentationSpec.Builder(new Size(100, getHeight()), |
| new Size(740, getHeight())).setStyle(stylesBundle).build()); |
| presentationSpecs.add(new InlinePresentationSpec.Builder(new Size(100, getHeight()), |
| new Size(740, getHeight())).setStyle(stylesBundle).build()); |
| |
| return new InlineSuggestionsRequest.Builder(presentationSpecs) |
| .setMaxSuggestionCount(6) |
| .build(); |
| } |
| |
| private int toPixel(int dp) { |
| return (int) TypedValue.applyDimension(COMPLEX_UNIT_DIP, dp, |
| getResources().getDisplayMetrics()); |
| } |
| |
| private int getHeight() { |
| return getResources().getDimensionPixelSize(R.dimen.keyboard_header_height); |
| } |
| |
| @Override |
| public boolean onInlineSuggestionsResponse(InlineSuggestionsResponse response) { |
| Log.d(TAG, |
| "onInlineSuggestionsResponse() called: " + response.getInlineSuggestions().size()); |
| cancelDelayedDeletion("onInlineSuggestionsResponse"); |
| postPendingResponse(response); |
| return true; |
| } |
| |
| private void updateInlineSuggestionStrip(List<SuggestionItem> suggestionItems) { |
| Log.d(TAG, "Actually updating the suggestion strip: " + suggestionItems.size()); |
| mPinnedSuggestionsStart.removeAllViews(); |
| mScrollableSuggestions.removeAllViews(); |
| mPinnedSuggestionsEnd.removeAllViews(); |
| |
| if (suggestionItems.isEmpty()) { |
| return; |
| } |
| |
| // TODO: refactor me |
| mScrollableSuggestionsClip.setBackgroundColor( |
| getColor(R.color.suggestion_strip_background)); |
| mSuggestionStrip.setVisibility(View.VISIBLE); |
| |
| for (SuggestionItem suggestionItem : suggestionItems) { |
| if (suggestionItem == null) { |
| continue; |
| } |
| final InlineContentView suggestionView = suggestionItem.mView; |
| if (suggestionItem.mIsPinned) { |
| if (mPinnedSuggestionsStart.getChildCount() <= 0) { |
| mPinnedSuggestionsStart.addView(suggestionView); |
| } else { |
| mPinnedSuggestionsEnd.addView(suggestionView); |
| } |
| } else { |
| mScrollableSuggestions.addView(suggestionView); |
| } |
| } |
| |
| if (SHOWCASE_BG_FG_TRANSITION) { |
| rescheduleShowcaseBgFgTransitions(); |
| } |
| if (SHOWCASE_UP_DOWN_TRANSITION) { |
| rescheduleShowcaseUpDownTransitions(); |
| } |
| } |
| |
| private void rescheduleShowcaseBgFgTransitions() { |
| final Handler handler = mInputView.getHandler(); |
| handler.removeCallbacks(mMoveScrollableSuggestionsToBg); |
| handler.postDelayed(mMoveScrollableSuggestionsToBg, MOVE_SUGGESTIONS_TO_BG_TIMEOUT); |
| handler.removeCallbacks(mMoveScrollableSuggestionsToFg); |
| handler.postDelayed(mMoveScrollableSuggestionsToFg, MOVE_SUGGESTIONS_TO_FG_TIMEOUT); |
| } |
| |
| private void rescheduleShowcaseUpDownTransitions() { |
| final Handler handler = mInputView.getHandler(); |
| handler.removeCallbacks(mMoveScrollableSuggestionsUp); |
| handler.postDelayed(mMoveScrollableSuggestionsUp, MOVE_SUGGESTIONS_UP_TIMEOUT); |
| handler.removeCallbacks(mMoveScrollableSuggestionsDown); |
| handler.postDelayed(mMoveScrollableSuggestionsDown, MOVE_SUGGESTIONS_DOWN_TIMEOUT); |
| } |
| |
| private void inflateThenShowSuggestions( List<InlineSuggestion> inlineSuggestions) { |
| final int totalSuggestionsCount = inlineSuggestions.size(); |
| if (inlineSuggestions.isEmpty()) { |
| // clear the suggestions and then return |
| getMainExecutor().execute(() -> updateInlineSuggestionStrip(Collections.EMPTY_LIST)); |
| return; |
| } |
| |
| final Map<Integer, SuggestionItem> suggestionMap = Collections.synchronizedMap(( |
| new TreeMap<>())); |
| final ExecutorService executor = Executors.newSingleThreadExecutor(); |
| |
| for (int i = 0; i < totalSuggestionsCount; i++) { |
| final int index = i; |
| final InlineSuggestion inlineSuggestion = inlineSuggestions.get(i); |
| final Size size = new Size(ViewGroup.LayoutParams.WRAP_CONTENT, |
| ViewGroup.LayoutParams.WRAP_CONTENT); |
| |
| inlineSuggestion.inflate(this, size, executor, suggestionView -> { |
| Log.d(TAG, "new inline suggestion view ready"); |
| if(suggestionView != null) { |
| suggestionView.setOnClickListener((v) -> { |
| Log.d(TAG, "Received click on the suggestion"); |
| }); |
| suggestionView.setOnLongClickListener((v) -> { |
| Log.d(TAG, "Received long click on the suggestion"); |
| return true; |
| }); |
| final SuggestionItem suggestionItem = new SuggestionItem( |
| suggestionView, /*isAction*/ inlineSuggestion.getInfo().isPinned()); |
| suggestionMap.put(index, suggestionItem); |
| } else { |
| suggestionMap.put(index, null); |
| } |
| |
| // Update the UI once the last inflation completed |
| if (suggestionMap.size() >= totalSuggestionsCount) { |
| final ArrayList<SuggestionItem> suggestionItems = new ArrayList<>( |
| suggestionMap.values()); |
| getMainExecutor().execute(() -> updateInlineSuggestionStrip(suggestionItems)); |
| } |
| }); |
| } |
| } |
| |
| void handle(String data) { |
| Log.d(TAG, "handle() called: [" + data + "]"); |
| mDecoder.decodeAndApply(data); |
| } |
| |
| static class SuggestionItem { |
| final InlineContentView mView; |
| final boolean mIsPinned; |
| |
| SuggestionItem(InlineContentView view, boolean isPinned) { |
| mView = view; |
| mIsPinned = isPinned; |
| } |
| } |
| |
| enum ResponseState { |
| RESET, |
| RECEIVE_RESPONSE, |
| START_INPUT, |
| } |
| } |