summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--services/core/java/com/android/server/biometrics/AuthenticationStats.java32
-rw-r--r--services/core/java/com/android/server/biometrics/AuthenticationStatsCollector.java27
-rw-r--r--services/core/java/com/android/server/biometrics/AuthenticationStatsPersister.java192
-rw-r--r--services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsCollectorTest.java39
-rw-r--r--services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsPersisterTest.java233
5 files changed, 508 insertions, 15 deletions
diff --git a/services/core/java/com/android/server/biometrics/AuthenticationStats.java b/services/core/java/com/android/server/biometrics/AuthenticationStats.java
index 137a418e31ab..e109cc8011e7 100644
--- a/services/core/java/com/android/server/biometrics/AuthenticationStats.java
+++ b/services/core/java/com/android/server/biometrics/AuthenticationStats.java
@@ -22,6 +22,8 @@ package com.android.server.biometrics;
*/
public class AuthenticationStats {
+ private static final float FRR_NOT_ENOUGH_ATTEMPTS = -1.0f;
+
private final int mUserId;
private int mTotalAttempts;
private int mRejectedAttempts;
@@ -70,7 +72,7 @@ public class AuthenticationStats {
if (mTotalAttempts > 0) {
return mRejectedAttempts / (float) mTotalAttempts;
} else {
- return -1.0f;
+ return FRR_NOT_ENOUGH_ATTEMPTS;
}
}
@@ -87,4 +89,32 @@ public class AuthenticationStats {
mTotalAttempts = 0;
mRejectedAttempts = 0;
}
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+
+ if (!(obj instanceof AuthenticationStats)) {
+ return false;
+ }
+
+ AuthenticationStats target = (AuthenticationStats) obj;
+ return this.getUserId() == target.getUserId()
+ && this.getTotalAttempts()
+ == target.getTotalAttempts()
+ && this.getRejectedAttempts()
+ == target.getRejectedAttempts()
+ && this.getEnrollmentNotifications()
+ == target.getEnrollmentNotifications()
+ && this.getModality() == target.getModality();
+ }
+
+ @Override
+ public int hashCode() {
+ return String.format("userId: %d, totalAttempts: %d, rejectedAttempts: %d, "
+ + "enrollmentNotifications: %d, modality: %d", mUserId, mTotalAttempts,
+ mRejectedAttempts, mEnrollmentNotifications, mModality).hashCode();
+ }
}
diff --git a/services/core/java/com/android/server/biometrics/AuthenticationStatsCollector.java b/services/core/java/com/android/server/biometrics/AuthenticationStatsCollector.java
index c9cd8148c0f1..85125d294015 100644
--- a/services/core/java/com/android/server/biometrics/AuthenticationStatsCollector.java
+++ b/services/core/java/com/android/server/biometrics/AuthenticationStatsCollector.java
@@ -37,23 +37,42 @@ public class AuthenticationStatsCollector {
// The minimum number of attempts that will calculate the FRR and trigger the notification.
private static final int MINIMUM_ATTEMPTS = 500;
+ // Upload the data every 50 attempts (average number of daily authentications).
+ private static final int AUTHENTICATION_UPLOAD_INTERVAL = 50;
// The maximum number of eligible biometric enrollment notification can be sent.
private static final int MAXIMUM_ENROLLMENT_NOTIFICATIONS = 2;
+ @NonNull private final Context mContext;
+
private final float mThreshold;
private final int mModality;
@NonNull private final Map<Integer, AuthenticationStats> mUserAuthenticationStatsMap;
+ @NonNull private AuthenticationStatsPersister mAuthenticationStatsPersister;
+
public AuthenticationStatsCollector(@NonNull Context context, int modality) {
+ mContext = context;
mThreshold = context.getResources()
.getFraction(R.fraction.config_biometricNotificationFrrThreshold, 1, 1);
mUserAuthenticationStatsMap = new HashMap<>();
mModality = modality;
}
+ private void initializeUserAuthenticationStatsMap() {
+ mAuthenticationStatsPersister = new AuthenticationStatsPersister(mContext);
+ for (AuthenticationStats stats : mAuthenticationStatsPersister.getAllFrrStats(mModality)) {
+ mUserAuthenticationStatsMap.put(stats.getUserId(), stats);
+ }
+ }
+
/** Update total authentication and rejected attempts. */
public void authenticate(int userId, boolean authenticated) {
+ // Initialize mUserAuthenticationStatsMap when authenticate to ensure SharedPreferences
+ // are ready for application use and avoid ramdump issue.
+ if (mUserAuthenticationStatsMap.isEmpty()) {
+ initializeUserAuthenticationStatsMap();
+ }
// Check if this is a new user.
if (!mUserAuthenticationStatsMap.containsKey(userId)) {
mUserAuthenticationStatsMap.put(userId, new AuthenticationStats(userId, mModality));
@@ -82,8 +101,12 @@ public class AuthenticationStatsCollector {
private void persistDataIfNeeded(int userId) {
AuthenticationStats authenticationStats = mUserAuthenticationStatsMap.get(userId);
- if (authenticationStats.getTotalAttempts() % 50 == 0) {
- // TODO(wenhuiy): Persist data.
+ if (authenticationStats.getTotalAttempts() % AUTHENTICATION_UPLOAD_INTERVAL == 0) {
+ mAuthenticationStatsPersister.persistFrrStats(authenticationStats.getUserId(),
+ authenticationStats.getTotalAttempts(),
+ authenticationStats.getRejectedAttempts(),
+ authenticationStats.getEnrollmentNotifications(),
+ authenticationStats.getModality());
}
}
diff --git a/services/core/java/com/android/server/biometrics/AuthenticationStatsPersister.java b/services/core/java/com/android/server/biometrics/AuthenticationStatsPersister.java
new file mode 100644
index 000000000000..96150a655342
--- /dev/null
+++ b/services/core/java/com/android/server/biometrics/AuthenticationStatsPersister.java
@@ -0,0 +1,192 @@
+/*
+ * 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.server.biometrics;
+
+import android.annotation.NonNull;
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.hardware.biometrics.BiometricsProtoEnums;
+import android.os.Environment;
+import android.util.Slog;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Persists and retrieves stats for Biometric Authentication.
+ * Authentication stats include userId, total attempts, rejected attempts,
+ * and the number of sent enrollment notifications.
+ * Data are stored in SharedPreferences in a form of a set of JSON objects,
+ * where it's one element per user.
+ */
+public class AuthenticationStatsPersister {
+
+ private static final String TAG = "AuthenticationStatsPersister";
+ private static final String FILE_NAME = "authentication_stats";
+ private static final String USER_ID = "user_id";
+ private static final String FACE_ATTEMPTS = "face_attempts";
+ private static final String FACE_REJECTIONS = "face_rejections";
+ private static final String FINGERPRINT_ATTEMPTS = "fingerprint_attempts";
+ private static final String FINGERPRINT_REJECTIONS = "fingerprint_rejections";
+ private static final String ENROLLMENT_NOTIFICATIONS = "enrollment_notifications";
+ private static final String KEY = "frr_stats";
+
+ @NonNull private final SharedPreferences mSharedPreferences;
+
+ AuthenticationStatsPersister(@NonNull Context context) {
+ // The package info in the context isn't initialized in the way it is for normal apps,
+ // so the standard, name-based context.getSharedPreferences doesn't work. Instead, we
+ // build the path manually below using the same policy that appears in ContextImpl.
+ final File prefsFile = new File(Environment.getDataSystemDeDirectory(), FILE_NAME);
+ mSharedPreferences = context.getSharedPreferences(prefsFile, Context.MODE_PRIVATE);
+ }
+
+ /**
+ * Get all frr data from SharedPreference.
+ */
+ public List<AuthenticationStats> getAllFrrStats(int modality) {
+ List<AuthenticationStats> authenticationStatsList = new ArrayList<>();
+ for (String frrStats : readFrrStats()) {
+ try {
+ JSONObject frrStatsJson = new JSONObject(frrStats);
+ if (modality == BiometricsProtoEnums.MODALITY_FACE) {
+ authenticationStatsList.add(new AuthenticationStats(
+ getIntValue(frrStatsJson, USER_ID, -1 /* defaultValue */),
+ getIntValue(frrStatsJson, FACE_ATTEMPTS),
+ getIntValue(frrStatsJson, FACE_REJECTIONS),
+ getIntValue(frrStatsJson, ENROLLMENT_NOTIFICATIONS),
+ modality));
+ } else if (modality == BiometricsProtoEnums.MODALITY_FINGERPRINT) {
+ authenticationStatsList.add(new AuthenticationStats(
+ getIntValue(frrStatsJson, USER_ID, -1 /* defaultValue */),
+ getIntValue(frrStatsJson, FINGERPRINT_ATTEMPTS),
+ getIntValue(frrStatsJson, FINGERPRINT_REJECTIONS),
+ getIntValue(frrStatsJson, ENROLLMENT_NOTIFICATIONS),
+ modality));
+ }
+ } catch (JSONException e) {
+ Slog.w(TAG, String.format("Unable to resolve authentication stats JSON: %s",
+ frrStats));
+ }
+ }
+ return authenticationStatsList;
+ }
+
+ /**
+ * Persist frr data for a specific user.
+ */
+ public void persistFrrStats(int userId, int totalAttempts, int rejectedAttempts,
+ int enrollmentNotifications, int modality) {
+ try {
+ // Copy into a new HashSet to avoid iterator exception.
+ Set<String> frrStatsSet = new HashSet<>(readFrrStats());
+
+ // Remove the old authentication stat for the user if it exists.
+ JSONObject frrStatJson = null;
+ for (Iterator<String> iterator = frrStatsSet.iterator(); iterator.hasNext();) {
+ String frrStats = iterator.next();
+ frrStatJson = new JSONObject(frrStats);
+ if (getValue(frrStatJson, USER_ID).equals(String.valueOf(userId))) {
+ iterator.remove();
+ break;
+ }
+ }
+
+ // If there's existing frr stats in the file, we want to update the stats for the given
+ // modality and keep the stats for other modalities.
+ if (frrStatJson != null) {
+ frrStatsSet.add(buildFrrStats(frrStatJson, totalAttempts, rejectedAttempts,
+ enrollmentNotifications, modality));
+ } else {
+ frrStatsSet.add(buildFrrStats(userId, totalAttempts, rejectedAttempts,
+ enrollmentNotifications, modality));
+ }
+
+ mSharedPreferences.edit().putStringSet(KEY, frrStatsSet).apply();
+
+ } catch (JSONException e) {
+ Slog.e(TAG, "Unable to persist authentication stats");
+ }
+ }
+
+ private Set<String> readFrrStats() {
+ return mSharedPreferences.getStringSet(KEY, Set.of());
+ }
+
+ // Update frr stats for existing frrStats JSONObject and build the new string.
+ private String buildFrrStats(JSONObject frrStats, int totalAttempts, int rejectedAttempts,
+ int enrollmentNotifications, int modality) throws JSONException {
+ if (modality == BiometricsProtoEnums.MODALITY_FACE) {
+ return frrStats
+ .put(FACE_ATTEMPTS, totalAttempts)
+ .put(FACE_REJECTIONS, rejectedAttempts)
+ .put(ENROLLMENT_NOTIFICATIONS, enrollmentNotifications)
+ .toString();
+ } else if (modality == BiometricsProtoEnums.MODALITY_FINGERPRINT) {
+ return frrStats
+ .put(FINGERPRINT_ATTEMPTS, totalAttempts)
+ .put(FINGERPRINT_REJECTIONS, rejectedAttempts)
+ .put(ENROLLMENT_NOTIFICATIONS, enrollmentNotifications)
+ .toString();
+ } else {
+ return frrStats.toString();
+ }
+ }
+
+ // Build string for new user and new authentication stats.
+ private String buildFrrStats(int userId, int totalAttempts, int rejectedAttempts,
+ int enrollmentNotifications, int modality)
+ throws JSONException {
+ if (modality == BiometricsProtoEnums.MODALITY_FACE) {
+ return new JSONObject()
+ .put(USER_ID, userId)
+ .put(FACE_ATTEMPTS, totalAttempts)
+ .put(FACE_REJECTIONS, rejectedAttempts)
+ .put(ENROLLMENT_NOTIFICATIONS, enrollmentNotifications)
+ .toString();
+ } else if (modality == BiometricsProtoEnums.MODALITY_FINGERPRINT) {
+ return new JSONObject()
+ .put(USER_ID, userId)
+ .put(FINGERPRINT_ATTEMPTS, totalAttempts)
+ .put(FINGERPRINT_REJECTIONS, rejectedAttempts)
+ .put(ENROLLMENT_NOTIFICATIONS, enrollmentNotifications)
+ .toString();
+ } else {
+ return "";
+ }
+ }
+
+ private String getValue(JSONObject jsonObject, String key) throws JSONException {
+ return jsonObject.has(key) ? jsonObject.getString(key) : "";
+ }
+
+ private int getIntValue(JSONObject jsonObject, String key) throws JSONException {
+ return getIntValue(jsonObject, key, 0 /* defaultValue */);
+ }
+
+ private int getIntValue(JSONObject jsonObject, String key, int defaultValue)
+ throws JSONException {
+ return jsonObject.has(key) ? jsonObject.getInt(key) : defaultValue;
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsCollectorTest.java b/services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsCollectorTest.java
index 99d66c5bda19..a578f9a6bf39 100644
--- a/services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsCollectorTest.java
+++ b/services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsCollectorTest.java
@@ -16,11 +16,18 @@
package com.android.server.biometrics;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
+import static java.util.Collections.emptySet;
+
import android.content.Context;
+import android.content.SharedPreferences;
import android.content.res.Resources;
import com.android.internal.R;
@@ -30,6 +37,8 @@ import org.junit.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
+import java.io.File;
+
public class AuthenticationStatsCollectorTest {
private AuthenticationStatsCollector mAuthenticationStatsCollector;
@@ -40,6 +49,8 @@ public class AuthenticationStatsCollectorTest {
private Context mContext;
@Mock
private Resources mResources;
+ @Mock
+ private SharedPreferences mSharedPreferences;
@Before
public void setUp() {
@@ -48,6 +59,9 @@ public class AuthenticationStatsCollectorTest {
when(mContext.getResources()).thenReturn(mResources);
when(mResources.getFraction(R.fraction.config_biometricNotificationFrrThreshold, 1, 1))
.thenReturn(FRR_THRESHOLD);
+ when(mContext.getSharedPreferences(any(File.class), anyInt()))
+ .thenReturn(mSharedPreferences);
+ when(mSharedPreferences.getStringSet(anyString(), anySet())).thenReturn(emptySet());
mAuthenticationStatsCollector = new AuthenticationStatsCollector(mContext,
0 /* modality */);
@@ -57,30 +71,31 @@ public class AuthenticationStatsCollectorTest {
@Test
public void authenticate_authenticationSucceeded_mapShouldBeUpdated() {
// Assert that the user doesn't exist in the map initially.
- assertNull(mAuthenticationStatsCollector.getAuthenticationStatsForUser(USER_ID_1));
+ assertThat(mAuthenticationStatsCollector.getAuthenticationStatsForUser(USER_ID_1)).isNull();
mAuthenticationStatsCollector.authenticate(USER_ID_1, true /* authenticated*/);
AuthenticationStats authenticationStats =
mAuthenticationStatsCollector.getAuthenticationStatsForUser(USER_ID_1);
- assertEquals(USER_ID_1, authenticationStats.getUserId());
- assertEquals(1, authenticationStats.getTotalAttempts());
- assertEquals(0, authenticationStats.getRejectedAttempts());
- assertEquals(0, authenticationStats.getEnrollmentNotifications());
+ assertThat(authenticationStats.getUserId()).isEqualTo(USER_ID_1);
+ assertThat(authenticationStats.getTotalAttempts()).isEqualTo(1);
+ assertThat(authenticationStats.getRejectedAttempts()).isEqualTo(0);
+ assertThat(authenticationStats.getEnrollmentNotifications()).isEqualTo(0);
}
@Test
public void authenticate_authenticationFailed_mapShouldBeUpdated() {
// Assert that the user doesn't exist in the map initially.
- assertNull(mAuthenticationStatsCollector.getAuthenticationStatsForUser(USER_ID_1));
+ assertThat(mAuthenticationStatsCollector.getAuthenticationStatsForUser(USER_ID_1)).isNull();
mAuthenticationStatsCollector.authenticate(USER_ID_1, false /* authenticated*/);
AuthenticationStats authenticationStats =
mAuthenticationStatsCollector.getAuthenticationStatsForUser(USER_ID_1);
- assertEquals(USER_ID_1, authenticationStats.getUserId());
- assertEquals(1, authenticationStats.getTotalAttempts());
- assertEquals(1, authenticationStats.getRejectedAttempts());
- assertEquals(0, authenticationStats.getEnrollmentNotifications());
+
+ assertThat(authenticationStats.getUserId()).isEqualTo(USER_ID_1);
+ assertThat(authenticationStats.getTotalAttempts()).isEqualTo(1);
+ assertThat(authenticationStats.getRejectedAttempts()).isEqualTo(1);
+ assertThat(authenticationStats.getEnrollmentNotifications()).isEqualTo(0);
}
}
diff --git a/services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsPersisterTest.java b/services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsPersisterTest.java
new file mode 100644
index 000000000000..455625cf69ec
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/biometrics/AuthenticationStatsPersisterTest.java
@@ -0,0 +1,233 @@
+/*
+ * 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.server.biometrics;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anySet;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.hardware.biometrics.BiometricsProtoEnums;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.io.File;
+import java.util.List;
+import java.util.Set;
+
+public class AuthenticationStatsPersisterTest {
+
+ @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();
+
+ private static final int USER_ID_1 = 1;
+ private static final int USER_ID_2 = 2;
+ private static final String USER_ID = "user_id";
+ private static final String FACE_ATTEMPTS = "face_attempts";
+ private static final String FACE_REJECTIONS = "face_rejections";
+ private static final String FINGERPRINT_ATTEMPTS = "fingerprint_attempts";
+ private static final String FINGERPRINT_REJECTIONS = "fingerprint_rejections";
+ private static final String ENROLLMENT_NOTIFICATIONS = "enrollment_notifications";
+ private static final String KEY = "frr_stats";
+
+ @Mock
+ private Context mContext;
+ @Mock
+ private SharedPreferences mSharedPreferences;
+ @Mock
+ private SharedPreferences.Editor mEditor;
+ private AuthenticationStatsPersister mAuthenticationStatsPersister;
+
+ @Captor
+ private ArgumentCaptor<Set<String>> mStringSetArgumentCaptor;
+
+ @Before
+ public void setUp() {
+ when(mContext.getSharedPreferences(any(File.class), anyInt()))
+ .thenReturn(mSharedPreferences);
+ when(mSharedPreferences.edit()).thenReturn(mEditor);
+ when(mEditor.putStringSet(anyString(), anySet())).thenReturn(mEditor);
+
+ mAuthenticationStatsPersister = new AuthenticationStatsPersister(mContext);
+ }
+
+ @Test
+ public void getAllFrrStats_face_shouldListAllFrrStats() throws JSONException {
+ AuthenticationStats stats1 = new AuthenticationStats(USER_ID_1,
+ 300 /* totalAttempts */, 10 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
+ AuthenticationStats stats2 = new AuthenticationStats(USER_ID_2,
+ 200 /* totalAttempts */, 20 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
+ when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
+ Set.of(buildFrrStats(stats1), buildFrrStats(stats2)));
+
+ List<AuthenticationStats> authenticationStatsList =
+ mAuthenticationStatsPersister.getAllFrrStats(BiometricsProtoEnums.MODALITY_FACE);
+
+ assertThat(authenticationStatsList.size()).isEqualTo(2);
+ AuthenticationStats expectedStats2 = new AuthenticationStats(USER_ID_2,
+ 0 /* totalAttempts */, 0 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
+ assertThat(authenticationStatsList).contains(stats1);
+ assertThat(authenticationStatsList).contains(expectedStats2);
+ }
+
+ @Test
+ public void getAllFrrStats_fingerprint_shouldListAllFrrStats() throws JSONException {
+ // User 1 with fingerprint authentication stats.
+ AuthenticationStats stats1 = new AuthenticationStats(USER_ID_1,
+ 200 /* totalAttempts */, 20 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
+ // User 2 without fingerprint authentication stats.
+ AuthenticationStats stats2 = new AuthenticationStats(USER_ID_2,
+ 300 /* totalAttempts */, 10 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
+ when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
+ Set.of(buildFrrStats(stats1), buildFrrStats(stats2)));
+
+ List<AuthenticationStats> authenticationStatsList =
+ mAuthenticationStatsPersister
+ .getAllFrrStats(BiometricsProtoEnums.MODALITY_FINGERPRINT);
+
+ assertThat(authenticationStatsList.size()).isEqualTo(2);
+ AuthenticationStats expectedStats2 = new AuthenticationStats(USER_ID_2,
+ 0 /* totalAttempts */, 0 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
+ assertThat(authenticationStatsList).contains(stats1);
+ assertThat(authenticationStatsList).contains(expectedStats2);
+ }
+
+ @Test
+ public void persistFrrStats_newUser_face_shouldSuccess() throws JSONException {
+ AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
+ 300 /* totalAttempts */, 10 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
+
+ mAuthenticationStatsPersister.persistFrrStats(authenticationStats.getUserId(),
+ authenticationStats.getTotalAttempts(),
+ authenticationStats.getRejectedAttempts(),
+ authenticationStats.getEnrollmentNotifications(),
+ authenticationStats.getModality());
+
+ verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
+ assertThat(mStringSetArgumentCaptor.getValue())
+ .contains(buildFrrStats(authenticationStats));
+ }
+
+ @Test
+ public void persistFrrStats_newUser_fingerprint_shouldSuccess() throws JSONException {
+ AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
+ 300 /* totalAttempts */, 10 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
+
+ mAuthenticationStatsPersister.persistFrrStats(authenticationStats.getUserId(),
+ authenticationStats.getTotalAttempts(),
+ authenticationStats.getRejectedAttempts(),
+ authenticationStats.getEnrollmentNotifications(),
+ authenticationStats.getModality());
+
+ verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
+ assertThat(mStringSetArgumentCaptor.getValue())
+ .contains(buildFrrStats(authenticationStats));
+ }
+
+ @Test
+ public void persistFrrStats_existingUser_shouldUpdateRecord() throws JSONException {
+ AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
+ 300 /* totalAttempts */, 10 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
+ AuthenticationStats newAuthenticationStats = new AuthenticationStats(USER_ID_1,
+ 500 /* totalAttempts */, 30 /* rejectedAttempts */,
+ 1 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
+ when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
+ Set.of(buildFrrStats(authenticationStats)));
+
+ mAuthenticationStatsPersister.persistFrrStats(newAuthenticationStats.getUserId(),
+ newAuthenticationStats.getTotalAttempts(),
+ newAuthenticationStats.getRejectedAttempts(),
+ newAuthenticationStats.getEnrollmentNotifications(),
+ newAuthenticationStats.getModality());
+
+ verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
+ assertThat(mStringSetArgumentCaptor.getValue())
+ .contains(buildFrrStats(newAuthenticationStats));
+ }
+
+ @Test
+ public void persistFrrStats_existingUserWithFingerprint_faceAuthenticate_shouldUpdateRecord()
+ throws JSONException {
+ // User with fingerprint authentication stats.
+ AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
+ 200 /* totalAttempts */, 20 /* rejectedAttempts */,
+ 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
+ // The same user with face authentication stats.
+ AuthenticationStats newAuthenticationStats = new AuthenticationStats(USER_ID_1,
+ 500 /* totalAttempts */, 30 /* rejectedAttempts */,
+ 1 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
+ when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
+ Set.of(buildFrrStats(authenticationStats)));
+
+ mAuthenticationStatsPersister.persistFrrStats(newAuthenticationStats.getUserId(),
+ newAuthenticationStats.getTotalAttempts(),
+ newAuthenticationStats.getRejectedAttempts(),
+ newAuthenticationStats.getEnrollmentNotifications(),
+ newAuthenticationStats.getModality());
+
+ String expectedFrrStats = new JSONObject(buildFrrStats(authenticationStats))
+ .put(ENROLLMENT_NOTIFICATIONS, newAuthenticationStats.getEnrollmentNotifications())
+ .put(FACE_ATTEMPTS, newAuthenticationStats.getTotalAttempts())
+ .put(FACE_REJECTIONS, newAuthenticationStats.getRejectedAttempts()).toString();
+ verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
+ assertThat(mStringSetArgumentCaptor.getValue()).contains(expectedFrrStats);
+ }
+
+ private String buildFrrStats(AuthenticationStats authenticationStats)
+ throws JSONException {
+ if (authenticationStats.getModality() == BiometricsProtoEnums.MODALITY_FACE) {
+ return new JSONObject()
+ .put(USER_ID, authenticationStats.getUserId())
+ .put(FACE_ATTEMPTS, authenticationStats.getTotalAttempts())
+ .put(FACE_REJECTIONS, authenticationStats.getRejectedAttempts())
+ .put(ENROLLMENT_NOTIFICATIONS, authenticationStats.getEnrollmentNotifications())
+ .toString();
+ } else if (authenticationStats.getModality() == BiometricsProtoEnums.MODALITY_FINGERPRINT) {
+ return new JSONObject()
+ .put(USER_ID, authenticationStats.getUserId())
+ .put(FINGERPRINT_ATTEMPTS, authenticationStats.getTotalAttempts())
+ .put(FINGERPRINT_REJECTIONS, authenticationStats.getRejectedAttempts())
+ .put(ENROLLMENT_NOTIFICATIONS, authenticationStats.getEnrollmentNotifications())
+ .toString();
+ }
+ return "";
+ }
+}