395 lines
16 KiB
Java
395 lines
16 KiB
Java
/*
|
|
* 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,
|
|
}
|
|
}
|