Supported updated suggestion cards in AOSP Settings

Flag: com.android.settings.flags.updated_suggestion_card_aosp
Bug: 323258154
Test: Enable flag and trigger suggestion cards to show
Change-Id: Iee47d05d8d75c10ba073ae3541f108bc37b4c09b
This commit is contained in:
Chris Antol
2024-08-01 09:01:49 +00:00
parent 4c90aa7404
commit 468e15f49a
11 changed files with 438 additions and 6 deletions

View File

@@ -56,3 +56,10 @@ flag {
description: "This flag controls the About phone > Legal information page migration" description: "This flag controls the About phone > Legal information page migration"
bug: "323791114" bug: "323791114"
} }
flag {
name: "updated_suggestion_card_aosp"
namespace: "android_settings"
description: "Use updated suggestion card(s) in AOSP Settings"
bug: "323258154"
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<size
android:width="40dp"
android:height="40dp" />
<solid android:color="@color/settingslib_materialColorSecondaryContainer" />
</shape>

View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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.
-->
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
xmlns:apps="http://schemas.android.com/apk/res-auto"
android:id="@+id/suggestion_card"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
style="@style/SuggestionCardStyle">
<LinearLayout
android:id="@+id/card_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
android:minHeight="72dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:orientation="horizontal"
android:gravity="center_vertical"
android:background="?android:attr/selectableItemBackground">
<FrameLayout
android:id="@android:id/icon_frame"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:ignore="ContentDescription">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/suggestion_icon_background"/>
<ImageView
android:id="@android:id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center"/>
</FrameLayout>
<LinearLayout
android:id="@+id/text_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingVertical="@dimen/suggestion_card_text_padding_vertical"
android:orientation="vertical">
<TextView
android:id="@android:id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.SuggestionCardTitle"/>
<TextView
android:id="@android:id/summary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.SuggestionCardSummary"/>
</LinearLayout>
<ImageView
android:id="@android:id/closeButton"
android:layout_width="48dp"
android:layout_height="48dp"
android:padding="8dp"
android:visibility="gone"
android:layout_gravity="center"
android:contentDescription="@string/suggestion_button_close"
android:src="@drawable/ic_suggestion_close_button"
android:tint="@color/settingslib_materialColorPrimaryContainer"
android:background="?android:attr/selectableItemBackground" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@@ -297,6 +297,7 @@
<dimen name="contextual_card_side_margin">4dp</dimen> <dimen name="contextual_card_side_margin">4dp</dimen>
<dimen name="contextual_card_icon_padding_start">14dp</dimen> <dimen name="contextual_card_icon_padding_start">14dp</dimen>
<dimen name="contextual_card_text_padding_start">16dp</dimen> <dimen name="contextual_card_text_padding_start">16dp</dimen>
<dimen name="suggestion_card_text_padding_vertical">18dp</dimen>
<dimen name="contextual_card_padding_end">16dp</dimen> <dimen name="contextual_card_padding_end">16dp</dimen>
<dimen name="contextual_card_corner_radius">@*android:dimen/config_dialogCornerRadius</dimen> <dimen name="contextual_card_corner_radius">@*android:dimen/config_dialogCornerRadius</dimen>
<dimen name="contextual_full_card_padding_end">12dp</dimen> <dimen name="contextual_full_card_padding_end">12dp</dimen>

View File

@@ -1017,4 +1017,22 @@
<item name="android:background">@null</item> <item name="android:background">@null</item>
</style> </style>
<style name="SuggestionCardStyle">
<item name="cardBackgroundColor">@color/settingslib_materialColorPrimary</item>
<item name="cardCornerRadius">40dp</item>
<item name="cardElevation">0dp</item>
</style>
<style name="TextAppearance.SuggestionCardTitle">
<item name="android:textColor">@color/settingslib_materialColorOnPrimary</item>
<item name="android:textSize">20sp</item>
<item name="android:fontFamily">@*android:string/config_headlineFontFamily</item>
</style>
<style name="TextAppearance.SuggestionCardSummary">
<item name="android:textColor">@color/settingslib_materialColorOnSecondary</item>
<item name="android:textSize">14sp</item>
<item name="android:fontFamily">@*android:string/config_bodyFontFamily</item>
</style>
</resources> </resources>

View File

@@ -21,6 +21,7 @@ import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
/** Interface should be implemented if you have added new suggestions */ /** 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. * Returns the class of {@link Fragment} that supports contextual suggestion.
*
* @deprecated - use {@link SuggestionFeatureProvider#getSuggestionFragment()} instead.
*/ */
Class<? extends Fragment> getContextualSuggestionFragment(); @Deprecated
@Nullable
default Class<? extends Fragment> getContextualSuggestionFragment() {
return null;
}
/**
* Returns the class of {@link Fragment} that provides the UI for Suggestions.
*/
@Nullable
default Class<? extends Fragment> getSuggestionFragment() {
return getContextualSuggestionFragment();
}
} }

View File

