diff options
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 ""; + } +} |