1 /* <lambda>null2 * Copyright (C) 2024 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.settings.dashboard.suggestions 18 19 import android.app.ActivityOptions 20 import android.app.PendingIntent 21 import android.app.settings.SettingsEnums 22 import android.content.Context 23 import android.os.Bundle 24 import android.os.SystemClock 25 import android.service.settings.suggestions.Suggestion 26 import android.util.Log 27 import android.view.LayoutInflater 28 import android.view.View 29 import android.view.ViewGroup 30 import android.widget.ImageView 31 import android.widget.TextView 32 import com.android.settings.core.InstrumentedFragment 33 import com.android.settings.homepage.SettingsHomepageActivity 34 import com.android.settings.homepage.SplitLayoutListener 35 import com.android.settings.overlay.FeatureFactory 36 import com.android.settings.R 37 import com.android.settingslib.suggestions.SuggestionController 38 import kotlinx.coroutines.CoroutineScope 39 import kotlinx.coroutines.Dispatchers 40 import kotlinx.coroutines.Job 41 import kotlinx.coroutines.launch 42 import kotlinx.coroutines.withContext 43 44 private const val SUGGESTIONS = "suggestions" 45 private const val TAG = "ContextualSuggestFrag" 46 private const val FLAG_IS_DISMISSIBLE = 1 shl 2 47 48 /** 49 * Fragment to control display and interaction logic for [Suggestion]s 50 */ 51 class SuggestionFragment : InstrumentedFragment(), 52 SplitLayoutListener, SuggestionController.ServiceConnectionListener { 53 54 private val scope = CoroutineScope(Job() + Dispatchers.Main) 55 private lateinit var suggestionController: SuggestionController 56 private lateinit var suggestionTile: View 57 private var icon: ImageView? = null 58 private var iconFrame: View? = null 59 private var title: TextView? = null 60 private var summary: TextView? = null 61 private var dismiss: ImageView? = null 62 private var iconVisible = true 63 private var startTime: Long = 0 64 private var suggestionsRestored = false 65 private var splitLayoutSupported = false 66 67 override fun onAttach(context: Context) { 68 super.onAttach(context) 69 val component = FeatureFactory.featureFactory 70 .suggestionFeatureProvider 71 .suggestionServiceComponent 72 suggestionController = SuggestionController(context, component, this) 73 } 74 75 override fun onCreateView( 76 inflater: LayoutInflater, 77 container: ViewGroup?, 78 savedInstanceState: Bundle? 79 ): View? { 80 suggestionTile = inflater.inflate(R.layout.suggestion_tile, container, true) 81 icon = suggestionTile.findViewById(android.R.id.icon) 82 iconFrame = suggestionTile.findViewById(android.R.id.icon_frame) 83 title = suggestionTile.findViewById(android.R.id.title) 84 summary = suggestionTile.findViewById(android.R.id.summary) 85 dismiss = suggestionTile.findViewById(android.R.id.closeButton) 86 if (!iconVisible) { 87 onSplitLayoutChanged(false) 88 } 89 // Restore the suggestion and skip reloading 90 if (savedInstanceState != null) { 91 Log.d(TAG, "Restoring suggestions") 92 savedInstanceState.getParcelableArrayList( 93 SUGGESTIONS, 94 Suggestion::class.java 95 )?.let { suggestions -> 96 suggestionsRestored = true 97 startTime = SystemClock.uptimeMillis() 98 updateState(suggestions) 99 } 100 } 101 102 return super.onCreateView(inflater, container, savedInstanceState) 103 } 104 105 override fun onSaveInstanceState(outState: Bundle) { 106 outState.putParcelableArrayList(SUGGESTIONS, currentSuggestions) 107 super.onSaveInstanceState(outState) 108 } 109 110 override fun onStart() { 111 super.onStart() 112 suggestionController.start() 113 } 114 115 override fun onStop() { 116 suggestionController.stop() 117 super.onStop() 118 } 119 120 override fun getMetricsCategory(): Int { 121 return SettingsEnums.SETTINGS_HOMEPAGE 122 } 123 124 override fun setSplitLayoutSupported(supported: Boolean) { 125 splitLayoutSupported = supported 126 } 127 128 override fun onSplitLayoutChanged(isRegularLayout: Boolean) { 129 iconVisible = isRegularLayout 130 if (splitLayoutSupported) { 131 iconFrame?.visibility = if (iconVisible) View.VISIBLE else View.GONE 132 } 133 } 134 135 override fun onServiceConnected() { 136 loadSuggestions() 137 } 138 139 override fun onServiceDisconnected() { 140 // no-op 141 } 142 143 private fun loadSuggestions() { 144 if (suggestionsRestored) { 145 // Skip first suggestion loading when restored 146 suggestionsRestored = false 147 return 148 } 149 150 startTime = SystemClock.uptimeMillis() 151 scope.launch(Dispatchers.IO) { 152 Log.d(TAG, "Start loading suggestions") 153 val suggestions = suggestionController.suggestions 154 Log.d(TAG, "Loaded suggestions: ${suggestions?.size}") 155 withContext(Dispatchers.Main) { 156 updateState(suggestions) 157 } 158 } 159 } 160 161 private fun updateState(suggestions: List<Suggestion>?) { 162 currentSuggestions.clear() 163 if (suggestions.isNullOrEmpty()) { 164 Log.d(TAG, "Remove suggestions") 165 showSuggestionTile(false) 166 return 167 } 168 currentSuggestions.addAll(suggestions) 169 170 // Only take top suggestion; we assume this is the highest rank. 171 val suggestion = suggestions.first() 172 icon?.setImageIcon(suggestion.icon) 173 suggestion.title?.let { 174 title?.text = it 175 } ?: run { 176 Log.d(TAG, "No suggestion title, removing") 177 showSuggestionTile(false) 178 return 179 } 180 val suggestionSummary = suggestion.summary 181 if (suggestionSummary.isNullOrEmpty()) { 182 summary?.visibility = View.GONE 183 } else { 184 summary?.visibility = View.VISIBLE 185 summary?.text = suggestionSummary 186 } 187 if (suggestion.flags and FLAG_IS_DISMISSIBLE != 0) { 188 dismiss?.let { dismissView -> 189 dismissView.visibility = View.VISIBLE 190 dismissView.setOnClickListener { 191 scope.launch(Dispatchers.IO) { 192 suggestionController.dismissSuggestions(suggestion) 193 } 194 if (suggestions.size > 1) { 195 dismissView.visibility = View.GONE 196 updateState(suggestions.subList(1, suggestions.size)) 197 } else { 198 currentSuggestions.clear() 199 suggestionTile.visibility = View.GONE 200 } 201 } 202 } 203 } 204 suggestionTile.setOnClickListener { 205 // Notify service that suggestion is being launched. Note that the service does not 206 // actually start the suggestion on our behalf, instead simply logging metrics. 207 scope.launch(Dispatchers.IO) { 208 suggestionController.launchSuggestion(suggestion) 209 } 210 currentSuggestions.clear() 211 try { 212 val options = ActivityOptions.makeBasic() 213 .setPendingIntentBackgroundActivityStartMode( 214 ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED 215 ) 216 suggestion.pendingIntent.send(options.toBundle()) 217 } catch (e: PendingIntent.CanceledException) { 218 Log.e(TAG, "Failed to start suggestion ${suggestion.title}", e) 219 } 220 } 221 showSuggestionTile(true) 222 } 223 224 private fun showSuggestionTile(show: Boolean) { 225 val totalTime = SystemClock.uptimeMillis() - startTime 226 Log.d(TAG, "Total loading time: $totalTime ms") 227 mMetricsFeatureProvider.action( 228 context, 229 SettingsEnums.ACTION_CONTEXTUAL_HOME_SHOW, 230 totalTime.toInt() 231 ) 232 (activity as? SettingsHomepageActivity)?.showHomepageWithSuggestion(show) 233 } 234 235 private companion object { 236 val currentSuggestions = arrayListOf<Suggestion>() 237 } 238 }