blob: 32f765a3fd687bca36196cb926c93486fcc6bd9f [file] [log] [blame]
/*
* 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;
@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.FINISH_INPUT) {
mResponseState = ResponseState.START_INPUT;
} else {
mResponseState = ResponseState.RESET;
}
}
@Override
public void onFinishInput() {
super.onFinishInput();
if (mResponseState == ResponseState.RECEIVE_RESPONSE) {
mResponseState = ResponseState.FINISH_INPUT;
} else {
mResponseState = ResponseState.RESET;
}
}
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");
final List<InlineSuggestion> inlineSuggestions = response.getInlineSuggestions();
mResponseState = ResponseState.RECEIVE_RESPONSE;
mHandler.post(() -> {
if (mResponseState == ResponseState.START_INPUT && inlineSuggestions.isEmpty()) {
scheduleDelayedDeletion();
} else {
inflateThenShowSuggestions(inlineSuggestions);
}
mResponseState = ResponseState.RESET;
});
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();
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,
FINISH_INPUT,
START_INPUT,
}
}