/* * Copyright (C) 2020 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.systemui.media.dialog; import static android.view.WindowInsets.Type.navigationBars; import static android.view.WindowInsets.Type.statusBars; import android.annotation.NonNull; import android.app.WallpaperColors; import android.bluetooth.BluetoothLeBroadcast; import android.bluetooth.BluetoothLeBroadcastMetadata; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.PixelFormat; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.Window; import android.view.WindowInsets; import android.view.WindowManager; import android.widget.Button; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; import androidx.annotation.VisibleForTesting; import androidx.core.graphics.drawable.IconCompat; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.android.systemui.R; import com.android.systemui.broadcast.BroadcastSender; import com.android.systemui.statusbar.phone.SystemUIDialog; import java.util.concurrent.Executor; import java.util.concurrent.Executors; /** * Base dialog for media output UI */ public abstract class MediaOutputBaseDialog extends SystemUIDialog implements MediaOutputController.Callback, Window.Callback { private static final String TAG = "MediaOutputDialog"; private static final String EMPTY_TITLE = " "; private static final String PREF_NAME = "MediaOutputDialog"; private static final String PREF_IS_LE_BROADCAST_FIRST_LAUNCH = "PrefIsLeBroadcastFirstLaunch"; private static final boolean DEBUG = true; private static final int HANDLE_BROADCAST_FAILED_DELAY = 3000; protected final Handler mMainThreadHandler = new Handler(Looper.getMainLooper()); private final RecyclerView.LayoutManager mLayoutManager; final Context mContext; final MediaOutputController mMediaOutputController; final BroadcastSender mBroadcastSender; @VisibleForTesting View mDialogView; private TextView mHeaderTitle; private TextView mHeaderSubtitle; private ImageView mHeaderIcon; private ImageView mAppResourceIcon; private ImageView mBroadcastIcon; private RecyclerView mDevicesRecyclerView; private LinearLayout mDeviceListLayout; private LinearLayout mCastAppLayout; private LinearLayout mMediaMetadataSectionLayout; private Button mDoneButton; private Button mStopButton; private Button mAppButton; private int mListMaxHeight; private int mItemHeight; private int mListPaddingTop; private WallpaperColors mWallpaperColors; private boolean mShouldLaunchLeBroadcastDialog; private boolean mIsLeBroadcastCallbackRegistered; private boolean mDismissing; MediaOutputBaseAdapter mAdapter; protected Executor mExecutor; private final ViewTreeObserver.OnGlobalLayoutListener mDeviceListLayoutListener = () -> { ViewGroup.LayoutParams params = mDeviceListLayout.getLayoutParams(); int totalItemsHeight = mAdapter.getItemCount() * mItemHeight + mListPaddingTop; int correctHeight = Math.min(totalItemsHeight, mListMaxHeight); // Set max height for list if (correctHeight != params.height) { params.height = correctHeight; mDeviceListLayout.setLayoutParams(params); } }; private final BluetoothLeBroadcast.Callback mBroadcastCallback = new BluetoothLeBroadcast.Callback() { @Override public void onBroadcastStarted(int reason, int broadcastId) { if (DEBUG) { Log.d(TAG, "onBroadcastStarted(), reason = " + reason + ", broadcastId = " + broadcastId); } mMainThreadHandler.post(() -> handleLeBroadcastStarted()); } @Override public void onBroadcastStartFailed(int reason) { if (DEBUG) { Log.d(TAG, "onBroadcastStartFailed(), reason = " + reason); } mMainThreadHandler.postDelayed(() -> handleLeBroadcastStartFailed(), HANDLE_BROADCAST_FAILED_DELAY); } @Override public void onBroadcastMetadataChanged(int broadcastId, @NonNull BluetoothLeBroadcastMetadata metadata) { if (DEBUG) { Log.d(TAG, "onBroadcastMetadataChanged(), broadcastId = " + broadcastId + ", metadata = " + metadata); } mMainThreadHandler.post(() -> handleLeBroadcastMetadataChanged()); } @Override public void onBroadcastStopped(int reason, int broadcastId) { if (DEBUG) { Log.d(TAG, "onBroadcastStopped(), reason = " + reason + ", broadcastId = " + broadcastId); } mMainThreadHandler.post(() -> handleLeBroadcastStopped()); } @Override public void onBroadcastStopFailed(int reason) { if (DEBUG) { Log.d(TAG, "onBroadcastStopFailed(), reason = " + reason); } mMainThreadHandler.post(() -> handleLeBroadcastStopFailed()); } @Override public void onBroadcastUpdated(int reason, int broadcastId) { if (DEBUG) { Log.d(TAG, "onBroadcastUpdated(), reason = " + reason + ", broadcastId = " + broadcastId); } mMainThreadHandler.post(() -> handleLeBroadcastUpdated()); } @Override public void onBroadcastUpdateFailed(int reason, int broadcastId) { if (DEBUG) { Log.d(TAG, "onBroadcastUpdateFailed(), reason = " + reason + ", broadcastId = " + broadcastId); } mMainThreadHandler.post(() -> handleLeBroadcastUpdateFailed()); } @Override public void onPlaybackStarted(int reason, int broadcastId) { } @Override public void onPlaybackStopped(int reason, int broadcastId) { } }; private class LayoutManagerWrapper extends LinearLayoutManager { LayoutManagerWrapper(Context context) { super(context); } @Override public void onLayoutCompleted(RecyclerView.State state) { super.onLayoutCompleted(state); mMediaOutputController.setRefreshing(false); mMediaOutputController.refreshDataSetIfNeeded(); } } public MediaOutputBaseDialog(Context context, BroadcastSender broadcastSender, MediaOutputController mediaOutputController) { super(context, R.style.Theme_SystemUI_Dialog_Media); // Save the context that is wrapped with our theme. mContext = getContext(); mBroadcastSender = broadcastSender; mMediaOutputController = mediaOutputController; mLayoutManager = new LayoutManagerWrapper(mContext); mListMaxHeight = context.getResources().getDimensionPixelSize( R.dimen.media_output_dialog_list_max_height); mItemHeight = context.getResources().getDimensionPixelSize( R.dimen.media_output_dialog_list_item_height); mListPaddingTop = mContext.getResources().getDimensionPixelSize( R.dimen.media_output_dialog_list_padding_top); mExecutor = Executors.newSingleThreadExecutor(); } @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); mDialogView = LayoutInflater.from(mContext).inflate(R.layout.media_output_dialog, null); final Window window = getWindow(); final WindowManager.LayoutParams lp = window.getAttributes(); lp.gravity = Gravity.CENTER; // Config insets to make sure the layout is above the navigation bar lp.setFitInsetsTypes(statusBars() | navigationBars()); lp.setFitInsetsSides(WindowInsets.Side.all()); lp.setFitInsetsIgnoringVisibility(true); window.setAttributes(lp); window.setContentView(mDialogView); window.setTitle(mContext.getString(R.string.media_output_dialog_accessibility_title)); mHeaderTitle = mDialogView.requireViewById(R.id.header_title); mHeaderSubtitle = mDialogView.requireViewById(R.id.header_subtitle); mHeaderIcon = mDialogView.requireViewById(R.id.header_icon); mDevicesRecyclerView = mDialogView.requireViewById(R.id.list_result); mMediaMetadataSectionLayout = mDialogView.requireViewById(R.id.media_metadata_section); mDeviceListLayout = mDialogView.requireViewById(R.id.device_list); mDoneButton = mDialogView.requireViewById(R.id.done); mStopButton = mDialogView.requireViewById(R.id.stop); mAppButton = mDialogView.requireViewById(R.id.launch_app_button); mAppResourceIcon = mDialogView.requireViewById(R.id.app_source_icon); mCastAppLayout = mDialogView.requireViewById(R.id.cast_app_section); mBroadcastIcon = mDialogView.requireViewById(R.id.broadcast_icon); mDeviceListLayout.getViewTreeObserver().addOnGlobalLayoutListener( mDeviceListLayoutListener); // Init device list mLayoutManager.setAutoMeasureEnabled(true); mDevicesRecyclerView.setLayoutManager(mLayoutManager); mDevicesRecyclerView.setAdapter(mAdapter); mDevicesRecyclerView.setHasFixedSize(false); // Init bottom buttons mDoneButton.setOnClickListener(v -> dismiss()); mStopButton.setOnClickListener(v -> onStopButtonClick()); mAppButton.setOnClickListener(mMediaOutputController::tryToLaunchMediaApplication); mMediaMetadataSectionLayout.setOnClickListener( mMediaOutputController::tryToLaunchMediaApplication); mDismissing = false; } @Override public void dismiss() { // TODO(287191450): remove this once expensive binder calls are removed from refresh(). // Due to these binder calls on the UI thread, calling refresh() during dismissal causes // significant frame drops for the dismissal animation. Since the dialog is going away // anyway, we use this state to turn refresh() into a no-op. mDismissing = true; super.dismiss(); } @Override public void start() { mMediaOutputController.start(this); if (isBroadcastSupported() && !mIsLeBroadcastCallbackRegistered) { mMediaOutputController.registerLeBroadcastServiceCallback(mExecutor, mBroadcastCallback); mIsLeBroadcastCallbackRegistered = true; } } @Override public void stop() { // unregister broadcast callback should only depend on profile and registered flag // rather than remote device or broadcast state // otherwise it might have risks of leaking registered callback handle if (mMediaOutputController.isBroadcastSupported() && mIsLeBroadcastCallbackRegistered) { mMediaOutputController.unregisterLeBroadcastServiceCallback(mBroadcastCallback); mIsLeBroadcastCallbackRegistered = false; } mMediaOutputController.stop(); } @VisibleForTesting void refresh() { refresh(false); } void refresh(boolean deviceSetChanged) { // TODO(287191450): remove binder calls in this method from the UI thread. // If the dialog is going away or is already refreshing, do nothing. if (mDismissing || mMediaOutputController.isRefreshing()) { return; } mMediaOutputController.setRefreshing(true); // Update header icon final int iconRes = getHeaderIconRes(); final IconCompat headerIcon = getHeaderIcon(); final IconCompat appSourceIcon = getAppSourceIcon(); boolean colorSetUpdated = false; mCastAppLayout.setVisibility( mMediaOutputController.shouldShowLaunchSection() ? View.VISIBLE : View.GONE); if (iconRes != 0) { mHeaderIcon.setVisibility(View.VISIBLE); mHeaderIcon.setImageResource(iconRes); } else if (headerIcon != null) { Icon icon = headerIcon.toIcon(mContext); if (icon.getType() != Icon.TYPE_BITMAP && icon.getType() != Icon.TYPE_ADAPTIVE_BITMAP) { // icon doesn't support getBitmap, use default value for color scheme updateButtonBackgroundColorFilter(); updateDialogBackgroundColor(); } else { Configuration config = mContext.getResources().getConfiguration(); int currentNightMode = config.uiMode & Configuration.UI_MODE_NIGHT_MASK; boolean isDarkThemeOn = currentNightMode == Configuration.UI_MODE_NIGHT_YES; WallpaperColors wallpaperColors = WallpaperColors.fromBitmap(icon.getBitmap()); colorSetUpdated = !wallpaperColors.equals(mWallpaperColors); if (colorSetUpdated) { mAdapter.updateColorScheme(wallpaperColors, isDarkThemeOn); updateButtonBackgroundColorFilter(); updateDialogBackgroundColor(); } } mHeaderIcon.setVisibility(View.VISIBLE); mHeaderIcon.setImageIcon(icon); } else { updateButtonBackgroundColorFilter(); updateDialogBackgroundColor(); mHeaderIcon.setVisibility(View.GONE); } if (appSourceIcon != null) { Icon appIcon = appSourceIcon.toIcon(mContext); mAppResourceIcon.setColorFilter(mMediaOutputController.getColorItemContent()); mAppResourceIcon.setImageIcon(appIcon); } else { Drawable appIconDrawable = mMediaOutputController.getAppSourceIconFromPackage(); if (appIconDrawable != null) { mAppResourceIcon.setImageDrawable(appIconDrawable); } else { mAppResourceIcon.setVisibility(View.GONE); } } if (mHeaderIcon.getVisibility() == View.VISIBLE) { final int size = getHeaderIconSize(); final int padding = mContext.getResources().getDimensionPixelSize( R.dimen.media_output_dialog_header_icon_padding); mHeaderIcon.setLayoutParams(new LinearLayout.LayoutParams(size + padding, size)); } mAppButton.setText(mMediaOutputController.getAppSourceName()); // Update title and subtitle mHeaderTitle.setText(getHeaderText()); final CharSequence subTitle = getHeaderSubtitle(); if (TextUtils.isEmpty(subTitle)) { mHeaderSubtitle.setVisibility(View.GONE); mHeaderTitle.setGravity(Gravity.START | Gravity.CENTER_VERTICAL); } else { mHeaderSubtitle.setVisibility(View.VISIBLE); mHeaderSubtitle.setText(subTitle); mHeaderTitle.setGravity(Gravity.NO_GRAVITY); } // Show when remote media session is available or // when the device supports BT LE audio + media is playing mStopButton.setVisibility(getStopButtonVisibility()); mStopButton.setEnabled(true); mStopButton.setText(getStopButtonText()); mStopButton.setOnClickListener(v -> onStopButtonClick()); mBroadcastIcon.setVisibility(getBroadcastIconVisibility()); mBroadcastIcon.setOnClickListener(v -> onBroadcastIconClick()); if (!mAdapter.isDragging()) { int currentActivePosition = mAdapter.getCurrentActivePosition(); if (!colorSetUpdated && !deviceSetChanged && currentActivePosition >= 0 && currentActivePosition < mAdapter.getItemCount()) { mAdapter.notifyItemChanged(currentActivePosition); } else { mAdapter.updateItems(); } } else { mMediaOutputController.setRefreshing(false); mMediaOutputController.refreshDataSetIfNeeded(); } } private void updateButtonBackgroundColorFilter() { ColorFilter buttonColorFilter = new PorterDuffColorFilter( mMediaOutputController.getColorButtonBackground(), PorterDuff.Mode.SRC_IN); mDoneButton.getBackground().setColorFilter(buttonColorFilter); mStopButton.getBackground().setColorFilter(buttonColorFilter); mDoneButton.setTextColor(mMediaOutputController.getColorPositiveButtonText()); } private void updateDialogBackgroundColor() { getDialogView().getBackground().setTint(mMediaOutputController.getColorDialogBackground()); mDeviceListLayout.setBackgroundColor(mMediaOutputController.getColorDialogBackground()); } private Drawable resizeDrawable(Drawable drawable, int size) { if (drawable == null) { return null; } int width = drawable.getIntrinsicWidth(); int height = drawable.getIntrinsicHeight(); Bitmap.Config config = drawable.getOpacity() != PixelFormat.OPAQUE ? Bitmap.Config.ARGB_8888 : Bitmap.Config.RGB_565; Bitmap bitmap = Bitmap.createBitmap(width, height, config); Canvas canvas = new Canvas(bitmap); drawable.setBounds(0, 0, width, height); drawable.draw(canvas); return new BitmapDrawable(mContext.getResources(), Bitmap.createScaledBitmap(bitmap, size, size, false)); } public void handleLeBroadcastStarted() { // Waiting for the onBroadcastMetadataChanged. The UI launchs the broadcast dialog when // the metadata is ready. mShouldLaunchLeBroadcastDialog = true; } public void handleLeBroadcastStartFailed() { mStopButton.setText(R.string.media_output_broadcast_start_failed); mStopButton.setEnabled(false); refresh(); } public void handleLeBroadcastMetadataChanged() { if (mShouldLaunchLeBroadcastDialog) { startLeBroadcastDialog(); mShouldLaunchLeBroadcastDialog = false; } refresh(); } public void handleLeBroadcastStopped() { mShouldLaunchLeBroadcastDialog = false; refresh(); } public void handleLeBroadcastStopFailed() { refresh(); } public void handleLeBroadcastUpdated() { refresh(); } public void handleLeBroadcastUpdateFailed() { refresh(); } protected void startLeBroadcast() { mStopButton.setText(R.string.media_output_broadcast_starting); mStopButton.setEnabled(false); if (!mMediaOutputController.startBluetoothLeBroadcast()) { // If the system can't execute "broadcast start", then UI shows the error. handleLeBroadcastStartFailed(); } } protected boolean startLeBroadcastDialogForFirstTime(){ SharedPreferences sharedPref = mContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); if (sharedPref != null && sharedPref.getBoolean(PREF_IS_LE_BROADCAST_FIRST_LAUNCH, true)) { Log.d(TAG, "PREF_IS_LE_BROADCAST_FIRST_LAUNCH: true"); mMediaOutputController.launchLeBroadcastNotifyDialog(mDialogView, mBroadcastSender, MediaOutputController.BroadcastNotifyDialog.ACTION_FIRST_LAUNCH, (d, w) -> { startLeBroadcast(); }); SharedPreferences.Editor editor = sharedPref.edit(); editor.putBoolean(PREF_IS_LE_BROADCAST_FIRST_LAUNCH, false); editor.apply(); return true; } return false; } protected void startLeBroadcastDialog() { mMediaOutputController.launchMediaOutputBroadcastDialog(mDialogView, mBroadcastSender); refresh(); } protected void stopLeBroadcast() { mStopButton.setEnabled(false); if (!mMediaOutputController.stopBluetoothLeBroadcast()) { // If the system can't execute "broadcast stop", then UI does refresh. mMainThreadHandler.post(() -> refresh()); } } abstract IconCompat getAppSourceIcon(); abstract int getHeaderIconRes(); abstract IconCompat getHeaderIcon(); abstract int getHeaderIconSize(); abstract CharSequence getHeaderText(); abstract CharSequence getHeaderSubtitle(); abstract int getStopButtonVisibility(); public CharSequence getStopButtonText() { return mContext.getText(R.string.keyboard_key_media_stop); } public void onStopButtonClick() { mMediaOutputController.releaseSession(); dismiss(); } public int getBroadcastIconVisibility() { return View.GONE; } public void onBroadcastIconClick() { // Do nothing. } public boolean isBroadcastSupported() { return false; } @Override public void onMediaChanged() { mMainThreadHandler.post(() -> refresh()); } @Override public void onMediaStoppedOrPaused() { if (isShowing()) { dismiss(); } } @Override public void onRouteChanged() { mMainThreadHandler.post(() -> refresh()); } @Override public void onDeviceListChanged() { mMainThreadHandler.post(() -> refresh(true)); } @Override public void dismissDialog() { mBroadcastSender.closeSystemDialogs(); } void onHeaderIconClick() { } View getDialogView() { return mDialogView; } }