Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit b81f76ab authored by Wenhui Yang's avatar Wenhui Yang Committed by Android (Google) Code Review
Browse files

Merge "[2/n] FRR data persistence" into udc-qpr-dev

parents 657e69c9 53bc9fa6
Loading
Loading
Loading
Loading
+31 −1
Original line number Diff line number Diff line
@@ -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();
    }
}
+25 −2
Original line number Diff line number Diff line
@@ -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());
        }
    }

+192 −0
Original line number Diff line number Diff line
/*
 * 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;
    }
}
+27 −12
Original line number Diff line number Diff line
@@ -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);
    }
}
+233 −0

File added.

Preview size limit exceeded, changes collapsed.