1 /* <lambda>null2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.car.customization.tool.ui 18 19 import android.graphics.PixelFormat 20 import android.util.Log 21 import android.view.Gravity 22 import android.view.LayoutInflater 23 import android.view.View 24 import android.view.ViewTreeObserver 25 import android.view.WindowInsets 26 import android.view.WindowManager 27 import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE 28 import android.view.WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL 29 import android.view.WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH 30 import android.widget.ImageButton 31 import android.widget.LinearLayout 32 import androidx.recyclerview.widget.LinearLayoutManager 33 import androidx.recyclerview.widget.RecyclerView 34 import com.android.car.customization.tool.R 35 import com.android.car.customization.tool.domain.Action 36 import com.android.car.customization.tool.domain.Observer 37 import com.android.car.customization.tool.domain.PageState 38 import com.android.car.customization.tool.domain.ToggleUiAction 39 import com.android.car.customization.tool.ui.menu.HorizontalMarginItemDecoration 40 import com.android.car.customization.tool.ui.menu.MenuAdapter 41 import com.android.car.customization.tool.ui.panel.header.PanelHeaderAdapter 42 import com.android.car.customization.tool.ui.panel.items.PanelItemsAdapter 43 import javax.inject.Inject 44 import kotlin.math.max 45 import kotlin.math.min 46 47 /** 48 * Renders the UI of the tool. 49 */ 50 internal class CustomizationToolUI @Inject constructor( 51 private val windowManager: WindowManager, 52 layoutInflater: LayoutInflater, 53 ) : Observer<PageState> { 54 55 lateinit var handleAction: (action: Action) -> Unit 56 57 private val windowLayoutParams: WindowManager.LayoutParams = WindowManager.LayoutParams().apply { 58 width = WindowManager.LayoutParams.WRAP_CONTENT 59 height = WindowManager.LayoutParams.WRAP_CONTENT 60 type = WindowManager.LayoutParams.TYPE_ACCESSIBILITY_OVERLAY 61 format = PixelFormat.TRANSPARENT 62 flags = flags or FLAG_NOT_FOCUSABLE or FLAG_NOT_TOUCH_MODAL or FLAG_WATCH_OUTSIDE_TOUCH 63 gravity = Gravity.CENTER_HORIZONTAL or Gravity.TOP 64 y = 0 65 x = 0 66 } 67 private val mMainLayout: MainLayoutView = layoutInflater.inflate( 68 R.layout.main_layout, 69 /*root=*/null 70 ) as MainLayoutView 71 private val mEntryPoint: ImageButton 72 private val mMenu: RecyclerView 73 74 private val mControlPanelContainer: LinearLayout 75 private val mControlPanelHeader: RecyclerView 76 private val mControlPanelItems: RecyclerView 77 78 private var usableWindowWidth: Int = calculateUsableWidth() 79 80 init { 81 setWindowMoveAnimation(false) 82 mMainLayout.setTouchListener { inside -> setKeyboardActive(inside) } 83 windowManager.addView(mMainLayout, windowLayoutParams) 84 85 mEntryPoint = mMainLayout.requireViewById<ImageButton>(R.id.entry_point).apply { 86 setOnClickListener { 87 handleAction(ToggleUiAction) 88 } 89 } 90 91 mMenu = mMainLayout.requireViewById<RecyclerView>(R.id.menu).apply { 92 layoutManager = LinearLayoutManager( 93 context, 94 LinearLayoutManager.HORIZONTAL, 95 /*reverseLayout=*/false 96 ) 97 itemAnimator = null 98 addItemDecoration( 99 HorizontalMarginItemDecoration( 100 resources.getDimensionPixelSize(R.dimen.menu_items_horizontal_margin) 101 ) 102 ) 103 } 104 105 mControlPanelContainer = mMainLayout.requireViewById(R.id.control_panel_container) 106 mControlPanelHeader = mMainLayout 107 .requireViewById<RecyclerView>(R.id.control_panel_header) 108 .apply { 109 layoutManager = LinearLayoutManager( 110 context, 111 LinearLayoutManager.HORIZONTAL, 112 /*reverseLayout=*/ false 113 ) 114 itemAnimator = null 115 } 116 mControlPanelItems = mMainLayout.requireViewById<RecyclerView>(R.id.control_panel_items).apply { 117 layoutManager = LinearLayoutManager( 118 context, 119 LinearLayoutManager.VERTICAL, 120 /*reverseLayout=*/false 121 ) 122 itemAnimator = null 123 } 124 125 mMainLayout.setOnApplyWindowInsetsListener { _, insets -> 126 val newUsableWindowWidth = calculateUsableWidth() 127 if (newUsableWindowWidth != usableWindowWidth) { 128 usableWindowWidth = newUsableWindowWidth 129 updateToolWidth() 130 } 131 insets 132 } 133 } 134 135 override fun render(state: PageState) { 136 if (!state.isOpen) { 137 mMenu.visibility = View.GONE 138 mControlPanelContainer.visibility = View.GONE 139 } else { 140 mMenu.run { 141 if (adapter == null) adapter = MenuAdapter(handleAction) 142 (adapter as MenuAdapter).submitList(state.menu.currentParentNode.subMenu) 143 visibility = View.VISIBLE 144 } 145 146 if (mControlPanelItems.adapter == null) { 147 mControlPanelItems.adapter = PanelItemsAdapter(handleAction) 148 } 149 if (mControlPanelHeader.adapter == null) { 150 mControlPanelHeader.adapter = PanelHeaderAdapter(handleAction) 151 } 152 if (state.panel != null) { 153 (mControlPanelHeader.adapter as PanelHeaderAdapter).submitList(state.panel.headerItems) 154 (mControlPanelItems.adapter as PanelItemsAdapter).submitList(state.panel.items) 155 mControlPanelContainer.visibility = View.VISIBLE 156 } else { 157 (mControlPanelHeader.adapter as PanelHeaderAdapter).submitList(listOf()) 158 (mControlPanelItems.adapter as PanelItemsAdapter).submitList(listOf()) 159 mControlPanelContainer.visibility = View.GONE 160 } 161 } 162 163 waitForMeasureAndUpdateWidth() 164 } 165 166 /** 167 * A utility function to calculate the necessary width of the tool. 168 * 169 * On bigger screens WRAP_CONTENT doesn't behave correctly for dialogs because of a size limit 170 * defined in the config_prefDialogWidth property. For this reason 171 * the width of the tool is calculated manually based on the content displayed. 172 */ 173 private fun waitForMeasureAndUpdateWidth() { 174 mMainLayout.viewTreeObserver.addOnGlobalLayoutListener( 175 object : ViewTreeObserver.OnGlobalLayoutListener { 176 override fun onGlobalLayout() { 177 mMainLayout.viewTreeObserver.removeOnGlobalLayoutListener(/*victim=*/this) 178 usableWindowWidth = calculateUsableWidth() 179 updateToolWidth() 180 } 181 } 182 ) 183 } 184 185 private fun calculateUsableWidth(): Int { 186 val windowMetrics = windowManager.currentWindowMetrics 187 val insets = windowMetrics.windowInsets.getInsetsIgnoringVisibility( 188 /*typeMask=*/WindowInsets.Type.navigationBars() or WindowInsets.Type.displayCutout() 189 ) 190 return windowMetrics.bounds.width() - insets.right - insets.left 191 } 192 193 private fun updateToolWidth() { 194 val windowMetrics = windowManager.currentWindowMetrics 195 mMainLayout.measure( 196 windowMetrics.bounds.width(), 197 windowMetrics.bounds.height() 198 ) 199 val mainWidth = mMainLayout.measuredWidth 200 201 val panelMinWidth = if (mControlPanelContainer.visibility == View.VISIBLE) { 202 (usableWindowWidth * 0.8).toInt() 203 } else { 204 -1 205 } 206 207 windowLayoutParams.width = min(max(mainWidth, panelMinWidth), usableWindowWidth) 208 windowManager.updateViewLayout(mMainLayout, windowLayoutParams) 209 } 210 211 /** 212 * Accessing a PRIVATE_FLAG_NO_MOVE_ANIMATION to enable or disable window movement animation. 213 * 214 * Disabling the window animation prevents the UI from "shaking" when the window changes width, 215 * while enabling it allows the window to move smoothly while dragging the tool. 216 */ 217 private fun setWindowMoveAnimation(enable: Boolean) { 218 try { 219 val privateFlagsField = windowLayoutParams.javaClass.getField("privateFlags") 220 val newValue = if (enable) { 221 (privateFlagsField.get(windowLayoutParams) as Int) and (1 shl 6).inv() 222 } else { 223 (privateFlagsField.get(windowLayoutParams) as Int) or (1 shl 6) 224 } 225 privateFlagsField.set(windowLayoutParams, newValue) 226 } catch (e: Exception) { 227 Log.e("CustomizationToolUi", "Setting window animation failed! \n $e") 228 } 229 } 230 231 /** 232 * Changing flags on [WindowManager.LayoutParams] based on clicks inside or outside the Window 233 * to enable / disable keyboard access in the tool. 234 */ 235 private fun setKeyboardActive(active: Boolean) { 236 if (active && windowLayoutParams.flags and FLAG_NOT_FOCUSABLE != 0) { 237 windowLayoutParams.flags = windowLayoutParams.flags and FLAG_NOT_FOCUSABLE.inv() 238 windowManager.updateViewLayout(mMainLayout, windowLayoutParams) 239 } else if (!active && windowLayoutParams.flags and FLAG_NOT_FOCUSABLE == 0) { 240 windowLayoutParams.flags = windowLayoutParams.flags or FLAG_NOT_FOCUSABLE 241 windowManager.updateViewLayout(mMainLayout, windowLayoutParams) 242 } 243 } 244 } 245