From 334e48f4ffe2446378ba5ee440282b52d1cf1f62 Mon Sep 17 00:00:00 2001 From: Derek Jedral Date: Wed, 25 Jan 2023 13:48:18 -0800 Subject: [PATCH] Update tile summary from ContentProvider. A subscription is created and destroyed based on the lifecycle events. Fetching the summary is done asynchronously to prevent blocking the UI thread. Test: make RunSettingsRoboTests Test: manually flip flags, confirm tile has dynamic summary Bug: 264812018 Change-Id: Ib1149ec6907b6a70226c36d37431023aaf6ad557 --- .../ActiveUnlockContentListener.java | 145 ++++++++++++++++++ ...ctiveUnlockStatusPreferenceController.java | 35 ++++- .../ActiveUnlockSummaryListener.java | 43 ++++++ .../ActiveUnlockContentListenerTest.java | 145 ++++++++++++++++++ ...eUnlockStatusPreferenceControllerTest.java | 31 ++++ .../activeunlock/FakeContentProvider.java | 99 ++++++++++++ 6 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListener.java create mode 100644 src/com/android/settings/biometrics/activeunlock/ActiveUnlockSummaryListener.java create mode 100644 tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListenerTest.java create mode 100644 tests/robotests/src/com/android/settings/biometrics/activeunlock/FakeContentProvider.java diff --git a/src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListener.java b/src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListener.java new file mode 100644 index 00000000000..c2a8f39b54b --- /dev/null +++ b/src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListener.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2023 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.biometrics.activeunlock; + +import android.content.ContentProviderClient; +import android.content.ContentResolver; +import android.content.Context; +import android.database.ContentObserver; +import android.net.Uri; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.RemoteException; +import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.android.settingslib.utils.ThreadUtils; + +/** Listens to updates from the content provider and fetches the latest value. */ +public class ActiveUnlockContentListener { + + /** Callback interface for updates to values from the ContentProvider. */ + public interface OnContentChangedListener { + /** + * Called when the content observer has updated. + * + * @param newValue the new value retrieved from the ContentProvider. + **/ + void onContentChanged(@Nullable String newValue); + } + + private static final String CONTENT_PROVIDER_PATH = "getSummary"; + + private final Context mContext; + private final OnContentChangedListener mContentChangedListener; + @Nullable private final Uri mUri; + private final String mLogTag; + private final String mMethodName; + private final String mContentKey; + @Nullable private String mContent; + private boolean mSubscribed = false; + private ContentObserver mContentObserver = + new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(boolean selfChange) { + getContentFromUri(); + } + }; + + ActiveUnlockContentListener( + Context context, + OnContentChangedListener listener, + String logTag, + String methodName, + String contentKey) { + mContext = context; + mContentChangedListener = listener; + mLogTag = logTag; + mMethodName = methodName; + mContentKey = contentKey; + String authority = new ActiveUnlockStatusUtils(mContext).getAuthority(); + if (authority != null) { + mUri = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(authority) + .appendPath(CONTENT_PROVIDER_PATH) + .build(); + } else { + mUri = null; + } + + } + + /** Starts listening for updates from the ContentProvider, and fetches the current value. */ + public synchronized void subscribe() { + if (mSubscribed && mUri != null) { + return; + } + mSubscribed = true; + mContext.getContentResolver().registerContentObserver( + mUri, true /* notifyForDescendants */, mContentObserver); + ThreadUtils.postOnBackgroundThread( + () -> { + getContentFromUri(); + }); + } + + /** Stops listening for updates from the ContentProvider. */ + public synchronized void unsubscribe() { + if (!mSubscribed && mUri != null) { + return; + } + mSubscribed = false; + mContext.getContentResolver().unregisterContentObserver(mContentObserver); + } + + /** Retrieves the most recently fetched value from the ContentProvider. */ + @Nullable + public String getContent() { + return mContent; + } + + private void getContentFromUri() { + if (mUri == null) { + Log.e(mLogTag, "Uri null when trying to fetch content"); + return; + } + ContentResolver contentResolver = mContext.getContentResolver(); + ContentProviderClient client = contentResolver.acquireContentProviderClient(mUri); + Bundle bundle; + try { + bundle = client.call(mMethodName, null /* arg */, null /* extras */); + } catch (RemoteException e) { + Log.e(mLogTag, "Failed to call contentProvider", e); + return; + } finally { + client.close(); + } + if (bundle == null) { + Log.e(mLogTag, "Null bundle returned from contentProvider"); + return; + } + String newValue = bundle.getString(mContentKey); + if (!TextUtils.equals(mContent, newValue)) { + mContent = newValue; + mContentChangedListener.onContentChanged(mContent); + } + } +} diff --git a/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceController.java b/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceController.java index e423a880bfe..05d4acb8f24 100644 --- a/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceController.java +++ b/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceController.java @@ -27,6 +27,7 @@ import androidx.preference.PreferenceScreen; import com.android.settings.Utils; import com.android.settings.biometrics.BiometricStatusPreferenceController; +import com.android.settings.biometrics.activeunlock.ActiveUnlockContentListener.OnContentChangedListener; import com.android.settingslib.RestrictedPreference; /** @@ -34,7 +35,8 @@ import com.android.settingslib.RestrictedPreference; * controls the ability to unlock the phone with watch authentication. */ public class ActiveUnlockStatusPreferenceController - extends BiometricStatusPreferenceController implements LifecycleObserver { + extends BiometricStatusPreferenceController + implements LifecycleObserver, OnContentChangedListener { /** * Preference key. * @@ -43,7 +45,9 @@ public class ActiveUnlockStatusPreferenceController public static final String KEY_ACTIVE_UNLOCK_SETTINGS = "biometric_active_unlock_settings"; @Nullable private RestrictedPreference mPreference; @Nullable private PreferenceScreen mPreferenceScreen; + @Nullable private String mSummary; private final ActiveUnlockStatusUtils mActiveUnlockStatusUtils; + private final ActiveUnlockSummaryListener mActiveUnlockSummaryListener; public ActiveUnlockStatusPreferenceController(@NonNull Context context) { this(context, KEY_ACTIVE_UNLOCK_SETTINGS); @@ -53,6 +57,14 @@ public class ActiveUnlockStatusPreferenceController @NonNull Context context, @NonNull String key) { super(context, key); mActiveUnlockStatusUtils = new ActiveUnlockStatusUtils(context); + mActiveUnlockSummaryListener = new ActiveUnlockSummaryListener(context, this); + } + + + /** Subscribes to update preference summary dynamically. */ + @OnLifecycleEvent(Lifecycle.Event.ON_START) + public void onStart() { + mActiveUnlockSummaryListener.subscribe(); } /** Resets the preference reference on resume. */ @@ -63,6 +75,20 @@ public class ActiveUnlockStatusPreferenceController } } + /** Unsubscribes to prevent leaked listener. */ + @OnLifecycleEvent(Lifecycle.Event.ON_STOP) + public void onStop() { + mActiveUnlockSummaryListener.unsubscribe(); + } + + @Override + public void onContentChanged(String newContent) { + mSummary = newContent; + if (mPreference != null) { + mPreference.setSummary(getSummaryText()); + } + } + @Override public void displayPreference(PreferenceScreen screen) { super.displayPreference(screen); @@ -94,8 +120,11 @@ public class ActiveUnlockStatusPreferenceController @Override protected String getSummaryText() { - // TODO(b/264812018): set the summary from the ContentProvider - return ""; + if (mSummary == null) { + // return non-empty string to prevent re-sizing of the tile + return " "; + } + return mSummary; } @Override diff --git a/src/com/android/settings/biometrics/activeunlock/ActiveUnlockSummaryListener.java b/src/com/android/settings/biometrics/activeunlock/ActiveUnlockSummaryListener.java new file mode 100644 index 00000000000..bcffe6297d1 --- /dev/null +++ b/src/com/android/settings/biometrics/activeunlock/ActiveUnlockSummaryListener.java @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2023 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.biometrics.activeunlock; + +import android.content.Context; + +/** Listens to summary updates from the content provider and fetches the latest value. */ +public class ActiveUnlockSummaryListener { + private static final String TAG = "ActiveUnlockSummaryListener"; + private static final String METHOD_NAME = "getSummary"; + private static final String SUMMARY_KEY = "com.android.settings.summary"; + + private final ActiveUnlockContentListener mContentListener; + public ActiveUnlockSummaryListener( + Context context, ActiveUnlockContentListener.OnContentChangedListener listener) { + mContentListener = new ActiveUnlockContentListener( + context, listener, TAG, METHOD_NAME, SUMMARY_KEY); + } + + /** Subscribes for summary updates. */ + public void subscribe() { + mContentListener.subscribe(); + } + + /** Unsubscribes from summary updates. */ + public void unsubscribe() { + mContentListener.unsubscribe(); + } +} diff --git a/tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListenerTest.java b/tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListenerTest.java new file mode 100644 index 00000000000..cb0c9420e80 --- /dev/null +++ b/tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockContentListenerTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2023 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.biometrics.activeunlock; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.robolectric.shadows.ShadowLooper.idleMainLooper; + +import android.content.Context; +import android.content.pm.PackageManager; + +import androidx.annotation.Nullable; + +import com.android.settings.biometrics.activeunlock.ActiveUnlockContentListener.OnContentChangedListener; +import com.android.settings.testutils.ActiveUnlockTestUtils; +import com.android.settings.testutils.shadow.ShadowDeviceConfig; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; +import org.robolectric.Robolectric; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowDeviceConfig.class}) +public class ActiveUnlockContentListenerTest { + + @Rule public final MockitoRule mMocks = MockitoJUnit.rule(); + @Mock private PackageManager mPackageManager; + + private Context mContext; + private ActiveUnlockContentListener mContentListener; + @Nullable private String mContent; + private int mUpdateCount; + + @Before + public void setUp() { + Robolectric.setupContentProvider( + FakeContentProvider.class, FakeContentProvider.AUTHORITY); + mContext = spy(RuntimeEnvironment.application); + when(mContext.getPackageManager()).thenReturn(mPackageManager); + OnContentChangedListener listener = new OnContentChangedListener() { + @Override + public void onContentChanged(String newValue) { + mContent = newValue; + mUpdateCount++; + } + }; + ActiveUnlockTestUtils.enable(mContext); + mContentListener = + new ActiveUnlockContentListener( + mContext, + listener, + "logTag", + FakeContentProvider.METHOD_SUMMARY, + FakeContentProvider.KEY_SUMMARY); + FakeContentProvider.init(mContext); + } + + @Test + public void subscribe_contentFetched() { + String newContent = "newContent"; + FakeContentProvider.setTileSummary(newContent); + + mContentListener.subscribe(); + idleMainLooper(); + + assertThat(mContent).isEqualTo(newContent); + } + + @Test + public void contentUpdated_contentUpdated() { + mContentListener.subscribe(); + idleMainLooper(); + + String newContent = "newContent"; + updateContent(newContent); + + assertThat(mContent).isEqualTo(newContent); + } + + @Test + public void contentUpdated_unsubscribed_contentNotUpdated() { + mContentListener.subscribe(); + idleMainLooper(); + + mContentListener.unsubscribe(); + updateContent("newContent"); + + assertThat(mContent).isNull(); + } + + @Test + public void multipleContentUpdates_contentIsNewestValueAndUpdatedTwice() { + mContentListener.subscribe(); + idleMainLooper(); + + updateContent("temporaryContent"); + String newContent = "newContent"; + updateContent(newContent); + + assertThat(mContent).isEqualTo(newContent); + assertThat(mUpdateCount).isEqualTo(2); + } + + @Test + public void duplicateContentUpdates_onContentChangedOnlyCalledOnce() { + mContentListener.subscribe(); + idleMainLooper(); + + updateContent("newContent"); + updateContent("newContent"); + + assertThat(mUpdateCount).isEqualTo(1); + } + + private void updateContent(String content) { + FakeContentProvider.setTileSummary(content); + mContext.getContentResolver().notifyChange( + FakeContentProvider.URI, null /* observer */); + idleMainLooper(); + } +} diff --git a/tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceControllerTest.java index 572c005a67e..bf60d0173c0 100644 --- a/tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceControllerTest.java +++ b/tests/robotests/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceControllerTest.java @@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.when; +import static org.robolectric.shadows.ShadowLooper.idleMainLooper; import android.content.Context; import android.content.pm.PackageManager; @@ -43,6 +44,7 @@ import org.junit.runner.RunWith; import org.mockito.Mock; import org.mockito.junit.MockitoJUnit; import org.mockito.junit.MockitoRule; +import org.robolectric.Robolectric; import org.robolectric.RobolectricTestRunner; import org.robolectric.RuntimeEnvironment; import org.robolectric.annotation.Config; @@ -66,6 +68,7 @@ public class ActiveUnlockStatusPreferenceControllerTest { @Before public void setUp() { + Robolectric.setupContentProvider(FakeContentProvider.class, FakeContentProvider.AUTHORITY); mContext = spy(RuntimeEnvironment.application); when(mContext.getPackageManager()).thenReturn(mPackageManager); when(mPackageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)).thenReturn(true); @@ -80,6 +83,7 @@ public class ActiveUnlockStatusPreferenceControllerTest { when(mFingerprintManager.isHardwareDetected()).thenReturn(true); when(mFaceManager.isHardwareDetected()).thenReturn(true); ActiveUnlockTestUtils.enable(mContext); + FakeContentProvider.init(mContext); mController = new ActiveUnlockStatusPreferenceController(mContext); } @@ -136,4 +140,31 @@ public class ActiveUnlockStatusPreferenceControllerTest { assertThat(mPreference.isVisible()).isTrue(); } + + @Test + public void defaultState_summaryIsEmpty() { + mController.displayPreference(mPreferenceScreen); + + idleMainLooper(); + + assertThat(mPreference.getSummary().toString()).isEqualTo(" "); + } + + @Test + public void onStart_summaryIsUpdated() { + String summary = "newSummary"; + updateSummary(summary); + mController.displayPreference(mPreferenceScreen); + + mController.onStart(); + idleMainLooper(); + + assertThat(mPreference.getSummary().toString()).isEqualTo(summary); + } + + private void updateSummary(String summary) { + FakeContentProvider.setTileSummary(summary); + mContext.getContentResolver().notifyChange(FakeContentProvider.URI, null /* observer */); + idleMainLooper(); + } } diff --git a/tests/robotests/src/com/android/settings/biometrics/activeunlock/FakeContentProvider.java b/tests/robotests/src/com/android/settings/biometrics/activeunlock/FakeContentProvider.java new file mode 100644 index 00000000000..07b79dab575 --- /dev/null +++ b/tests/robotests/src/com/android/settings/biometrics/activeunlock/FakeContentProvider.java @@ -0,0 +1,99 @@ +/* + * Copyright (C) 2023 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.biometrics.activeunlock; + +import android.content.ContentProvider; +import android.content.ContentResolver; +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; + +import androidx.annotation.Nullable; + +import com.android.settings.testutils.ActiveUnlockTestUtils; + +/** ContentProvider to provider tile summary for ActiveUnlock in tests. */ +public final class FakeContentProvider extends ContentProvider { + public static final String AUTHORITY = ActiveUnlockTestUtils.PROVIDER; + public static final Uri URI = new Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(AUTHORITY) + .appendPath("getSummary") + .build(); + public static final String METHOD_SUMMARY = "getSummary"; + public static final String KEY_SUMMARY = "com.android.settings.summary"; + @Nullable private static String sTileSummary; + @Nullable private static String sDeviceName; + + public FakeContentProvider() { + super(); + } + + public static void setTileSummary(String summary) { + sTileSummary = summary; + } + + public static void init(Context context) { + Settings.Secure.putString( + context.getContentResolver(), ActiveUnlockTestUtils.PROVIDER_SETTING, AUTHORITY); + sTileSummary = null; + } + + @Override + public Bundle call(String method, String arg, Bundle extras) { + Bundle bundle = new Bundle(); + if (METHOD_SUMMARY.equals(method)) { + bundle.putCharSequence(KEY_SUMMARY, sTileSummary); + } + return bundle; + } + + @Override + public boolean onCreate() { + return true; + } + + @Override + public Cursor query(Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + return null; + } + + @Override + public Uri insert(Uri uri, ContentValues values) { + return null; + } + + @Override + public int update(Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + return 0; + } + + @Override + public int delete(Uri uri, String selection, String[] selectionArgs) { + return 0; + } + + @Override + public String getType(Uri uri) { + return null; + } +}