/* * 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 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 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 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 inlineSuggestions) { final int totalSuggestionsCount = inlineSuggestions.size(); if (inlineSuggestions.isEmpty()) { // clear the suggestions and then return getMainExecutor().execute(() -> updateInlineSuggestionStrip(Collections.EMPTY_LIST)); return; } final Map 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 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, } }