diff --git a/aconfig/settings_flag_declarations.aconfig b/aconfig/settings_flag_declarations.aconfig index 0007bbffb75..983d4b3e6d9 100644 --- a/aconfig/settings_flag_declarations.aconfig +++ b/aconfig/settings_flag_declarations.aconfig @@ -56,3 +56,10 @@ flag { description: "This flag controls the About phone > Legal information page migration" bug: "323791114" } + +flag { + name: "updated_suggestion_card_aosp" + namespace: "android_settings" + description: "Use updated suggestion card(s) in AOSP Settings" + bug: "323258154" +} diff --git a/res/drawable/suggestion_icon_background.xml b/res/drawable/suggestion_icon_background.xml new file mode 100644 index 00000000000..a6dee4836cf --- /dev/null +++ b/res/drawable/suggestion_icon_background.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/suggestion_tile.xml b/res/layout/suggestion_tile.xml new file mode 100644 index 00000000000..fcb0d4f78a1 --- /dev/null +++ b/res/layout/suggestion_tile.xml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/values/dimens.xml b/res/values/dimens.xml index 689262fa3e8..2a9b4334e29 100755 --- a/res/values/dimens.xml +++ b/res/values/dimens.xml @@ -297,6 +297,7 @@ 4dp 14dp 16dp + 18dp 16dp @*android:dimen/config_dialogCornerRadius 12dp diff --git a/res/values/styles.xml b/res/values/styles.xml index c02378e3f7c..70d3670cebb 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -1017,4 +1017,22 @@ @null + + + + + + diff --git a/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProvider.java b/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProvider.java index fe97759e225..6c4d1ad0255 100644 --- a/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProvider.java +++ b/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProvider.java @@ -21,6 +21,7 @@ import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; /** Interface should be implemented if you have added new suggestions */ @@ -46,6 +47,20 @@ public interface SuggestionFeatureProvider { /** * Returns the class of {@link Fragment} that supports contextual suggestion. + * + * @deprecated - use {@link SuggestionFeatureProvider#getSuggestionFragment()} instead. */ - Class getContextualSuggestionFragment(); + @Deprecated + @Nullable + default Class getContextualSuggestionFragment() { + return null; + } + + /** + * Returns the class of {@link Fragment} that provides the UI for Suggestions. + */ + @Nullable + default Class getSuggestionFragment() { + return getContextualSuggestionFragment(); + } } diff --git a/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImpl.java b/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImpl.java index 066901015d4..5e8e3740904 100644 --- a/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImpl.java +++ b/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImpl.java @@ -22,12 +22,14 @@ import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.android.settings.Settings.NightDisplaySuggestionActivity; import com.android.settings.biometrics.fingerprint.FingerprintEnrollSuggestionActivity; import com.android.settings.biometrics.fingerprint.FingerprintSuggestionActivity; import com.android.settings.display.NightDisplayPreferenceController; +import com.android.settings.flags.Flags; import com.android.settings.notification.zen.ZenOnboardingActivity; import com.android.settings.notification.zen.ZenSuggestionActivity; import com.android.settings.password.ScreenLockSuggestionActivity; @@ -81,8 +83,13 @@ public class SuggestionFeatureProviderImpl implements SuggestionFeatureProvider return context.getSharedPreferences(SHARED_PREF_FILENAME, Context.MODE_PRIVATE); } + @Nullable @Override - public Class getContextualSuggestionFragment() { - return null; + public Class getSuggestionFragment() { + if (Flags.updatedSuggestionCardAosp()) { + return SuggestionFragment.class; + } else { + return null; + } } } diff --git a/src/com/android/settings/dashboard/suggestions/SuggestionFragment.kt b/src/com/android/settings/dashboard/suggestions/SuggestionFragment.kt new file mode 100644 index 00000000000..760ddc9e505 --- /dev/null +++ b/src/com/android/settings/dashboard/suggestions/SuggestionFragment.kt @@ -0,0 +1,238 @@ +/* + * Copyright (C) 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.dashboard.suggestions + +import android.app.ActivityOptions +import android.app.PendingIntent +import android.app.settings.SettingsEnums +import android.content.Context +import android.os.Bundle +import android.os.SystemClock +import android.service.settings.suggestions.Suggestion +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import com.android.settings.core.InstrumentedFragment +import com.android.settings.homepage.SettingsHomepageActivity +import com.android.settings.homepage.SplitLayoutListener +import com.android.settings.overlay.FeatureFactory +import com.android.settings.R +import com.android.settingslib.suggestions.SuggestionController +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +private const val SUGGESTIONS = "suggestions" +private const val TAG = "ContextualSuggestFrag" +private const val FLAG_IS_DISMISSIBLE = 1 shl 2 + +/** + * Fragment to control display and interaction logic for [Suggestion]s + */ +class SuggestionFragment : InstrumentedFragment(), + SplitLayoutListener, SuggestionController.ServiceConnectionListener { + + private val scope = CoroutineScope(Job() + Dispatchers.Main) + private lateinit var suggestionController: SuggestionController + private lateinit var suggestionTile: View + private var icon: ImageView? = null + private var iconFrame: View? = null + private var title: TextView? = null + private var summary: TextView? = null + private var dismiss: ImageView? = null + private var iconVisible = true + private var startTime: Long = 0 + private var suggestionsRestored = false + private var splitLayoutSupported = false + + override fun onAttach(context: Context) { + super.onAttach(context) + val component = FeatureFactory.featureFactory + .suggestionFeatureProvider + .suggestionServiceComponent + suggestionController = SuggestionController(context, component, this) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + suggestionTile = inflater.inflate(R.layout.suggestion_tile, container, true) + icon = suggestionTile.findViewById(android.R.id.icon) + iconFrame = suggestionTile.findViewById(android.R.id.icon_frame) + title = suggestionTile.findViewById(android.R.id.title) + summary = suggestionTile.findViewById(android.R.id.summary) + dismiss = suggestionTile.findViewById(android.R.id.closeButton) + if (!iconVisible) { + onSplitLayoutChanged(false) + } + // Restore the suggestion and skip reloading + if (savedInstanceState != null) { + Log.d(TAG, "Restoring suggestions") + savedInstanceState.getParcelableArrayList( + SUGGESTIONS, + Suggestion::class.java + )?.let { suggestions -> + suggestionsRestored = true + startTime = SystemClock.uptimeMillis() + updateState(suggestions) + } + } + + return super.onCreateView(inflater, container, savedInstanceState) + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putParcelableArrayList(SUGGESTIONS, currentSuggestions) + super.onSaveInstanceState(outState) + } + + override fun onStart() { + super.onStart() + suggestionController.start() + } + + override fun onStop() { + suggestionController.stop() + super.onStop() + } + + override fun getMetricsCategory(): Int { + return SettingsEnums.SETTINGS_HOMEPAGE + } + + override fun setSplitLayoutSupported(supported: Boolean) { + splitLayoutSupported = supported + } + + override fun onSplitLayoutChanged(isRegularLayout: Boolean) { + iconVisible = isRegularLayout + if (splitLayoutSupported) { + iconFrame?.visibility = if (iconVisible) View.VISIBLE else View.GONE + } + } + + override fun onServiceConnected() { + loadSuggestions() + } + + override fun onServiceDisconnected() { + // no-op + } + + private fun loadSuggestions() { + if (suggestionsRestored) { + // Skip first suggestion loading when restored + suggestionsRestored = false + return + } + + startTime = SystemClock.uptimeMillis() + scope.launch(Dispatchers.IO) { + Log.d(TAG, "Start loading suggestions") + val suggestions = suggestionController.suggestions + Log.d(TAG, "Loaded suggestions: ${suggestions?.size}") + withContext(Dispatchers.Main) { + updateState(suggestions) + } + } + } + + private fun updateState(suggestions: List?) { + currentSuggestions.clear() + if (suggestions.isNullOrEmpty()) { + Log.d(TAG, "Remove suggestions") + showSuggestionTile(false) + return + } + currentSuggestions.addAll(suggestions) + + // Only take top suggestion; we assume this is the highest rank. + val suggestion = suggestions.first() + icon?.setImageIcon(suggestion.icon) + suggestion.title?.let { + title?.text = it + } ?: run { + Log.d(TAG, "No suggestion title, removing") + showSuggestionTile(false) + return + } + val suggestionSummary = suggestion.summary + if (suggestionSummary.isNullOrEmpty()) { + summary?.visibility = View.GONE + } else { + summary?.visibility = View.VISIBLE + summary?.text = suggestionSummary + } + if (suggestion.flags and FLAG_IS_DISMISSIBLE != 0) { + dismiss?.let { dismissView -> + dismissView.visibility = View.VISIBLE + dismissView.setOnClickListener { + scope.launch(Dispatchers.IO) { + suggestionController.dismissSuggestions(suggestion) + } + if (suggestions.size > 1) { + dismissView.visibility = View.GONE + updateState(suggestions.subList(1, suggestions.size)) + } else { + currentSuggestions.clear() + suggestionTile.visibility = View.GONE + } + } + } + } + suggestionTile.setOnClickListener { + // Notify service that suggestion is being launched. Note that the service does not + // actually start the suggestion on our behalf, instead simply logging metrics. + scope.launch(Dispatchers.IO) { + suggestionController.launchSuggestion(suggestion) + } + currentSuggestions.clear() + try { + val options = ActivityOptions.makeBasic() + .setPendingIntentBackgroundActivityStartMode( + ActivityOptions.MODE_BACKGROUND_ACTIVITY_START_ALLOWED + ) + suggestion.pendingIntent.send(options.toBundle()) + } catch (e: PendingIntent.CanceledException) { + Log.e(TAG, "Failed to start suggestion ${suggestion.title}", e) + } + } + showSuggestionTile(true) + } + + private fun showSuggestionTile(show: Boolean) { + val totalTime = SystemClock.uptimeMillis() - startTime + Log.d(TAG, "Total loading time: $totalTime ms") + mMetricsFeatureProvider.action( + context, + SettingsEnums.ACTION_CONTEXTUAL_HOME_SHOW, + totalTime.toInt() + ) + (activity as? SettingsHomepageActivity)?.showHomepageWithSuggestion(show) + } + + private companion object { + val currentSuggestions = arrayListOf() + } +} \ No newline at end of file diff --git a/src/com/android/settings/homepage/SettingsHomepageActivity.java b/src/com/android/settings/homepage/SettingsHomepageActivity.java index 7a2f52a8674..4b9f6a8da9d 100644 --- a/src/com/android/settings/homepage/SettingsHomepageActivity.java +++ b/src/com/android/settings/homepage/SettingsHomepageActivity.java @@ -276,7 +276,8 @@ public class SettingsHomepageActivity extends FragmentActivity implements final boolean scrollNeeded = mIsEmbeddingActivityEnabled && !TextUtils.equals(getString(DEFAULT_HIGHLIGHT_MENU_KEY), highlightMenuKey); showSuggestionFragment(scrollNeeded); - if (FeatureFlagUtils.isEnabled(this, FeatureFlags.CONTEXTUAL_HOME)) { + if (!Flags.updatedSuggestionCardAosp() + && FeatureFlagUtils.isEnabled(this, FeatureFlags.CONTEXTUAL_HOME)) { showFragment(() -> new ContextualCardsFragment(), R.id.contextual_cards_content); ((FrameLayout) findViewById(R.id.main_content)) .getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); @@ -477,7 +478,7 @@ public class SettingsHomepageActivity extends FragmentActivity implements private void showSuggestionFragment(boolean scrollNeeded) { final Class fragmentClass = FeatureFactory.getFeatureFactory() - .getSuggestionFeatureProvider().getContextualSuggestionFragment(); + .getSuggestionFeatureProvider().getSuggestionFragment(); if (fragmentClass == null) { return; } diff --git a/tests/robotests/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImplTest.java b/tests/robotests/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImplTest.java index 0c8af12db29..6d2193cb760 100644 --- a/tests/robotests/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImplTest.java +++ b/tests/robotests/src/com/android/settings/dashboard/suggestions/SuggestionFeatureProviderImplTest.java @@ -25,12 +25,19 @@ import android.content.Context; import android.content.pm.ActivityInfo; import android.content.pm.PackageManager; import android.hardware.fingerprint.FingerprintManager; +import android.platform.test.annotations.DisableFlags; +import android.platform.test.annotations.EnableFlags; +import android.platform.test.flag.junit.SetFlagsRule; import android.service.settings.suggestions.Suggestion; +import androidx.fragment.app.Fragment; + +import com.android.settings.flags.Flags; import com.android.settings.testutils.FakeFeatureFactory; import com.android.settings.testutils.shadow.ShadowSecureSettings; import org.junit.Before; +import org.junit.Rule; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mock; @@ -43,6 +50,9 @@ import org.robolectric.annotation.Config; @Config(shadows = ShadowSecureSettings.class) public class SuggestionFeatureProviderImplTest { + @Rule + public final SetFlagsRule mSetFlagsRule = new SetFlagsRule(); + @Mock private Context mContext; @Mock @@ -94,4 +104,20 @@ public class SuggestionFeatureProviderImplTest { when(mActivityManager.isLowRamDevice()).thenReturn(false); assertThat(mProvider.isSuggestionEnabled(mContext)).isTrue(); } + + @DisableFlags(Flags.FLAG_UPDATED_SUGGESTION_CARD_AOSP) + @Test + public void getSuggestionFragment_withFlagDisabled_shouldReturnNull() { + Class fragment = mProvider.getSuggestionFragment(); + + assertThat(fragment).isNull(); + } + + @EnableFlags(Flags.FLAG_UPDATED_SUGGESTION_CARD_AOSP) + @Test + public void getSuggestionFragment_withFlagEnabled_shouldReturnFragment() { + Class fragment = mProvider.getSuggestionFragment(); + + assertThat(fragment).isEqualTo(SuggestionFragment.class); + } } diff --git a/tests/robotests/src/com/android/settings/homepage/SettingsHomepageActivityTest.java b/tests/robotests/src/com/android/settings/homepage/SettingsHomepageActivityTest.java index 31ac9446612..2cf93e4fb3b 100644 --- a/tests/robotests/src/com/android/settings/homepage/SettingsHomepageActivityTest.java +++ b/tests/robotests/src/com/android/settings/homepage/SettingsHomepageActivityTest.java @@ -444,7 +444,7 @@ public class SettingsHomepageActivityTest { public static class ShadowSuggestionFeatureProviderImpl { @Implementation - public Class getContextualSuggestionFragment() { + public Class getSuggestionFragment() { return Fragment.class; } }