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