| /* | |
| * 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, | |
| } | |
| } |