@@ -22,12 +22,14 @@ import android.content.Context;
import android.content.SharedPreferences; import android.content.SharedPreferences;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import com.android.settings.Settings.NightDisplaySuggestionActivity; import com.android.settings.Settings.NightDisplaySuggestionActivity;
import com.android.settings.biometrics.fingerprint.FingerprintEnrollSuggestionActivity; import com.android.settings.biometrics.fingerprint.FingerprintEnrollSuggestionActivity;
import com.android.settings.biometrics.fingerprint.FingerprintSuggestionActivity; import com.android.settings.biometrics.fingerprint.FingerprintSuggestionActivity;
import com.android.settings.display.NightDisplayPreferenceController; 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.ZenOnboardingActivity;
import com.android.settings.notification.zen.ZenSuggestionActivity; import com.android.settings.notification.zen.ZenSuggestionActivity;
import com.android.settings.password.ScreenLockSuggestionActivity; import com.android.settings.password.ScreenLockSuggestionActivity;
@@ -81,8 +83,13 @@ public class SuggestionFeatureProviderImpl implements SuggestionFeatureProvider
return context.getSharedPreferences(SHARED_PREF_FILENAME, Context.MODE_PRIVATE); return context.getSharedPreferences(SHARED_PREF_FILENAME, Context.MODE_PRIVATE);
} }
@Nullable
@Override @Override
public Class<? extends Fragment> getContextualSuggestionFragment() { public Class<? extends Fragment> getSuggestionFragment() {
if (Flags.updatedSuggestionCardAosp()) {
return SuggestionFragment.class;
} else {
return null; return null;
} }
}
} }

View File

@@ -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<Suggestion>?) {
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<Suggestion>()
}
}

View File

@@ -276,7 +276,8 @@ public class SettingsHomepageActivity extends FragmentActivity implements
final boolean scrollNeeded = mIsEmbeddingActivityEnabled final boolean scrollNeeded = mIsEmbeddingActivityEnabled
&& !TextUtils.equals(getString(DEFAULT_HIGHLIGHT_MENU_KEY), highlightMenuKey); && !TextUtils.equals(getString(DEFAULT_HIGHLIGHT_MENU_KEY), highlightMenuKey);
showSuggestionFragment(scrollNeeded); 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); showFragment(() -> new ContextualCardsFragment(), R.id.contextual_cards_content);
((FrameLayout) findViewById(R.id.main_content)) ((FrameLayout) findViewById(R.id.main_content))
.getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING); .getLayoutTransition().enableTransitionType(LayoutTransition.CHANGING);
@@ -477,7 +478,7 @@ public class SettingsHomepageActivity extends FragmentActivity implements
private void showSuggestionFragment(boolean scrollNeeded) { private void showSuggestionFragment(boolean scrollNeeded) {
final Class<? extends Fragment> fragmentClass = FeatureFactory.getFeatureFactory() final Class<? extends Fragment> fragmentClass = FeatureFactory.getFeatureFactory()
.getSuggestionFeatureProvider().getContextualSuggestionFragment(); .getSuggestionFeatureProvider().getSuggestionFragment();
if (fragmentClass == null) { if (fragmentClass == null) {
return; return;
} }

View File

@@ -25,12 +25,19 @@ import android.content.Context;
import android.content.pm.ActivityInfo; import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.hardware.fingerprint.FingerprintManager; 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 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.FakeFeatureFactory;
import com.android.settings.testutils.shadow.ShadowSecureSettings; import com.android.settings.testutils.shadow.ShadowSecureSettings;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.Mock; import org.mockito.Mock;
@@ -43,6 +50,9 @@ import org.robolectric.annotation.Config;
@Config(shadows = ShadowSecureSettings.class) @Config(shadows = ShadowSecureSettings.class)
public class SuggestionFeatureProviderImplTest { public class SuggestionFeatureProviderImplTest {
@Rule
public final SetFlagsRule mSetFlagsRule = new SetFlagsRule();
@Mock @Mock
private Context mContext; private Context mContext;
@Mock @Mock
@@ -94,4 +104,20 @@ public class SuggestionFeatureProviderImplTest {
when(mActivityManager.isLowRamDevice()).thenReturn(false); when(mActivityManager.isLowRamDevice()).thenReturn(false);
assertThat(mProvider.isSuggestionEnabled(mContext)).isTrue(); assertThat(mProvider.isSuggestionEnabled(mContext)).isTrue();
} }
@DisableFlags(Flags.FLAG_UPDATED_SUGGESTION_CARD_AOSP)
@Test
public void getSuggestionFragment_withFlagDisabled_shouldReturnNull() {
Class<? extends Fragment> fragment = mProvider.getSuggestionFragment();
assertThat(fragment).isNull();
}
@EnableFlags(Flags.FLAG_UPDATED_SUGGESTION_CARD_AOSP)
@Test
public void getSuggestionFragment_withFlagEnabled_shouldReturnFragment() {
Class<? extends Fragment> fragment = mProvider.getSuggestionFragment();
assertThat(fragment).isEqualTo(SuggestionFragment.class);
}
} }

View File

@@ -444,7 +444,7 @@ public class SettingsHomepageActivityTest {
public static class ShadowSuggestionFeatureProviderImpl { public static class ShadowSuggestionFeatureProviderImpl {
@Implementation @Implementation
public Class<? extends Fragment> getContextualSuggestionFragment() { public Class<? extends Fragment> getSuggestionFragment() {
return Fragment.class; return Fragment.class;
} }
} }