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
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 0000000..c2a8f39
--- /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 e423a88..05d4acb 100644
--- a/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceController.java
+++ b/src/com/android/settings/biometrics/activeunlock/ActiveUnlockStatusPreferenceController.java
@@ -27,6 +27,7 @@
 
 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 @@
  * 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 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 @@
             @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 @@
         }
     }
 
+    /** 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 @@
 
     @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 0000000..bcffe62
--- /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 0000000..cb0c942
--- /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 572c005..bf60d01 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.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.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 @@
 
     @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 @@
         when(mFingerprintManager.isHardwareDetected()).thenReturn(true);
         when(mFaceManager.isHardwareDetected()).thenReturn(true);
         ActiveUnlockTestUtils.enable(mContext);
+        FakeContentProvider.init(mContext);
         mController = new ActiveUnlockStatusPreferenceController(mContext);
     }
 
@@ -136,4 +140,31 @@
 
         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 0000000..07b79da
--- /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;
+    }
+}