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.systemui.biometrics.ui.binder
18 
19 import android.content.Context
20 import android.content.res.Resources
21 import android.hardware.biometrics.PromptContentItem
22 import android.hardware.biometrics.PromptContentItemBulletedText
23 import android.hardware.biometrics.PromptContentItemPlainText
24 import android.hardware.biometrics.PromptContentView
25 import android.hardware.biometrics.PromptContentViewWithMoreOptionsButton
26 import android.hardware.biometrics.PromptVerticalListContentView
27 import android.text.SpannableString
28 import android.text.Spanned
29 import android.text.TextPaint
30 import android.text.style.BulletSpan
31 import android.view.LayoutInflater
32 import android.view.View
33 import android.view.ViewGroup.LayoutParams.MATCH_PARENT
34 import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
35 import android.view.ViewTreeObserver
36 import android.widget.Button
37 import android.widget.LinearLayout
38 import android.widget.Space
39 import android.widget.TextView
40 import com.android.settingslib.Utils
41 import com.android.systemui.biometrics.ui.BiometricPromptLayout
42 import com.android.systemui.biometrics.Utils.ellipsize
43 import com.android.systemui.lifecycle.repeatWhenAttached
44 import com.android.systemui.res.R
45 import kotlin.math.ceil
46 
47 private const val TAG = "BiometricCustomizedViewBinder"
48 
49 /** Sub-binder for [BiometricPromptLayout.customized_view_container]. */
50 object BiometricCustomizedViewBinder {
51     const val MAX_DESCRIPTION_CHARACTER_NUMBER = 225
52 
53     fun bind(
54         customizedViewContainer: LinearLayout,
55         contentView: PromptContentView?,
56         legacyCallback: Spaghetti.Callback
57     ) {
58         customizedViewContainer.repeatWhenAttached { containerView ->
59             if (contentView == null) {
60                 containerView.visibility = View.GONE
61                 return@repeatWhenAttached
62             }
63 
64             containerView.width { containerWidth ->
65                 if (containerWidth == 0) {
66                     return@width
67                 }
68                 (containerView as LinearLayout).addView(
69                     contentView.toView(containerView.context, containerWidth, legacyCallback),
70                     LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT)
71                 )
72                 containerView.visibility = View.VISIBLE
73             }
74         }
75     }
76 }
77 
toViewnull78 private fun PromptContentView.toView(
79     context: Context,
80     containerViewWidth: Int,
81     legacyCallback: Spaghetti.Callback
82 ): View {
83     return when (this) {
84         is PromptVerticalListContentView -> initLayout(context, containerViewWidth)
85         is PromptContentViewWithMoreOptionsButton -> initLayout(context, legacyCallback)
86         else -> {
87             throw IllegalStateException("No such PromptContentView: $this")
88         }
89     }
90 }
91 
LayoutInflaternull92 private fun LayoutInflater.inflateContentView(id: Int, description: String?): LinearLayout {
93     val contentView = inflate(id, null) as LinearLayout
94 
95     val descriptionView = contentView.requireViewById<TextView>(R.id.customized_view_description)
96     if (!description.isNullOrEmpty()) {
97         descriptionView.text =
98             description.ellipsize(BiometricCustomizedViewBinder.MAX_DESCRIPTION_CHARACTER_NUMBER)
99     } else {
100         descriptionView.visibility = View.GONE
101     }
102     return contentView
103 }
104 
PromptContentViewWithMoreOptionsButtonnull105 private fun PromptContentViewWithMoreOptionsButton.initLayout(
106     context: Context,
107     legacyCallback: Spaghetti.Callback
108 ): View {
109     val inflater = LayoutInflater.from(context)
110     val contentView =
111         inflater.inflateContentView(
112             R.layout.biometric_prompt_content_with_button_layout,
113             description
114         )
115     val buttonView = contentView.requireViewById<Button>(R.id.customized_view_more_options_button)
116     buttonView.setOnClickListener { legacyCallback.onContentViewMoreOptionsButtonPressed() }
117     return contentView
118 }
119 
PromptVerticalListContentViewnull120 private fun PromptVerticalListContentView.initLayout(
121     context: Context,
122     containerViewWidth: Int
123 ): View {
124     val inflater = LayoutInflater.from(context)
125     context.resources
126     val contentView =
127         inflater.inflateContentView(
128             R.layout.biometric_prompt_vertical_list_content_layout,
129             description
130         )
131     val listItemsToShow = ArrayList<PromptContentItem>(listItems)
132     // Show two column by default, once there is an item exceeding max lines, show single
133     // item instead.
134     val showTwoColumn =
135         listItemsToShow.all { !it.doesExceedMaxLinesIfTwoColumn(context, containerViewWidth) }
136     // If should show two columns and there are more than one items, make listItems always have odd
137     // number items.
138     if (showTwoColumn && listItemsToShow.size > 1 && listItemsToShow.size % 2 == 1) {
139         listItemsToShow.add(dummyItem())
140     }
141     var currRow = createNewRowLayout(inflater)
142     for (i in 0 until listItemsToShow.size) {
143         val item = listItemsToShow[i]
144         val itemView = item.toView(context, inflater)
145         contentView.removeTopPaddingForFirstRow(description, itemView)
146 
147         // If there should be two column, and there is already one item in the current row, add
148         // space between two items.
149         if (showTwoColumn && currRow.childCount == 1) {
150             currRow.addSpaceViewBetweenListItem()
151         }
152         currRow.addView(itemView)
153 
154         // If there should be one column, or there are already two items (plus the space view) in
155         // the current row, or it's already the last item, start a new row
156         if (!showTwoColumn || currRow.childCount == 3 || i == listItemsToShow.size - 1) {
157             contentView.addView(currRow)
158             currRow = createNewRowLayout(inflater)
159         }
160     }
161     return contentView
162 }
163 
createNewRowLayoutnull164 private fun createNewRowLayout(inflater: LayoutInflater): LinearLayout {
165     return inflater.inflate(R.layout.biometric_prompt_content_row_layout, null) as LinearLayout
166 }
167 
PromptContentItemnull168 private fun PromptContentItem.doesExceedMaxLinesIfTwoColumn(
169     context: Context,
170     containerViewWidth: Int,
171 ): Boolean {
172     val resources = context.resources
173     val passedInText: String =
174         when (this) {
175             is PromptContentItemPlainText -> text
176             is PromptContentItemBulletedText -> text
177             else -> {
178                 throw IllegalStateException("No such PromptContentItem: $this")
179             }
180         }
181 
182     when (this) {
183         is PromptContentItemPlainText,
184         is PromptContentItemBulletedText -> {
185             val contentViewPadding =
186                 resources.getDimensionPixelSize(R.dimen.biometric_prompt_content_padding_horizontal)
187             val listItemPadding = getListItemPadding(resources)
188             var maxWidth = containerViewWidth / 2 - contentViewPadding - listItemPadding
189             // Reduce maxWidth a bit since paint#measureText is not accurate. See b/330909104 for
190             // more context.
191             maxWidth -= contentViewPadding / 2
192 
193             val paint = TextPaint()
194             val attributes =
195                 context.obtainStyledAttributes(
196                     R.style.TextAppearance_AuthCredential_ContentViewListItem,
197                     intArrayOf(android.R.attr.textSize)
198                 )
199             paint.textSize = attributes.getDimensionPixelSize(0, 0).toFloat()
200             val textWidth = paint.measureText(passedInText)
201             attributes.recycle()
202 
203             val maxLines =
204                 resources.getInteger(
205                     R.integer.biometric_prompt_content_list_item_max_lines_if_two_column
206                 )
207             val numLines = ceil(textWidth / maxWidth).toInt()
208             return numLines > maxLines
209         }
210         else -> {
211             throw IllegalStateException("No such PromptContentItem: $this")
212         }
213     }
214 }
215 
toViewnull216 private fun PromptContentItem.toView(
217     context: Context,
218     inflater: LayoutInflater,
219 ): TextView {
220     val resources = context.resources
221     // Somehow xml layout params settings doesn't work, set it again here.
222     val textView =
223         inflater.inflate(R.layout.biometric_prompt_content_row_item_text_view, null) as TextView
224     val lp = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1f)
225     textView.layoutParams = lp
226     val maxCharNumber = PromptVerticalListContentView.getMaxEachItemCharacterNumber()
227 
228     when (this) {
229         is PromptContentItemPlainText -> {
230             textView.text = text.ellipsize(maxCharNumber)
231         }
232         is PromptContentItemBulletedText -> {
233             val bulletedText = SpannableString(text.ellipsize(maxCharNumber))
234             val span =
235                 BulletSpan(
236                     getListItemBulletGapWidth(resources),
237                     getListItemBulletColor(context),
238                     getListItemBulletRadius(resources)
239                 )
240             bulletedText.setSpan(span, 0 /* start */, text.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
241             textView.text = bulletedText
242         }
243         else -> {
244             throw IllegalStateException("No such PromptContentItem: $this")
245         }
246     }
247     return textView
248 }
249 
250 /* [contentView] function */
addSpaceViewBetweenListItemnull251 private fun LinearLayout.addSpaceViewBetweenListItem() =
252     addView(
253         Space(context),
254         LinearLayout.LayoutParams(
255             resources.getDimensionPixelSize(
256                 R.dimen.biometric_prompt_content_space_width_between_items
257             ),
258             MATCH_PARENT
259         )
260     )
261 
262 /* [contentView] function*/
263 private fun LinearLayout.removeTopPaddingForFirstRow(description: String?, itemView: TextView) {
264     // If this item will be in the first row (contentView only has description view and
265     // description is empty), remove top padding of this item.
266     if (description.isNullOrEmpty() && childCount == 1) {
267         itemView.setPadding(itemView.paddingLeft, 0, itemView.paddingRight, itemView.paddingBottom)
268     }
269 }
270 
dummyItemnull271 private fun dummyItem(): PromptContentItem = PromptContentItemPlainText("")
272 
273 private fun PromptContentItem.getListItemPadding(resources: Resources): Int {
274     var listItemPadding =
275         resources.getDimensionPixelSize(
276             R.dimen.biometric_prompt_content_space_width_between_items
277         ) / 2
278     when (this) {
279         is PromptContentItemPlainText -> {}
280         is PromptContentItemBulletedText -> {
281             listItemPadding +=
282                 getListItemBulletRadius(resources) * 2 + getListItemBulletGapWidth(resources)
283         }
284         else -> {
285             throw IllegalStateException("No such PromptContentItem: $this")
286         }
287     }
288     return listItemPadding
289 }
290 
getListItemBulletRadiusnull291 private fun getListItemBulletRadius(resources: Resources): Int =
292     resources.getDimensionPixelSize(R.dimen.biometric_prompt_content_list_item_bullet_radius)
293 
294 private fun getListItemBulletGapWidth(resources: Resources): Int =
295     resources.getDimensionPixelSize(R.dimen.biometric_prompt_content_list_item_bullet_gap_width)
296 
297 private fun getListItemBulletColor(context: Context): Int =
298     Utils.getColorAttrDefaultColor(context, com.android.internal.R.attr.materialColorOnSurface)
299 
300 private fun <T : View> T.width(function: (Int) -> Unit) {
301     if (width == 0)
302         viewTreeObserver.addOnGlobalLayoutListener(
303             object : ViewTreeObserver.OnGlobalLayoutListener {
304                 override fun onGlobalLayout() {
305                     if (measuredWidth > 0) {
306                         viewTreeObserver.removeOnGlobalLayoutListener(this)
307                     }
308                     function(measuredWidth)
309                 }
310             }
311         )
312     else function(measuredWidth)
313 }
314