1 /*
2  * Copyright (C) 2023 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.server.biometrics;
18 
19 import static com.google.common.truth.Truth.assertThat;
20 
21 import static org.mockito.ArgumentMatchers.any;
22 import static org.mockito.ArgumentMatchers.anyFloat;
23 import static org.mockito.ArgumentMatchers.anyInt;
24 import static org.mockito.ArgumentMatchers.anySet;
25 import static org.mockito.ArgumentMatchers.anyString;
26 import static org.mockito.ArgumentMatchers.eq;
27 import static org.mockito.Mockito.verify;
28 import static org.mockito.Mockito.when;
29 
30 import android.content.Context;
31 import android.content.SharedPreferences;
32 import android.hardware.biometrics.BiometricsProtoEnums;
33 import android.platform.test.annotations.Presubmit;
34 
35 import androidx.test.filters.SmallTest;
36 
37 import org.json.JSONException;
38 import org.json.JSONObject;
39 import org.junit.Before;
40 import org.junit.Rule;
41 import org.junit.Test;
42 import org.mockito.ArgumentCaptor;
43 import org.mockito.Captor;
44 import org.mockito.Mock;
45 import org.mockito.junit.MockitoJUnit;
46 import org.mockito.junit.MockitoRule;
47 
48 import java.io.File;
49 import java.util.List;
50 import java.util.Set;
51 
52 @Presubmit
53 @SmallTest
54 public class AuthenticationStatsPersisterTest {
55 
56     @Rule public MockitoRule mockitoRule = MockitoJUnit.rule();
57 
58     private static final int USER_ID_1 = 1;
59     private static final int USER_ID_2 = 2;
60     private static final String USER_ID = "user_id";
61     private static final String FACE_ATTEMPTS = "face_attempts";
62     private static final String FACE_REJECTIONS = "face_rejections";
63     private static final String FINGERPRINT_ATTEMPTS = "fingerprint_attempts";
64     private static final String FINGERPRINT_REJECTIONS = "fingerprint_rejections";
65     private static final String ENROLLMENT_NOTIFICATIONS = "enrollment_notifications";
66     private static final String KEY = "frr_stats";
67     private static final String THRESHOLD_KEY = "frr_threshold";
68     private static final float FRR_THRESHOLD = 0.25f;
69 
70     @Mock
71     private Context mContext;
72     @Mock
73     private SharedPreferences mSharedPreferences;
74     @Mock
75     private SharedPreferences.Editor mEditor;
76     private AuthenticationStatsPersister mAuthenticationStatsPersister;
77 
78     @Captor
79     private ArgumentCaptor<Set<String>> mStringSetArgumentCaptor;
80     @Captor
81     private ArgumentCaptor<Float> mFrrThresholdArgumentCaptor;
82 
83     @Before
setUp()84     public void setUp() {
85         when(mContext.getSharedPreferences(any(File.class), anyInt()))
86                 .thenReturn(mSharedPreferences);
87         when(mSharedPreferences.edit()).thenReturn(mEditor);
88         when(mEditor.putStringSet(anyString(), anySet())).thenReturn(mEditor);
89         when(mEditor.putFloat(anyString(), anyFloat())).thenReturn(mEditor);
90 
91         mAuthenticationStatsPersister = new AuthenticationStatsPersister(mContext);
92     }
93 
94     @Test
getAllFrrStats_face_shouldListAllFrrStats()95     public void getAllFrrStats_face_shouldListAllFrrStats() throws JSONException {
96         AuthenticationStats stats1 = new AuthenticationStats(USER_ID_1,
97                 300 /* totalAttempts */, 10 /* rejectedAttempts */,
98                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
99         AuthenticationStats stats2 = new AuthenticationStats(USER_ID_2,
100                 200 /* totalAttempts */, 20 /* rejectedAttempts */,
101                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
102         when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
103                 Set.of(buildFrrStats(stats1), buildFrrStats(stats2)));
104 
105         List<AuthenticationStats> authenticationStatsList =
106                 mAuthenticationStatsPersister.getAllFrrStats(BiometricsProtoEnums.MODALITY_FACE);
107 
108         assertThat(authenticationStatsList.size()).isEqualTo(2);
109         AuthenticationStats expectedStats2 = new AuthenticationStats(USER_ID_2,
110                 0 /* totalAttempts */, 0 /* rejectedAttempts */,
111                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
112         assertThat(authenticationStatsList).contains(stats1);
113         assertThat(authenticationStatsList).contains(expectedStats2);
114     }
115 
116     @Test
getAllFrrStats_fingerprint_shouldListAllFrrStats()117     public void getAllFrrStats_fingerprint_shouldListAllFrrStats() throws JSONException {
118         // User 1 with fingerprint authentication stats.
119         AuthenticationStats stats1 = new AuthenticationStats(USER_ID_1,
120                 200 /* totalAttempts */, 20 /* rejectedAttempts */,
121                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
122         // User 2 without fingerprint authentication stats.
123         AuthenticationStats stats2 = new AuthenticationStats(USER_ID_2,
124                 300 /* totalAttempts */, 10 /* rejectedAttempts */,
125                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
126         when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
127                 Set.of(buildFrrStats(stats1), buildFrrStats(stats2)));
128 
129         List<AuthenticationStats> authenticationStatsList =
130                 mAuthenticationStatsPersister
131                         .getAllFrrStats(BiometricsProtoEnums.MODALITY_FINGERPRINT);
132 
133         assertThat(authenticationStatsList.size()).isEqualTo(2);
134         AuthenticationStats expectedStats2 = new AuthenticationStats(USER_ID_2,
135                 0 /* totalAttempts */, 0 /* rejectedAttempts */,
136                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
137         assertThat(authenticationStatsList).contains(stats1);
138         assertThat(authenticationStatsList).contains(expectedStats2);
139     }
140 
141     @Test
persistFrrStats_newUser_face_shouldSuccess()142     public void persistFrrStats_newUser_face_shouldSuccess() throws JSONException {
143         AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
144                 300 /* totalAttempts */, 10 /* rejectedAttempts */,
145                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
146 
147         mAuthenticationStatsPersister.persistFrrStats(authenticationStats.getUserId(),
148                 authenticationStats.getTotalAttempts(),
149                 authenticationStats.getRejectedAttempts(),
150                 authenticationStats.getEnrollmentNotifications(),
151                 authenticationStats.getModality());
152 
153         verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
154         assertThat(mStringSetArgumentCaptor.getValue())
155                 .contains(buildFrrStats(authenticationStats));
156     }
157 
158     @Test
persistFrrStats_newUser_fingerprint_shouldSuccess()159     public void persistFrrStats_newUser_fingerprint_shouldSuccess() throws JSONException {
160         AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
161                 300 /* totalAttempts */, 10 /* rejectedAttempts */,
162                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
163 
164         mAuthenticationStatsPersister.persistFrrStats(authenticationStats.getUserId(),
165                 authenticationStats.getTotalAttempts(),
166                 authenticationStats.getRejectedAttempts(),
167                 authenticationStats.getEnrollmentNotifications(),
168                 authenticationStats.getModality());
169 
170         verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
171         assertThat(mStringSetArgumentCaptor.getValue())
172                 .contains(buildFrrStats(authenticationStats));
173     }
174 
175     @Test
persistFrrStats_existingUser_shouldUpdateRecord()176     public void persistFrrStats_existingUser_shouldUpdateRecord() throws JSONException {
177         AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
178                 300 /* totalAttempts */, 10 /* rejectedAttempts */,
179                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
180         AuthenticationStats newAuthenticationStats = new AuthenticationStats(USER_ID_1,
181                 500 /* totalAttempts */, 30 /* rejectedAttempts */,
182                 1 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
183         when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
184                 Set.of(buildFrrStats(authenticationStats)));
185 
186         mAuthenticationStatsPersister.persistFrrStats(newAuthenticationStats.getUserId(),
187                 newAuthenticationStats.getTotalAttempts(),
188                 newAuthenticationStats.getRejectedAttempts(),
189                 newAuthenticationStats.getEnrollmentNotifications(),
190                 newAuthenticationStats.getModality());
191 
192         verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
193         assertThat(mStringSetArgumentCaptor.getValue())
194                 .contains(buildFrrStats(newAuthenticationStats));
195     }
196 
197     @Test
persistFrrStats_existingUserWithFingerprint_faceAuthenticate_shouldUpdateRecord()198     public void persistFrrStats_existingUserWithFingerprint_faceAuthenticate_shouldUpdateRecord()
199             throws JSONException {
200         // User with fingerprint authentication stats.
201         AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
202                 200 /* totalAttempts */, 20 /* rejectedAttempts */,
203                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
204         // The same user with face authentication stats.
205         AuthenticationStats newAuthenticationStats = new AuthenticationStats(USER_ID_1,
206                 500 /* totalAttempts */, 30 /* rejectedAttempts */,
207                 1 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
208         when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
209                 Set.of(buildFrrStats(authenticationStats)));
210 
211         mAuthenticationStatsPersister.persistFrrStats(newAuthenticationStats.getUserId(),
212                 newAuthenticationStats.getTotalAttempts(),
213                 newAuthenticationStats.getRejectedAttempts(),
214                 newAuthenticationStats.getEnrollmentNotifications(),
215                 newAuthenticationStats.getModality());
216 
217         String expectedFrrStats = new JSONObject(buildFrrStats(authenticationStats))
218                 .put(ENROLLMENT_NOTIFICATIONS, newAuthenticationStats.getEnrollmentNotifications())
219                 .put(FACE_ATTEMPTS, newAuthenticationStats.getTotalAttempts())
220                 .put(FACE_REJECTIONS, newAuthenticationStats.getRejectedAttempts()).toString();
221         verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
222         assertThat(mStringSetArgumentCaptor.getValue()).contains(expectedFrrStats);
223     }
224 
225     @Test
persistFrrStats_multiUser_newUser_shouldUpdateRecord()226     public void persistFrrStats_multiUser_newUser_shouldUpdateRecord() throws JSONException {
227         AuthenticationStats authenticationStats1 = new AuthenticationStats(USER_ID_1,
228                 300 /* totalAttempts */, 10 /* rejectedAttempts */,
229                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
230         AuthenticationStats authenticationStats2 = new AuthenticationStats(USER_ID_2,
231                 100 /* totalAttempts */, 5 /* rejectedAttempts */,
232                 1 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FINGERPRINT);
233 
234         // Sets up the shared preference with user 1 only.
235         when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
236                 Set.of(buildFrrStats(authenticationStats1)));
237 
238         // Add data for user 2.
239         mAuthenticationStatsPersister.persistFrrStats(authenticationStats2.getUserId(),
240                 authenticationStats2.getTotalAttempts(),
241                 authenticationStats2.getRejectedAttempts(),
242                 authenticationStats2.getEnrollmentNotifications(),
243                 authenticationStats2.getModality());
244 
245         verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
246         assertThat(mStringSetArgumentCaptor.getValue())
247                 .contains(buildFrrStats(authenticationStats2));
248     }
249 
250     @Test
removeFrrStats_existingUser_shouldUpdateRecord()251     public void removeFrrStats_existingUser_shouldUpdateRecord() throws JSONException {
252         AuthenticationStats authenticationStats = new AuthenticationStats(USER_ID_1,
253                 300 /* totalAttempts */, 10 /* rejectedAttempts */,
254                 0 /* enrollmentNotifications */, BiometricsProtoEnums.MODALITY_FACE);
255         when(mSharedPreferences.getStringSet(eq(KEY), anySet())).thenReturn(
256                 Set.of(buildFrrStats(authenticationStats)));
257 
258         mAuthenticationStatsPersister.removeFrrStats(USER_ID_1);
259 
260         verify(mEditor).putStringSet(eq(KEY), mStringSetArgumentCaptor.capture());
261         assertThat(mStringSetArgumentCaptor.getValue()).doesNotContain(authenticationStats);
262     }
263 
264     @Test
persistFrrThreshold_shouldUpdateRecord()265     public void persistFrrThreshold_shouldUpdateRecord() {
266         mAuthenticationStatsPersister.persistFrrThreshold(FRR_THRESHOLD);
267 
268         verify(mEditor).putFloat(eq(THRESHOLD_KEY), mFrrThresholdArgumentCaptor.capture());
269         assertThat(mFrrThresholdArgumentCaptor.getValue()).isWithin(0f).of(FRR_THRESHOLD);
270     }
271 
buildFrrStats(AuthenticationStats authenticationStats)272     private String buildFrrStats(AuthenticationStats authenticationStats)
273             throws JSONException {
274         if (authenticationStats.getModality() == BiometricsProtoEnums.MODALITY_FACE) {
275             return new JSONObject()
276                     .put(USER_ID, authenticationStats.getUserId())
277                     .put(FACE_ATTEMPTS, authenticationStats.getTotalAttempts())
278                     .put(FACE_REJECTIONS, authenticationStats.getRejectedAttempts())
279                     .put(ENROLLMENT_NOTIFICATIONS, authenticationStats.getEnrollmentNotifications())
280                     .toString();
281         } else if (authenticationStats.getModality() == BiometricsProtoEnums.MODALITY_FINGERPRINT) {
282             return new JSONObject()
283                     .put(USER_ID, authenticationStats.getUserId())
284                     .put(FINGERPRINT_ATTEMPTS, authenticationStats.getTotalAttempts())
285                     .put(FINGERPRINT_REJECTIONS, authenticationStats.getRejectedAttempts())
286                     .put(ENROLLMENT_NOTIFICATIONS, authenticationStats.getEnrollmentNotifications())
287                     .toString();
288         }
289         return "";
290     }
291 }
292