blob: 23afd7749024339339faa895cfbb526db38d2808 [file] [log] [blame]
/*
* Copyright (C) 2021 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.android.car.ui.toolbar;
import static android.view.WindowInsets.Type.ime;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.CONTENT_AREA_SURFACE_DISPLAY_ID;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.CONTENT_AREA_SURFACE_HEIGHT;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.CONTENT_AREA_SURFACE_HOST_TOKEN;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.CONTENT_AREA_SURFACE_PACKAGE;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.CONTENT_AREA_SURFACE_WIDTH;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.SEARCH_RESULT_ITEM_ID_LIST;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.SEARCH_RESULT_SUPPLEMENTAL_ICON_ID_LIST;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.WIDE_SCREEN_ACTION;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.WIDE_SCREEN_CLEAR_DATA_ACTION;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.WIDE_SCREEN_POST_LOAD_SEARCH_RESULTS_ACTION;
import static com.android.car.ui.imewidescreen.CarUiImeWideScreenController.WIDE_SCREEN_SEARCH_RESULTS;
import static com.android.car.ui.utils.CarUiUtils.getBooleanSystemProperty;
import android.content.ContentValues;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.hardware.display.DisplayManager;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Parcel;
import android.view.Display;
import android.view.SurfaceControlViewHost;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.InputMethodManager;
import android.widget.FrameLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.android.car.ui.CarUiText;
import com.android.car.ui.R;
import com.android.car.ui.core.SearchResultsProvider;
import com.android.car.ui.imewidescreen.CarUiImeSearchListItem;
import com.android.car.ui.recyclerview.CarUiContentListItem;
import com.android.car.ui.recyclerview.CarUiRecyclerView;
import java.util.List;
import java.util.function.BiConsumer;
/**
* Internal class to car-ui-lib, do not use from outside.
* <p>
* Handles converting a {@link SearchConfig} into the appropriate commands to send to the widescreen
* IME.
* <p>
* You must call {@link #setTextView} and {@link #getOnPrivateImeCommandListener} after creating
* this object to finish initializing it, but they don't have to be called immediately. The
* SearchWidescreenController will still accept calls to {@link #setSearchConfig} even before
* the TextView has been set, it will just wait for the TextView before doing anything.
*/
@SuppressWarnings("AndroidJdkLibsChecker")
public class SearchWidescreenController {
private final Handler mHandler = new Handler(Looper.getMainLooper());
private final Context mContext;
private final InputMethodManager mInputMethodManager;
@Nullable
private TextView mTextView;
private SurfaceControlViewHost mSurfaceControlViewHost;
private int mSurfaceHeight;
private int mSurfaceWidth;
private boolean mIsImeWidescreenViewSet = false;
private ViewGroup mOriginalParent;
private ViewGroup.LayoutParams mLayoutParams;
@NonNull
private SearchConfig mSearchConfig = SearchConfig.builder().build();
public SearchWidescreenController(@NonNull Context context) {
mContext = context;
mInputMethodManager = context.getSystemService(InputMethodManager.class);
}
/**
* Sets the text view that will be used to send the IME commands. This can be called at
* any time after the creation of this SearchWidescreenController, the controller will
* do nothing until the text view is set.
*/
public void setTextView(@Nullable TextView textView) {
if (mTextView != null) {
throw new IllegalStateException("TextView already set");
}
mTextView = textView;
initializeWindowInsetsListener();
}
/**
* Sets the {@link SearchConfig} of data to display in the IME widescreen.
*/
public void setSearchConfig(@Nullable SearchConfig searchConfig) {
if (searchConfig == null) {
searchConfig = SearchConfig.builder().build();
}
mSearchConfig = searchConfig;
}
@Nullable
private View getSearchResultsView() {
View view = mSearchConfig.getSearchResultsView();
if (view instanceof CarUiRecyclerView) {
return ((CarUiRecyclerView) view).getContainer();
}
return view;
}
private void initializeWindowInsetsListener() {
View.OnApplyWindowInsetsListener onApplyWindowInsetsListener = (v, insets) -> {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// We should never get here because we don't register the listener pre-R anyways.
throw new IllegalStateException("Cannot check if the ime is visible pre R");
}
if (mTextView == null) {
return v.onApplyWindowInsets(insets);
}
if (insets.isVisible(ime())) {
FrameLayout wideScreenImeContentAreaViewContainer;
View contentView = getSearchResultsView();
if (!mIsImeWidescreenViewSet && contentView != null) {
// When the IME opens after the setSearchConfig()
// call, setup a container and attach the view to the IME surface.
// The container is necessary because the content view needs to be removed
// and added back into the app when the IME closes, and views cannot be
// detatched from a SurfaceControlViewHost. We save the content view's old
// LayoutParams and parent for when it's reattached to the app.
mLayoutParams = contentView.getLayoutParams();
mOriginalParent = (ViewGroup) contentView.getParent();
if (mOriginalParent != null) {
mOriginalParent.removeView(contentView);
}
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT);
// This is different from getSearchResultsView() because getSearchResultsView()
// will turn CarUiRecyclerViews into their containing views. But here
// we want to actually know if it is a CarUiRecyclerView
View originalView = mSearchConfig.getSearchResultsView();
if (originalView instanceof CarUiRecyclerView) {
// We need to use a negative layout margin to have the list take the full
// content area.
params.topMargin = -originalView.getPaddingTop();
params.bottomMargin = -originalView.getPaddingBottom();
params.leftMargin = -originalView.getPaddingLeft();
params.rightMargin = -originalView.getPaddingRight();
}
wideScreenImeContentAreaViewContainer = new FrameLayout(mContext);
wideScreenImeContentAreaViewContainer.addView(contentView, params);
} else {
wideScreenImeContentAreaViewContainer = null;
}
displaySearchWideScreen();
mHandler.post(() -> {
if (mSurfaceControlViewHost != null
&& wideScreenImeContentAreaViewContainer != null
&& mSurfaceControlViewHost.getView() == null) {
// set the container with app's view into the Surface view.
mIsImeWidescreenViewSet = true;
mSurfaceControlViewHost.setView(
wideScreenImeContentAreaViewContainer, mSurfaceWidth,
mSurfaceHeight);
}
});
} else {
removeView();
mIsImeWidescreenViewSet = false;
}
return v.onApplyWindowInsets(insets);
};
// We need to add the window insets changed listener to the root view.
// The text view may not be part of the view hierarchy yet, so in that case
// add a listener for when it's attached to the window and then set the window insets
// listener.
// The listener has code that requires R, so only register it if the system is on R.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && mTextView != null) {
if (mTextView.isAttachedToWindow()) {
mTextView.getRootView().setOnApplyWindowInsetsListener(onApplyWindowInsetsListener);
} else {
mTextView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
@Override
public void onViewAttachedToWindow(View v) {
mTextView.getRootView().setOnApplyWindowInsetsListener(
onApplyWindowInsetsListener);
mTextView.removeOnAttachStateChangeListener(this);
}
@Override
public void onViewDetachedFromWindow(View v) {
}
});
}
}
}
private void displaySearchWideScreen() {
Uri contentUri = SearchResultsProvider.getSearchResultsTableUri(mContext);
// clear the table.
mContext.getContentResolver().delete(contentUri, null, null);
// If the app requested showing a view and we support it, we will show that instead
// of the templatized list items.
if (getSearchResultsView() != null && getSearchCapabilities().canShowSearchResultsView()) {
return;
}
List<? extends CarUiImeSearchListItem> searchItems = mSearchConfig.getSearchResultItems();
if (searchItems == null) {
mInputMethodManager.sendAppPrivateCommand(mTextView, WIDE_SCREEN_ACTION, null);
return;
}
for (int id = 0; id < searchItems.size(); id++) {
CarUiImeSearchListItem item = searchItems.get(id);
ContentValues values = new ContentValues();
values.put(SearchResultsProvider.ITEM_ID, id);
values.put(SearchResultsProvider.SECONDARY_IMAGE_ID, id);
BitmapDrawable icon = (BitmapDrawable) item.getIcon();
values.put(SearchResultsProvider.PRIMARY_IMAGE_BLOB,
icon != null ? bitmapToByteArray(icon.getBitmap()) : null);
BitmapDrawable supplementalIcon = (BitmapDrawable) item.getSupplementalIcon();
values.put(SearchResultsProvider.SECONDARY_IMAGE_BLOB,
supplementalIcon != null ? bitmapToByteArray(supplementalIcon.getBitmap())
: null);
values.put(SearchResultsProvider.TITLE,
item.getTitle() != null ? item.getTitle().getPreferredText().toString() : null);
values.put(SearchResultsProvider.SUBTITLE,
item.getBody() != null ? CarUiText.combineMultiLine(item.getBody()).toString()
: null);
mContext.getContentResolver().insert(contentUri, values);
}
mInputMethodManager.sendAppPrivateCommand(mTextView, WIDE_SCREEN_SEARCH_RESULTS,
new Bundle());
}
/**
* Remove the app's view from the container and attach it back to its original parent.
*/
private void removeView() {
View contentView = getSearchResultsView();
if (mOriginalParent != null && contentView != null) {
mHandler.post(() -> {
ViewGroup parent = (ViewGroup) contentView.getParent();
if (parent != null) {
parent.removeView(contentView);
}
mOriginalParent.addView(contentView, mLayoutParams);
mOriginalParent.requestLayout();
mOriginalParent = null;
mLayoutParams = null;
});
}
}
private void onItemClicked(String itemId) {
List<? extends CarUiImeSearchListItem> items = mSearchConfig.getSearchResultItems();
CarUiImeSearchListItem item = items == null
? null
: items.get(Integer.parseInt(itemId));
if (item != null) {
CarUiContentListItem.OnClickListener listener = item.getOnClickListener();
if (listener != null) {
listener.onClick(item);
}
}
}
private void onSecondaryImageClicked(String secondaryImageId) {
List<? extends CarUiImeSearchListItem> items = mSearchConfig.getSearchResultItems();
CarUiImeSearchListItem item = items == null
? null
: items.get(Integer.parseInt(secondaryImageId));
if (item != null) {
CarUiContentListItem.OnClickListener listener =
item.getSupplementalIconOnClickListener();
if (listener != null) {
listener.onClick(item);
}
}
}
private void onSurfaceInfo(int displayId, IBinder binder, int width, int height) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// Other code like getSearchCapabilities().canShowSearchResultsView() returning false
// should prevent us from getting here.
throw new IllegalStateException("Views in the widescreen ime aren't supported pre R");
}
DisplayManager dm = mContext.getSystemService(DisplayManager.class);
Display display = dm.getDisplay(displayId);
mSurfaceControlViewHost = new SurfaceControlViewHost(mContext,
display, binder);
mSurfaceHeight = height;
mSurfaceWidth = width;
Bundle bundle = new Bundle();
bundle.putParcelable(CONTENT_AREA_SURFACE_PACKAGE,
mSurfaceControlViewHost.getSurfacePackage());
mInputMethodManager.sendAppPrivateCommand(mTextView, WIDE_SCREEN_ACTION, bundle);
}
private void reLayout(int width, int height) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// Other code like getSearchCapabilities().canShowSearchResultsView() returning false
// should prevent us from getting here.
throw new IllegalStateException("Views in the widescreen ime aren't supported pre R");
}
mSurfaceHeight = height;
mSurfaceWidth = width;
if (mSurfaceControlViewHost != null) {
mSurfaceControlViewHost.relayout(width, height);
}
}
private void onPostLoadSearchResults() {
Uri contentUri = SearchResultsProvider.getSearchResultsTableUri(mContext);
mContext.getContentResolver().delete(contentUri, null, null);
}
/**
* Gets a listener that should be called when the text view passed to {@link #setTextView}
* receives a call to it's {@link TextView#onPrivateIMECommand}
*/
public BiConsumer<String, Bundle> getOnPrivateImeCommandListener() {
return (action, data) -> {
if (WIDE_SCREEN_CLEAR_DATA_ACTION.equals(action) && mTextView != null) {
// clear the text.
mTextView.setText("");
}
if (WIDE_SCREEN_POST_LOAD_SEARCH_RESULTS_ACTION.equals(action)) {
onPostLoadSearchResults();
}
if (data == null) {
return;
}
if (data.getString(SEARCH_RESULT_ITEM_ID_LIST) != null) {
onItemClicked(data.getString(SEARCH_RESULT_ITEM_ID_LIST));
}
if (data.getString(SEARCH_RESULT_SUPPLEMENTAL_ICON_ID_LIST) != null) {
onSecondaryImageClicked(data.getString(SEARCH_RESULT_SUPPLEMENTAL_ICON_ID_LIST));
}
int displayId = data.getInt(CONTENT_AREA_SURFACE_DISPLAY_ID);
int height = data.getInt(CONTENT_AREA_SURFACE_HEIGHT);
int width = data.getInt(CONTENT_AREA_SURFACE_WIDTH);
IBinder binder = data.getBinder(CONTENT_AREA_SURFACE_HOST_TOKEN);
if (binder != null) {
onSurfaceInfo(displayId, binder, width, height);
return;
}
if (height != 0 || width != 0) {
reLayout(width, height);
}
};
}
/**
* Gets the {@link SearchCapabilities} that the system currently supports.
*
* This non-static version does not require a context to be passed in explicitly.
*/
public SearchCapabilities getSearchCapabilities() {
return getSearchCapabilities(mContext);
}
/**
* Gets the {@link SearchCapabilities} that the system currently supports.
*/
public static SearchCapabilities getSearchCapabilities(Context context) {
boolean isWideScreenMode = getBooleanSystemProperty(context.getResources(),
R.string.car_ui_ime_wide_screen_system_property_name, false)
&& Build.VERSION.SDK_INT >= Build.VERSION_CODES.R;
boolean allowAllAppsToShowSearchResultsView = context.getResources().getBoolean(
R.bool.car_ui_ime_wide_screen_allow_app_hide_content_area);
String[] allowedPackageNames = context.getResources()
.getStringArray(R.array.car_ui_ime_wide_screen_allowed_package_list);
boolean packageNameAllowed = false;
for (String name : allowedPackageNames) {
if (name.equals(context.getPackageName())) {
packageNameAllowed = true;
break;
}
}
boolean canShowSearchResultsView = isWideScreenMode
&& (packageNameAllowed || allowAllAppsToShowSearchResultsView);
return SearchCapabilities.builder()
.setCanShowSearchResultsView(canShowSearchResultsView)
.setCanShowSearchResultItems(isWideScreenMode)
.build();
}
private static byte[] bitmapToByteArray(Bitmap bitmap) {
Parcel parcel = Parcel.obtain();
bitmap.writeToParcel(parcel, 0);
byte[] bytes = parcel.marshall();
parcel.recycle();
return bytes;
}
}