1 /*
2  * Copyright (C) 2022 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.quicksearchbox.ui
18 
19 import android.content.Context
20 import android.content.res.ColorStateList
21 import android.graphics.drawable.Drawable
22 import android.net.Uri
23 import android.text.Html
24 import android.text.Spannable
25 import android.text.SpannableString
26 import android.text.TextUtils
27 import android.text.style.TextAppearanceSpan
28 import android.util.AttributeSet
29 import android.util.Log
30 import android.view.View
31 import android.widget.ImageView
32 import android.widget.TextView
33 import com.android.quicksearchbox.R
34 import com.android.quicksearchbox.Source
35 import com.android.quicksearchbox.Suggestion
36 import com.android.quicksearchbox.util.Consumer
37 import com.android.quicksearchbox.util.NowOrLater
38 
39 /**
40  * View for the items in the suggestions list. This includes promoted suggestions, sources, and
41  * suggestions under each source.
42  */
43 class DefaultSuggestionView : BaseSuggestionView {
44   private val TAG = "QSB.DefaultSuggestionView"
45   private var mAsyncIcon1: DefaultSuggestionView.AsyncIcon? = null
46   private var mAsyncIcon2: DefaultSuggestionView.AsyncIcon? = null
47 
48   constructor(
49     context: Context?,
50     attrs: AttributeSet?,
51     defStyle: Int
52   ) : super(context, attrs, defStyle)
53 
54   constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
55   constructor(context: Context?) : super(context)
56 
57   @Override
onFinishInflatenull58   override fun onFinishInflate() {
59     super.onFinishInflate()
60     mText1 = findViewById(R.id.text1) as TextView
61     mText2 = findViewById(R.id.text2) as TextView
62     mAsyncIcon1 =
63       object : AsyncIcon(mIcon1) {
64         // override default icon (when no other available) with default source icon
65         @Override
66         override fun getFallbackIconId(source: Source?): String {
67           return source?.sourceIconUri.toString()
68         }
69 
70         @Override
71         override fun getFallbackIcon(source: Source?): Drawable? {
72           return source?.sourceIcon
73         }
74       }
75     mAsyncIcon2 = AsyncIcon(mIcon2)
76   }
77 
78   @Override
bindAsSuggestionnull79   override fun bindAsSuggestion(suggestion: Suggestion?, userQuery: String?) {
80     super.bindAsSuggestion(suggestion, userQuery)
81     val text1 = formatText(suggestion?.suggestionText1, suggestion)
82     var text2: CharSequence = suggestion?.suggestionText2Url as CharSequence
83     text2 = formatUrl(text2)
84     // If there is no text for the second line, allow the first line to be up to two lines
85     if (TextUtils.isEmpty(text2)) {
86       mText1?.setSingleLine(false)
87       mText1?.setMaxLines(2)
88       mText1?.setEllipsize(TextUtils.TruncateAt.START)
89     } else {
90       mText1?.setSingleLine(true)
91       mText1?.setMaxLines(1)
92       mText1?.setEllipsize(TextUtils.TruncateAt.MIDDLE)
93     }
94     setText1(text1)
95     setText2(text2)
96     mAsyncIcon1?.set(suggestion.suggestionSource, suggestion.suggestionIcon1)
97     mAsyncIcon2?.set(suggestion.suggestionSource, suggestion.suggestionIcon2)
98     if (DBG) {
99       Log.d(
100         TAG,
101         "bindAsSuggestion(), text1=" +
102           text1 +
103           ",text2=" +
104           text2 +
105           ",q='" +
106           userQuery +
107           ",fromHistory=" +
108           isFromHistory(suggestion)
109       )
110     }
111   }
112 
formatUrlnull113   private fun formatUrl(url: CharSequence): CharSequence {
114     val text = SpannableString(url)
115     val colors: ColorStateList = getResources().getColorStateList(R.color.url_text, null)
116     text.setSpan(
117       TextAppearanceSpan(null, 0, 0, colors, null),
118       0,
119       url.length,
120       Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
121     )
122     return text
123   }
124 
formatTextnull125   private fun formatText(str: String?, suggestion: Suggestion?): CharSequence {
126     val isHtml = "html" == suggestion?.suggestionFormat
127     return if (isHtml && looksLikeHtml(str)) {
128       Html.fromHtml(str, Html.FROM_HTML_MODE_LEGACY)
129     } else {
130       str as CharSequence
131     }
132   }
133 
looksLikeHtmlnull134   private fun looksLikeHtml(str: String?): Boolean {
135     if (TextUtils.isEmpty(str)) return false
136     for (i in str!!.length - 1 downTo 0) {
137       val c: Char = str[i]
138       if (c == '>' || c == '&') return true
139     }
140     return false
141   }
142 
143   private open inner class AsyncIcon(view: ImageView?) {
144     private val mView: ImageView?
145     private var mCurrentId: String? = null
146     private var mWantedId: String? = null
147 
setnull148     operator fun set(source: Source?, sourceIconId: String?) {
149       if (sourceIconId != null) {
150         // The iconId can just be a package-relative resource ID, which may overlap with
151         // other packages. Make sure it's globally unique.
152         val iconUri: Uri? = source?.getIconUri(sourceIconId)
153         val uniqueIconId: String? = if (iconUri == null) null else iconUri.toString()
154         mWantedId = uniqueIconId
155         if (!TextUtils.equals(mWantedId, mCurrentId)) {
156           if (DBG) Log.d(TAG, "getting icon Id=$uniqueIconId")
157           val icon: NowOrLater<Drawable?>? = source?.getIcon(sourceIconId)
158           if (icon!!.haveNow()) {
159             if (DBG) Log.d(TAG, "getIcon ready now")
160             handleNewDrawable(icon.now, uniqueIconId, source)
161           } else {
162             // make sure old icon is not visible while new one is loaded
163             if (DBG) Log.d(TAG, "getIcon getting later")
164             clearDrawable()
165             icon.getLater(
166               object : Consumer<Drawable?> {
167                 @Override
168                 override fun consume(value: Drawable?): Boolean {
169                   if (DBG) {
170                     Log.d(TAG, "IconConsumer.consume got id $uniqueIconId want id $mWantedId")
171                   }
172                   // ensure we have not been re-bound since the request was made.
173                   if (TextUtils.equals(uniqueIconId, mWantedId)) {
174                     handleNewDrawable(value, uniqueIconId, source)
175                     return true
176                   }
177                   return false
178                 }
179               }
180             )
181           }
182         }
183       } else {
184         mWantedId = null
185         handleNewDrawable(null, null, source)
186       }
187     }
188 
handleNewDrawablenull189     private fun handleNewDrawable(icon: Drawable?, id: String?, source: Source?) {
190       var mIcon: Drawable? = icon
191       if (mIcon == null) {
192         mWantedId = getFallbackIconId(source)
193         if (TextUtils.equals(mWantedId, mCurrentId)) {
194           return
195         }
196         mIcon = getFallbackIcon(source)
197       }
198       setDrawable(mIcon, id)
199     }
200 
setDrawablenull201     private fun setDrawable(icon: Drawable?, id: String?) {
202       mCurrentId = id
203       setViewDrawable(mView, icon)
204     }
205 
clearDrawablenull206     private fun clearDrawable() {
207       mCurrentId = null
208       mView?.setImageDrawable(null)
209     }
210 
getFallbackIconIdnull211     protected open fun getFallbackIconId(source: Source?): String? {
212       return null
213     }
214 
getFallbackIconnull215     protected open fun getFallbackIcon(source: Source?): Drawable? {
216       return null
217     }
218 
219     init {
220       mView = view
221     }
222   }
223 
224   class Factory(context: Context?) :
225     SuggestionViewInflater(
226       VIEW_ID,
227       DefaultSuggestionView::class.java,
228       R.layout.suggestion,
229       context
230     )
231 
232   companion object {
233     private const val DBG = false
234     private const val VIEW_ID = "default"
235 
236     /**
237      * Sets the drawable in an image view, makes sure the view is only visible if there is a
238      * drawable.
239      */
setViewDrawablenull240     private fun setViewDrawable(v: ImageView?, drawable: Drawable?) {
241       // Set the icon even if the drawable is null, since we need to clear any
242       // previous icon.
243       v?.setImageDrawable(drawable)
244       if (drawable == null) {
245         v?.setVisibility(View.GONE)
246       } else {
247         v?.setVisibility(View.VISIBLE)
248 
249         // This is a hack to get any animated drawables (like a 'working' spinner)
250         // to animate. You have to setVisible true on an AnimationDrawable to get
251         // it to start animating, but it must first have been false or else the
252         // call to setVisible will be ineffective. We need to clear up the story
253         // about animated drawables in the future, see http://b/1878430.
254         drawable.setVisible(false, false)
255         drawable.setVisible(true, false)
256       }
257     }
258   }
259 }
260