summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author mrulhania <mrulhania@google.com> 2024-11-11 17:28:53 -0800
committer Manjeet Rulhania <mrulhania@google.com> 2025-01-07 08:14:34 -0800
commitf45695019779dd8df42f6c6ea6198129e1949765 (patch)
tree45c0056f9f7a172115d359e31fd39bc5735ec48b
parent658c3fe6a492eaba71b1e5f2607c635b0311b083 (diff)
Write app ops access data into sqlite
App op access data is primarily an audit data and is written only once, the data is never updated. however, we keep truncating the old data. The current architecture of discrete & historical registry is pretty complex and the xml as persistence layer has caused deeply nested classes. With Sqlite we are trying to make app ops audit data more debugable, robust and simplifying the code. Bug: 377584611 Test: atest DiscreteAppOpSqlPersistenceTest DiscreteAppOpXmlPersistenceTest FLAG: android.permission.flags.enable_sqlite_appops_accesses Change-Id: Id63fa8f0c15bc33c90c5411b6594b6d593d9d76c
-rw-r--r--services/core/java/com/android/server/appop/AppOpsService.java4
-rw-r--r--services/core/java/com/android/server/appop/DiscreteOpsDbHelper.java387
-rw-r--r--services/core/java/com/android/server/appop/DiscreteOpsMigrationHelper.java106
-rw-r--r--services/core/java/com/android/server/appop/DiscreteOpsRegistry.java30
-rw-r--r--services/core/java/com/android/server/appop/DiscreteOpsSqlRegistry.java689
-rw-r--r--services/core/java/com/android/server/appop/DiscreteOpsTable.java128
-rw-r--r--services/core/java/com/android/server/appop/DiscreteOpsTestingShim.java220
-rw-r--r--services/core/java/com/android/server/appop/DiscreteOpsXmlRegistry.java50
-rw-r--r--services/core/java/com/android/server/appop/HistoricalRegistry.java29
-rw-r--r--services/core/java/com/android/server/pm/permission/PermissionManagerService.java2
-rw-r--r--services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpSqlPersistenceTest.java309
-rw-r--r--services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpXmlPersistenceTest.java (renamed from services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpPersistenceTest.java)8
-rw-r--r--services/tests/servicestests/src/com/android/server/appop/DiscreteOpsMigrationAndRollbackTest.java168
13 files changed, 2100 insertions, 30 deletions
diff --git a/services/core/java/com/android/server/appop/AppOpsService.java b/services/core/java/com/android/server/appop/AppOpsService.java
index 295e0443371d..8a63f9a24ea3 100644
--- a/services/core/java/com/android/server/appop/AppOpsService.java
+++ b/services/core/java/com/android/server/appop/AppOpsService.java
@@ -359,7 +359,7 @@ public class AppOpsService extends IAppOpsService.Stub {
private static final Duration RATE_LIMITER_WINDOW = Duration.ofMillis(10);
private final RateLimiter mRateLimiter = new RateLimiter(RATE_LIMITER_WINDOW);
- volatile @NonNull HistoricalRegistry mHistoricalRegistry = new HistoricalRegistry(this);
+ volatile @NonNull HistoricalRegistry mHistoricalRegistry;
/*
* These are app op restrictions imposed per user from various parties.
@@ -1039,6 +1039,8 @@ public class AppOpsService extends IAppOpsService.Stub {
// will not exist and the nonce will be UNSET.
AppOpsManager.invalidateAppOpModeCache();
AppOpsManager.disableAppOpModeCache();
+
+ mHistoricalRegistry = new HistoricalRegistry(this, context);
}
public void publish() {
diff --git a/services/core/java/com/android/server/appop/DiscreteOpsDbHelper.java b/services/core/java/com/android/server/appop/DiscreteOpsDbHelper.java
new file mode 100644
index 000000000000..e4c36cc214e8
--- /dev/null
+++ b/services/core/java/com/android/server/appop/DiscreteOpsDbHelper.java
@@ -0,0 +1,387 @@
+/*
+ * Copyright (C) 2024 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.appop;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.content.Context;
+import android.database.DatabaseErrorHandler;
+import android.database.DefaultDatabaseErrorHandler;
+import android.database.sqlite.SQLiteDatabase;
+import android.database.sqlite.SQLiteException;
+import android.database.sqlite.SQLiteOpenHelper;
+import android.database.sqlite.SQLiteRawStatement;
+import android.os.Environment;
+import android.util.IntArray;
+import android.util.Slog;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+class DiscreteOpsDbHelper extends SQLiteOpenHelper {
+ private static final String LOG_TAG = "DiscreteOpsDbHelper";
+ static final String DATABASE_NAME = "app_op_history.db";
+ private static final int DATABASE_VERSION = 1;
+ private static final boolean DEBUG = false;
+
+ DiscreteOpsDbHelper(@NonNull Context context, @NonNull File databaseFile) {
+ super(context, databaseFile.getAbsolutePath(), null, DATABASE_VERSION,
+ new DiscreteOpsDatabaseErrorHandler());
+ setOpenParams(getDatabaseOpenParams());
+ }
+
+ private static SQLiteDatabase.OpenParams getDatabaseOpenParams() {
+ return new SQLiteDatabase.OpenParams.Builder()
+ .addOpenFlags(SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
+ .build();
+ }
+
+ @NonNull
+ static File getDatabaseFile() {
+ return new File(new File(Environment.getDataSystemDirectory(), "appops"), DATABASE_NAME);
+ }
+
+ @Override
+ public void onConfigure(SQLiteDatabase db) {
+ db.execSQL("PRAGMA synchronous = NORMAL");
+ }
+
+ @Override
+ public void onCreate(SQLiteDatabase db) {
+ db.execSQL(DiscreteOpsTable.CREATE_TABLE_SQL);
+ db.execSQL(DiscreteOpsTable.CREATE_INDEX_SQL);
+ }
+
+ @Override
+ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
+ }
+
+ void insertDiscreteOps(@NonNull List<DiscreteOpsSqlRegistry.DiscreteOp> opEvents) {
+ if (opEvents.isEmpty()) {
+ return;
+ }
+
+ SQLiteDatabase db = getWritableDatabase();
+ // TODO (b/383157289) what if database is busy and can't start a transaction? will read
+ // more about it and can be done in a follow up cl.
+ db.beginTransaction();
+ try (SQLiteRawStatement statement = db.createRawStatement(
+ DiscreteOpsTable.INSERT_TABLE_SQL)) {
+ for (DiscreteOpsSqlRegistry.DiscreteOp event : opEvents) {
+ try {
+ statement.bindInt(DiscreteOpsTable.UID_INDEX, event.getUid());
+ bindTextOrNull(statement, DiscreteOpsTable.PACKAGE_NAME_INDEX,
+ event.getPackageName());
+ bindTextOrNull(statement, DiscreteOpsTable.DEVICE_ID_INDEX,
+ event.getDeviceId());
+ statement.bindInt(DiscreteOpsTable.OP_CODE_INDEX, event.getOpCode());
+ bindTextOrNull(statement, DiscreteOpsTable.ATTRIBUTION_TAG_INDEX,
+ event.getAttributionTag());
+ statement.bindLong(DiscreteOpsTable.ACCESS_TIME_INDEX, event.getAccessTime());
+ statement.bindLong(
+ DiscreteOpsTable.ACCESS_DURATION_INDEX, event.getDuration());
+ statement.bindInt(DiscreteOpsTable.UID_STATE_INDEX, event.getUidState());
+ statement.bindInt(DiscreteOpsTable.OP_FLAGS_INDEX, event.getOpFlags());
+ statement.bindInt(DiscreteOpsTable.ATTRIBUTION_FLAGS_INDEX,
+ event.getAttributionFlags());
+ statement.bindLong(DiscreteOpsTable.CHAIN_ID_INDEX, event.getChainId());
+ statement.step();
+ } catch (Exception exception) {
+ Slog.e(LOG_TAG, "Error inserting the discrete op: " + event, exception);
+ } finally {
+ statement.reset();
+ }
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ }
+
+ private void bindTextOrNull(SQLiteRawStatement statement, int index, @Nullable String text) {
+ if (text == null) {
+ statement.bindNull(index);
+ } else {
+ statement.bindText(index, text);
+ }
+ }
+
+ /**
+ * This will be used as an offset for inserting new chain id in discrete ops table.
+ */
+ long getLargestAttributionChainId() {
+ long chainId = 0;
+ try {
+ SQLiteDatabase db = getReadableDatabase();
+ db.beginTransactionReadOnly();
+ try (SQLiteRawStatement statement =
+ db.createRawStatement(DiscreteOpsTable.SELECT_MAX_ATTRIBUTION_CHAIN_ID)) {
+ if (statement.step()) {
+ chainId = statement.getColumnLong(0);
+ if (chainId < 0) {
+ chainId = 0;
+ }
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ } catch (SQLiteException exception) {
+ Slog.e(LOG_TAG, "Error reading attribution chain id", exception);
+ }
+ return chainId;
+ }
+
+ void execSQL(@NonNull String sql) {
+ execSQL(sql, null);
+ }
+
+ void execSQL(@NonNull String sql, Object[] bindArgs) {
+ if (DEBUG) {
+ Slog.i(LOG_TAG, "DB execSQL, sql: " + sql);
+ }
+ SQLiteDatabase db = getWritableDatabase();
+ if (bindArgs == null) {
+ db.execSQL(sql);
+ } else {
+ db.execSQL(sql, bindArgs);
+ }
+ }
+
+ /**
+ * Returns a list of {@link DiscreteOpsSqlRegistry.DiscreteOp} based on the given filters.
+ */
+ List<DiscreteOpsSqlRegistry.DiscreteOp> getDiscreteOps(
+ @AppOpsManager.HistoricalOpsRequestFilter int requestFilters,
+ int uidFilter, @Nullable String packageNameFilter,
+ @Nullable String attributionTagFilter, IntArray opCodesFilter, int opFlagsFilter,
+ long beginTime, long endTime, int limit, String orderByColumn) {
+ List<SQLCondition> conditions = prepareConditions(beginTime, endTime, requestFilters,
+ uidFilter, packageNameFilter,
+ attributionTagFilter, opCodesFilter, opFlagsFilter);
+ String sql = buildSql(conditions, orderByColumn, limit);
+
+ SQLiteDatabase db = getReadableDatabase();
+ List<DiscreteOpsSqlRegistry.DiscreteOp> results = new ArrayList<>();
+ db.beginTransactionReadOnly();
+ try (SQLiteRawStatement statement = db.createRawStatement(sql)) {
+ int size = conditions.size();
+ for (int i = 0; i < size; i++) {
+ SQLCondition condition = conditions.get(i);
+ if (DEBUG) {
+ Slog.i(LOG_TAG, condition + ", binding value = " + condition.mFilterValue);
+ }
+ switch (condition.mColumnFilter) {
+ case PACKAGE_NAME, ATTR_TAG -> statement.bindText(i + 1,
+ condition.mFilterValue.toString());
+ case UID, OP_CODE_EQUAL, OP_FLAGS -> statement.bindInt(i + 1,
+ Integer.parseInt(condition.mFilterValue.toString()));
+ case BEGIN_TIME, END_TIME -> statement.bindLong(i + 1,
+ Long.parseLong(condition.mFilterValue.toString()));
+ case OP_CODE_IN -> Slog.d(LOG_TAG, "No binding for In operator");
+ default -> Slog.w(LOG_TAG, "unknown sql condition " + condition);
+ }
+ }
+
+ while (statement.step()) {
+ int uid = statement.getColumnInt(0);
+ String packageName = statement.getColumnText(1);
+ String deviceId = statement.getColumnText(2);
+ int opCode = statement.getColumnInt(3);
+ String attributionTag = statement.getColumnText(4);
+ long accessTime = statement.getColumnLong(5);
+ long duration = statement.getColumnLong(6);
+ int uidState = statement.getColumnInt(7);
+ int opFlags = statement.getColumnInt(8);
+ int attributionFlags = statement.getColumnInt(9);
+ long chainId = statement.getColumnLong(10);
+ DiscreteOpsSqlRegistry.DiscreteOp event = new DiscreteOpsSqlRegistry.DiscreteOp(uid,
+ packageName, attributionTag, deviceId, opCode,
+ opFlags, attributionFlags, uidState, chainId, accessTime, duration);
+ results.add(event);
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ return results;
+ }
+
+ private String buildSql(List<SQLCondition> conditions, String orderByColumn, int limit) {
+ StringBuilder sql = new StringBuilder(DiscreteOpsTable.SELECT_TABLE_DATA);
+ if (!conditions.isEmpty()) {
+ sql.append(" WHERE ");
+ int size = conditions.size();
+ for (int i = 0; i < size; i++) {
+ sql.append(conditions.get(i).toString());
+ if (i < size - 1) {
+ sql.append(" AND ");
+ }
+ }
+ }
+
+ if (orderByColumn != null) {
+ sql.append(" ORDER BY ").append(orderByColumn);
+ }
+ if (limit > 0) {
+ sql.append(" LIMIT ").append(limit);
+ }
+ if (DEBUG) {
+ Slog.i(LOG_TAG, "Sql query " + sql);
+ }
+ return sql.toString();
+ }
+
+ /**
+ * Creates where conditions for package, uid, attribution tag and app op codes,
+ * app op codes condition does not support argument binding.
+ */
+ private List<SQLCondition> prepareConditions(long beginTime, long endTime, int requestFilters,
+ int uid, @Nullable String packageName, @Nullable String attributionTag,
+ IntArray opCodes, int opFlags) {
+ final List<SQLCondition> conditions = new ArrayList<>();
+
+ if (beginTime != -1) {
+ conditions.add(new SQLCondition(ColumnFilter.BEGIN_TIME, beginTime));
+ }
+ if (endTime != -1) {
+ conditions.add(new SQLCondition(ColumnFilter.END_TIME, endTime));
+ }
+ if (opFlags != 0) {
+ conditions.add(new SQLCondition(ColumnFilter.OP_FLAGS, opFlags));
+ }
+
+ if (requestFilters != 0) {
+ if ((requestFilters & AppOpsManager.FILTER_BY_PACKAGE_NAME) != 0) {
+ conditions.add(new SQLCondition(ColumnFilter.PACKAGE_NAME, packageName));
+ }
+ if ((requestFilters & AppOpsManager.FILTER_BY_UID) != 0) {
+ conditions.add(new SQLCondition(ColumnFilter.UID, uid));
+
+ }
+ if ((requestFilters & AppOpsManager.FILTER_BY_ATTRIBUTION_TAG) != 0) {
+ conditions.add(new SQLCondition(ColumnFilter.ATTR_TAG, attributionTag));
+ }
+ // filter op codes
+ if (opCodes != null && opCodes.size() == 1) {
+ conditions.add(new SQLCondition(ColumnFilter.OP_CODE_EQUAL, opCodes.get(0)));
+ } else if (opCodes != null && opCodes.size() > 1) {
+ StringBuilder b = new StringBuilder();
+ int size = opCodes.size();
+ for (int i = 0; i < size; i++) {
+ b.append(opCodes.get(i));
+ if (i < size - 1) {
+ b.append(", ");
+ }
+ }
+ conditions.add(new SQLCondition(ColumnFilter.OP_CODE_IN, b.toString()));
+ }
+ }
+ return conditions;
+ }
+
+ /**
+ * This class prepares a where clause condition for discrete ops table column.
+ */
+ static final class SQLCondition {
+ private final ColumnFilter mColumnFilter;
+ private final Object mFilterValue;
+
+ SQLCondition(ColumnFilter columnFilter, Object filterValue) {
+ mColumnFilter = columnFilter;
+ mFilterValue = filterValue;
+ }
+
+ @Override
+ public String toString() {
+ if (mColumnFilter == ColumnFilter.OP_CODE_IN) {
+ return mColumnFilter + " ( " + mFilterValue + " )";
+ }
+ return mColumnFilter.toString();
+ }
+ }
+
+ /**
+ * This enum describes the where clause conditions for different columns in discrete ops
+ * table.
+ */
+ private enum ColumnFilter {
+ PACKAGE_NAME(DiscreteOpsTable.Columns.PACKAGE_NAME + " = ? "),
+ UID(DiscreteOpsTable.Columns.UID + " = ? "),
+ ATTR_TAG(DiscreteOpsTable.Columns.ATTRIBUTION_TAG + " = ? "),
+ END_TIME(DiscreteOpsTable.Columns.ACCESS_TIME + " < ? "),
+ OP_CODE_EQUAL(DiscreteOpsTable.Columns.OP_CODE + " = ? "),
+ BEGIN_TIME(DiscreteOpsTable.Columns.ACCESS_TIME + " + "
+ + DiscreteOpsTable.Columns.ACCESS_DURATION + " > ? "),
+ OP_FLAGS("(" + DiscreteOpsTable.Columns.OP_FLAGS + " & ? ) != 0"),
+ OP_CODE_IN(DiscreteOpsTable.Columns.OP_CODE + " IN ");
+
+ final String mCondition;
+
+ ColumnFilter(String condition) {
+ mCondition = condition;
+ }
+
+ @Override
+ public String toString() {
+ return mCondition;
+ }
+ }
+
+ static final class DiscreteOpsDatabaseErrorHandler implements DatabaseErrorHandler {
+ private final DefaultDatabaseErrorHandler mDefaultDatabaseErrorHandler =
+ new DefaultDatabaseErrorHandler();
+
+ @Override
+ public void onCorruption(SQLiteDatabase dbObj) {
+ Slog.e(LOG_TAG, "discrete ops database got corrupted.");
+ mDefaultDatabaseErrorHandler.onCorruption(dbObj);
+ }
+ }
+
+ // USED for testing only
+ List<DiscreteOpsSqlRegistry.DiscreteOp> getAllDiscreteOps(@NonNull String sql) {
+ SQLiteDatabase db = getReadableDatabase();
+ List<DiscreteOpsSqlRegistry.DiscreteOp> results = new ArrayList<>();
+ db.beginTransactionReadOnly();
+ try (SQLiteRawStatement statement = db.createRawStatement(sql)) {
+ while (statement.step()) {
+ int uid = statement.getColumnInt(0);
+ String packageName = statement.getColumnText(1);
+ String deviceId = statement.getColumnText(2);
+ int opCode = statement.getColumnInt(3);
+ String attributionTag = statement.getColumnText(4);
+ long accessTime = statement.getColumnLong(5);
+ long duration = statement.getColumnLong(6);
+ int uidState = statement.getColumnInt(7);
+ int opFlags = statement.getColumnInt(8);
+ int attributionFlags = statement.getColumnInt(9);
+ long chainId = statement.getColumnLong(10);
+ DiscreteOpsSqlRegistry.DiscreteOp event = new DiscreteOpsSqlRegistry.DiscreteOp(uid,
+ packageName, attributionTag, deviceId, opCode,
+ opFlags, attributionFlags, uidState, chainId, accessTime, duration);
+ results.add(event);
+ }
+ db.setTransactionSuccessful();
+ } finally {
+ db.endTransaction();
+ }
+ return results;
+ }
+}
diff --git a/services/core/java/com/android/server/appop/DiscreteOpsMigrationHelper.java b/services/core/java/com/android/server/appop/DiscreteOpsMigrationHelper.java
new file mode 100644
index 000000000000..c38ee55b4f42
--- /dev/null
+++ b/services/core/java/com/android/server/appop/DiscreteOpsMigrationHelper.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2024 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.appop;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Helper class for migrating discrete ops from xml to sqlite
+ */
+public class DiscreteOpsMigrationHelper {
+ /**
+ * migrate discrete ops from xml to sqlite.
+ */
+ static void migrateDiscreteOpsToSqlite(DiscreteOpsXmlRegistry xmlRegistry,
+ DiscreteOpsSqlRegistry sqlRegistry) {
+ DiscreteOpsXmlRegistry.DiscreteOps xmlOps = xmlRegistry.getAllDiscreteOps();
+ List<DiscreteOpsSqlRegistry.DiscreteOp> discreteOps = getSqlDiscreteOps(xmlOps);
+ sqlRegistry.migrateXmlData(discreteOps, xmlOps.mChainIdOffset);
+ xmlRegistry.deleteDiscreteOpsDir();
+ }
+
+ /**
+ * rollback discrete ops from sqlite to xml.
+ */
+ static void migrateDiscreteOpsToXml(DiscreteOpsSqlRegistry sqlRegistry,
+ DiscreteOpsXmlRegistry xmlRegistry) {
+ List<DiscreteOpsSqlRegistry.DiscreteOp> sqlOps = sqlRegistry.getAllDiscreteOps();
+ DiscreteOpsXmlRegistry.DiscreteOps xmlOps = getXmlDiscreteOps(sqlOps);
+ xmlRegistry.migrateSqliteData(xmlOps);
+ sqlRegistry.deleteDatabase();
+ }
+
+ /**
+ * Convert sqlite flat rows to hierarchical data.
+ */
+ private static DiscreteOpsXmlRegistry.DiscreteOps getXmlDiscreteOps(
+ List<DiscreteOpsSqlRegistry.DiscreteOp> discreteOps) {
+ DiscreteOpsXmlRegistry.DiscreteOps xmlOps =
+ new DiscreteOpsXmlRegistry.DiscreteOps(0);
+ if (discreteOps.isEmpty()) {
+ return xmlOps;
+ }
+
+ for (DiscreteOpsSqlRegistry.DiscreteOp discreteOp : discreteOps) {
+ xmlOps.addDiscreteAccess(discreteOp.getOpCode(), discreteOp.getUid(),
+ discreteOp.getPackageName(), discreteOp.getDeviceId(),
+ discreteOp.getAttributionTag(), discreteOp.getOpFlags(),
+ discreteOp.getUidState(),
+ discreteOp.getAccessTime(), discreteOp.getDuration(),
+ discreteOp.getAttributionFlags(), (int) discreteOp.getChainId());
+ }
+ return xmlOps;
+ }
+
+ /**
+ * Convert xml (hierarchical) data to flat row based data.
+ */
+ private static List<DiscreteOpsSqlRegistry.DiscreteOp> getSqlDiscreteOps(
+ DiscreteOpsXmlRegistry.DiscreteOps discreteOps) {
+ List<DiscreteOpsSqlRegistry.DiscreteOp> opEvents = new ArrayList<>();
+
+ if (discreteOps.isEmpty()) {
+ return opEvents;
+ }
+
+ discreteOps.mUids.forEach((uid, discreteUidOps) -> {
+ discreteUidOps.mPackages.forEach((packageName, packageOps) -> {
+ packageOps.mPackageOps.forEach((opcode, ops) -> {
+ ops.mDeviceAttributedOps.forEach((deviceId, deviceOps) -> {
+ deviceOps.mAttributedOps.forEach((tag, attributedOps) -> {
+ for (DiscreteOpsXmlRegistry.DiscreteOpEvent attributedOp :
+ attributedOps) {
+ DiscreteOpsSqlRegistry.DiscreteOp
+ opModel = new DiscreteOpsSqlRegistry.DiscreteOp(uid,
+ packageName, tag,
+ deviceId, opcode, attributedOp.mOpFlag,
+ attributedOp.mAttributionFlags,
+ attributedOp.mUidState, attributedOp.mAttributionChainId,
+ attributedOp.mNoteTime,
+ attributedOp.mNoteDuration);
+ opEvents.add(opModel);
+ }
+ });
+ });
+ });
+ });
+ });
+
+ return opEvents;
+ }
+}
diff --git a/services/core/java/com/android/server/appop/DiscreteOpsRegistry.java b/services/core/java/com/android/server/appop/DiscreteOpsRegistry.java
index 66b593ad5825..88b3f6dce4c2 100644
--- a/services/core/java/com/android/server/appop/DiscreteOpsRegistry.java
+++ b/services/core/java/com/android/server/appop/DiscreteOpsRegistry.java
@@ -49,6 +49,7 @@ import android.annotation.Nullable;
import android.app.AppOpsManager;
import android.os.AsyncTask;
import android.os.Build;
+import android.permission.flags.Flags;
import android.provider.DeviceConfig;
import android.util.Slog;
@@ -91,6 +92,7 @@ import java.util.Set;
abstract class DiscreteOpsRegistry {
private static final String TAG = DiscreteOpsRegistry.class.getSimpleName();
+ static final boolean DEBUG_LOG = false;
static final String PROPERTY_DISCRETE_HISTORY_CUTOFF = "discrete_history_cutoff_millis";
static final String PROPERTY_DISCRETE_HISTORY_QUANTIZATION =
"discrete_history_quantization_millis";
@@ -166,8 +168,10 @@ abstract class DiscreteOpsRegistry {
/**
* A periodic callback from {@link AppOpsService} to flush the in memory events to disk.
* The shutdown callback is also plugged into it.
+ * <p>
+ * This method flushes in memory records to disk, and also clears old records from disk.
*/
- abstract void writeAndClearAccessHistory();
+ abstract void writeAndClearOldAccessHistory();
/** Remove all discrete op events. */
abstract void clearHistory();
@@ -267,4 +271,28 @@ abstract class DiscreteOpsRegistry {
}
return result;
}
+
+ /**
+ * Whether app op access tacking is enabled and a metric event should be logged.
+ */
+ static boolean shouldLogAccess(int op) {
+ return Flags.appopAccessTrackingLoggingEnabled()
+ && ArrayUtils.contains(sDiscreteOpsToLog, op);
+ }
+
+ String getAttributionTag(String attributionTag, String packageName) {
+ if (attributionTag == null || packageName == null) {
+ return attributionTag;
+ }
+ int firstChar = 0;
+ if (attributionTag.startsWith(packageName)) {
+ firstChar = packageName.length();
+ if (firstChar < attributionTag.length() && attributionTag.charAt(firstChar)
+ == '.') {
+ firstChar++;
+ }
+ }
+ return attributionTag.substring(firstChar);
+ }
+
}
diff --git a/services/core/java/com/android/server/appop/DiscreteOpsSqlRegistry.java b/services/core/java/com/android/server/appop/DiscreteOpsSqlRegistry.java
new file mode 100644
index 000000000000..4b3981cd4bc0
--- /dev/null
+++ b/services/core/java/com/android/server/appop/DiscreteOpsSqlRegistry.java
@@ -0,0 +1,689 @@
+/*
+ * Copyright (C) 2024 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.appop;
+
+import static android.app.AppOpsManager.ATTRIBUTION_CHAIN_ID_NONE;
+import static android.app.AppOpsManager.ATTRIBUTION_FLAG_ACCESSOR;
+import static android.app.AppOpsManager.ATTRIBUTION_FLAG_RECEIVER;
+import static android.app.AppOpsManager.ATTRIBUTION_FLAG_TRUSTED;
+import static android.app.AppOpsManager.flagsToString;
+import static android.app.AppOpsManager.getUidStateName;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.content.Context;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Process;
+import android.util.ArraySet;
+import android.util.IntArray;
+import android.util.LongSparseArray;
+import android.util.Slog;
+
+import com.android.internal.util.FrameworkStatsLog;
+import com.android.server.ServiceThread;
+
+import java.io.File;
+import java.io.PrintWriter;
+import java.text.SimpleDateFormat;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * This class handles sqlite persistence layer for discrete ops.
+ */
+public class DiscreteOpsSqlRegistry extends DiscreteOpsRegistry {
+ private static final String TAG = "DiscreteOpsSqlRegistry";
+
+ private final Context mContext;
+ private final DiscreteOpsDbHelper mDiscreteOpsDbHelper;
+ private final SqliteWriteHandler mSqliteWriteHandler;
+ private final DiscreteOpCache mDiscreteOpCache = new DiscreteOpCache(512);
+ private static final long THREE_HOURS = Duration.ofHours(3).toMillis();
+ private static final int WRITE_CACHE_EVICTED_OP_EVENTS = 1;
+ private static final int DELETE_OLD_OP_EVENTS = 2;
+ // Attribution chain id is used to identify an attribution source chain, This is
+ // set for startOp only. PermissionManagerService resets this ID on device restart, so
+ // we use previously persisted chain id as offset, and add it to chain id received from
+ // permission manager service.
+ private long mChainIdOffset;
+ private final File mDatabaseFile;
+
+ DiscreteOpsSqlRegistry(Context context) {
+ this(context, DiscreteOpsDbHelper.getDatabaseFile());
+ }
+
+ DiscreteOpsSqlRegistry(Context context, File databaseFile) {
+ ServiceThread thread = new ServiceThread(TAG, Process.THREAD_PRIORITY_BACKGROUND, true);
+ thread.start();
+ mContext = context;
+ mDatabaseFile = databaseFile;
+ mSqliteWriteHandler = new SqliteWriteHandler(thread.getLooper());
+ mDiscreteOpsDbHelper = new DiscreteOpsDbHelper(context, databaseFile);
+ mChainIdOffset = mDiscreteOpsDbHelper.getLargestAttributionChainId();
+ }
+
+ @Override
+ void recordDiscreteAccess(int uid, String packageName,
+ @NonNull String deviceId, int op,
+ @Nullable String attributionTag, int flags, int uidState,
+ long accessTime, long accessDuration, int attributionFlags, int attributionChainId,
+ int accessType) {
+ if (shouldLogAccess(op)) {
+ FrameworkStatsLog.write(FrameworkStatsLog.APP_OP_ACCESS_TRACKED, uid, op, accessType,
+ uidState, flags, attributionFlags,
+ getAttributionTag(attributionTag, packageName),
+ attributionChainId);
+ }
+
+ if (!isDiscreteOp(op, flags)) {
+ return;
+ }
+
+ long offsetChainId = attributionChainId;
+ if (attributionChainId != ATTRIBUTION_CHAIN_ID_NONE) {
+ offsetChainId = attributionChainId + mChainIdOffset;
+ // PermissionManagerService chain id reached the max value,
+ // reset offset, it's going to be very rare.
+ if (attributionChainId == Integer.MAX_VALUE) {
+ mChainIdOffset = offsetChainId;
+ }
+ }
+ DiscreteOp discreteOpEvent = new DiscreteOp(uid, packageName, attributionTag, deviceId, op,
+ flags, attributionFlags, uidState, offsetChainId, accessTime, accessDuration);
+ mDiscreteOpCache.add(discreteOpEvent);
+ }
+
+ @Override
+ void writeAndClearOldAccessHistory() {
+ // Let the sql impl also follow the same disk write frequencies as xml,
+ // controlled by AppOpsService.
+ mDiscreteOpsDbHelper.insertDiscreteOps(mDiscreteOpCache.getAllEventsAndClear());
+ if (!mSqliteWriteHandler.hasMessages(DELETE_OLD_OP_EVENTS)) {
+ if (mSqliteWriteHandler.sendEmptyMessageDelayed(DELETE_OLD_OP_EVENTS, THREE_HOURS)) {
+ Slog.w(TAG, "DELETE_OLD_OP_EVENTS is not queued");
+ }
+ }
+ }
+
+ @Override
+ void clearHistory() {
+ mDiscreteOpCache.clear();
+ mDiscreteOpsDbHelper.execSQL(DiscreteOpsTable.DELETE_TABLE_DATA);
+ }
+
+ @Override
+ void clearHistory(int uid, String packageName) {
+ mDiscreteOpCache.clear(uid, packageName);
+ mDiscreteOpsDbHelper.execSQL(DiscreteOpsTable.DELETE_DATA_FOR_UID_PACKAGE,
+ new Object[]{uid, packageName});
+ }
+
+ @Override
+ void offsetHistory(long offset) {
+ mDiscreteOpCache.offsetTimestamp(offset);
+ mDiscreteOpsDbHelper.execSQL(DiscreteOpsTable.OFFSET_ACCESS_TIME,
+ new Object[]{offset});
+ }
+
+ private IntArray getAppOpCodes(@AppOpsManager.HistoricalOpsRequestFilter int filter,
+ @Nullable String[] opNamesFilter) {
+ if ((filter & AppOpsManager.FILTER_BY_OP_NAMES) != 0) {
+ IntArray opCodes = new IntArray(opNamesFilter.length);
+ for (int i = 0; i < opNamesFilter.length; i++) {
+ int op;
+ try {
+ op = AppOpsManager.strOpToOp(opNamesFilter[i]);
+ } catch (IllegalArgumentException ex) {
+ Slog.w(TAG, "Appop `" + opNamesFilter[i] + "` is not recognized.");
+ continue;
+ }
+ opCodes.add(op);
+ }
+ return opCodes;
+ }
+ return null;
+ }
+
+ @Override
+ void addFilteredDiscreteOpsToHistoricalOps(AppOpsManager.HistoricalOps result,
+ long beginTimeMillis, long endTimeMillis, int filter, int uidFilter,
+ @Nullable String packageNameFilter,
+ @Nullable String[] opNamesFilter,
+ @Nullable String attributionTagFilter, int opFlagsFilter,
+ Set<String> attributionExemptPkgs) {
+ // flush the cache into database before read.
+ writeAndClearOldAccessHistory();
+ boolean assembleChains = attributionExemptPkgs != null;
+ IntArray opCodes = getAppOpCodes(filter, opNamesFilter);
+ List<DiscreteOp> discreteOps = mDiscreteOpsDbHelper.getDiscreteOps(filter, uidFilter,
+ packageNameFilter, attributionTagFilter, opCodes, opFlagsFilter, beginTimeMillis,
+ endTimeMillis, -1, null);
+
+ LongSparseArray<AttributionChain> attributionChains = null;
+ if (assembleChains) {
+ attributionChains = createAttributionChains(discreteOps, attributionExemptPkgs);
+ }
+
+ int nEvents = discreteOps.size();
+ for (int j = 0; j < nEvents; j++) {
+ DiscreteOp event = discreteOps.get(j);
+ AppOpsManager.OpEventProxyInfo proxy = null;
+ if (assembleChains && event.mChainId != ATTRIBUTION_CHAIN_ID_NONE) {
+ AttributionChain chain = attributionChains.get(event.mChainId);
+ if (chain != null && chain.isComplete()
+ && chain.isStart(event)
+ && chain.mLastVisibleEvent != null) {
+ DiscreteOp proxyEvent = chain.mLastVisibleEvent;
+ proxy = new AppOpsManager.OpEventProxyInfo(proxyEvent.mUid,
+ proxyEvent.mPackageName, proxyEvent.mAttributionTag);
+ }
+ }
+ result.addDiscreteAccess(event.mOpCode, event.mUid, event.mPackageName,
+ event.mAttributionTag, event.mUidState, event.mOpFlags,
+ event.mDiscretizedAccessTime, event.mDiscretizedDuration, proxy);
+ }
+ }
+
+ @Override
+ void dump(@NonNull PrintWriter pw, int uidFilter, @Nullable String packageNameFilter,
+ @Nullable String attributionTagFilter,
+ @AppOpsManager.HistoricalOpsRequestFilter int filter, int dumpOp,
+ @NonNull SimpleDateFormat sdf, @NonNull Date date, @NonNull String prefix,
+ int nDiscreteOps) {
+ writeAndClearOldAccessHistory();
+ IntArray opCodes = new IntArray();
+ if (dumpOp != AppOpsManager.OP_NONE) {
+ opCodes.add(dumpOp);
+ }
+ List<DiscreteOp> discreteOps = mDiscreteOpsDbHelper.getDiscreteOps(filter, uidFilter,
+ packageNameFilter, attributionTagFilter, opCodes, 0, -1,
+ -1, nDiscreteOps, DiscreteOpsTable.Columns.ACCESS_TIME);
+
+ pw.print(prefix);
+ pw.print("Largest chain id: ");
+ pw.print(mDiscreteOpsDbHelper.getLargestAttributionChainId());
+ pw.println();
+ pw.println("UID|PACKAGE_NAME|DEVICE_ID|OP_NAME|ATTRIBUTION_TAG|UID_STATE|OP_FLAGS|"
+ + "ATTR_FLAGS|CHAIN_ID|ACCESS_TIME|DURATION");
+ int discreteOpsCount = discreteOps.size();
+ for (int i = 0; i < discreteOpsCount; i++) {
+ DiscreteOp event = discreteOps.get(i);
+ date.setTime(event.mAccessTime);
+ pw.println(event.mUid + "|" + event.mPackageName + "|" + event.mDeviceId + "|"
+ + AppOpsManager.opToName(event.mOpCode) + "|" + event.mAttributionTag + "|"
+ + getUidStateName(event.mUidState) + "|"
+ + flagsToString(event.mOpFlags) + "|" + event.mAttributionFlags + "|"
+ + event.mChainId + "|"
+ + sdf.format(date) + "|" + event.mDuration);
+ }
+ pw.println();
+ }
+
+ void migrateXmlData(List<DiscreteOp> opEvents, int chainIdOffset) {
+ mChainIdOffset = chainIdOffset;
+ mDiscreteOpsDbHelper.insertDiscreteOps(opEvents);
+ }
+
+ LongSparseArray<AttributionChain> createAttributionChains(
+ List<DiscreteOp> discreteOps, Set<String> attributionExemptPkgs) {
+ LongSparseArray<AttributionChain> chains = new LongSparseArray<>();
+ final int count = discreteOps.size();
+
+ for (int i = 0; i < count; i++) {
+ DiscreteOp opEvent = discreteOps.get(i);
+ if (opEvent.mChainId == ATTRIBUTION_CHAIN_ID_NONE
+ || (opEvent.mAttributionFlags & ATTRIBUTION_FLAG_TRUSTED) == 0) {
+ continue;
+ }
+ AttributionChain chain = chains.get(opEvent.mChainId);
+ if (chain == null) {
+ chain = new AttributionChain(attributionExemptPkgs);
+ chains.put(opEvent.mChainId, chain);
+ }
+ chain.addEvent(opEvent);
+ }
+ return chains;
+ }
+
+ static class AttributionChain {
+ List<DiscreteOp> mChain = new ArrayList<>();
+ Set<String> mExemptPkgs;
+ DiscreteOp mStartEvent = null;
+ DiscreteOp mLastVisibleEvent = null;
+
+ AttributionChain(Set<String> exemptPkgs) {
+ mExemptPkgs = exemptPkgs;
+ }
+
+ boolean isComplete() {
+ return !mChain.isEmpty() && getStart() != null && isEnd(mChain.get(mChain.size() - 1));
+ }
+
+ DiscreteOp getStart() {
+ return mChain.isEmpty() || !isStart(mChain.get(0)) ? null : mChain.get(0);
+ }
+
+ private boolean isEnd(DiscreteOp event) {
+ return event != null
+ && (event.mAttributionFlags & ATTRIBUTION_FLAG_ACCESSOR) != 0;
+ }
+
+ private boolean isStart(DiscreteOp event) {
+ return event != null
+ && (event.mAttributionFlags & ATTRIBUTION_FLAG_RECEIVER) != 0;
+ }
+
+ DiscreteOp getLastVisible() {
+ // Search all nodes but the first one, which is the start node
+ for (int i = mChain.size() - 1; i > 0; i--) {
+ DiscreteOp event = mChain.get(i);
+ if (!mExemptPkgs.contains(event.mPackageName)) {
+ return event;
+ }
+ }
+ return null;
+ }
+
+ void addEvent(DiscreteOp opEvent) {
+ // check if we have a matching event except duration.
+ DiscreteOp matchingItem = null;
+ for (int i = 0; i < mChain.size(); i++) {
+ DiscreteOp item = mChain.get(i);
+ if (item.equalsExceptDuration(opEvent)) {
+ matchingItem = item;
+ break;
+ }
+ }
+
+ if (matchingItem != null) {
+ // exact match or existing event has longer duration
+ if (matchingItem.mDuration == opEvent.mDuration
+ || matchingItem.mDuration > opEvent.mDuration) {
+ return;
+ }
+ mChain.remove(matchingItem);
+ }
+
+ if (mChain.isEmpty() || isEnd(opEvent)) {
+ mChain.add(opEvent);
+ } else if (isStart(opEvent)) {
+ mChain.add(0, opEvent);
+ } else {
+ for (int i = 0; i < mChain.size(); i++) {
+ DiscreteOp currEvent = mChain.get(i);
+ if ((!isStart(currEvent)
+ && currEvent.mAccessTime > opEvent.mAccessTime)
+ || (i == mChain.size() - 1 && isEnd(currEvent))) {
+ mChain.add(i, opEvent);
+ break;
+ } else if (i == mChain.size() - 1) {
+ mChain.add(opEvent);
+ break;
+ }
+ }
+ }
+ mStartEvent = isComplete() ? getStart() : null;
+ mLastVisibleEvent = isComplete() ? getLastVisible() : null;
+ }
+ }
+
+ /**
+ * Handler to write asynchronously to sqlite database.
+ */
+ class SqliteWriteHandler extends Handler {
+ SqliteWriteHandler(Looper looper) {
+ super(looper);
+ }
+
+ @Override
+ public void handleMessage(Message msg) {
+ switch (msg.what) {
+ case WRITE_CACHE_EVICTED_OP_EVENTS:
+ List<DiscreteOp> opEvents = (List<DiscreteOp>) msg.obj;
+ mDiscreteOpsDbHelper.insertDiscreteOps(opEvents);
+ break;
+ case DELETE_OLD_OP_EVENTS:
+ long cutOffTimeStamp = System.currentTimeMillis() - sDiscreteHistoryCutoff;
+ mDiscreteOpsDbHelper.execSQL(
+ DiscreteOpsTable.DELETE_TABLE_DATA_BEFORE_ACCESS_TIME,
+ new Object[]{cutOffTimeStamp});
+ break;
+ default:
+ throw new IllegalStateException("Unexpected value: " + msg.what);
+ }
+ }
+ }
+
+ /**
+ * A write cache for discrete ops. The noteOp, start/finishOp discrete op events are written to
+ * the cache first.
+ * <p>
+ * These events are persisted into sqlite database
+ * 1) Periodic interval, controlled by {@link AppOpsService}
+ * 2) When total events in the cache exceeds cache limit.
+ * 3) During read call we flush the whole cache to sqlite.
+ * 4) During shutdown.
+ */
+ class DiscreteOpCache {
+ private final int mCapacity;
+ private final ArraySet<DiscreteOp> mCache;
+
+ DiscreteOpCache(int capacity) {
+ mCapacity = capacity;
+ mCache = new ArraySet<>();
+ }
+
+ public void add(DiscreteOp opEvent) {
+ synchronized (this) {
+ if (mCache.contains(opEvent)) {
+ return;
+ }
+ mCache.add(opEvent);
+ if (mCache.size() >= mCapacity) {
+ if (DEBUG_LOG) {
+ Slog.i(TAG, "Current discrete ops cache size: " + mCache.size());
+ }
+ List<DiscreteOp> evictedEvents = evict();
+ if (DEBUG_LOG) {
+ Slog.i(TAG, "Evicted discrete ops size: " + evictedEvents.size());
+ }
+ // if nothing to evict, just write the whole cache to disk
+ if (evictedEvents.isEmpty()) {
+ Slog.w(TAG, "No discrete ops event is evicted, write cache to db.");
+ evictedEvents.addAll(mCache);
+ mCache.clear();
+ }
+ mSqliteWriteHandler.obtainMessage(WRITE_CACHE_EVICTED_OP_EVENTS, evictedEvents);
+ }
+ }
+ }
+
+ /**
+ * Evict entries older than {@link DiscreteOpsRegistry#sDiscreteHistoryQuantization}.
+ */
+ private List<DiscreteOp> evict() {
+ synchronized (this) {
+ List<DiscreteOp> evictedEvents = new ArrayList<>();
+ Set<DiscreteOp> snapshot = new ArraySet<>(mCache);
+ long evictionTimestamp = System.currentTimeMillis() - sDiscreteHistoryQuantization;
+ evictionTimestamp = discretizeTimeStamp(evictionTimestamp);
+ for (DiscreteOp opEvent : snapshot) {
+ if (opEvent.mDiscretizedAccessTime <= evictionTimestamp) {
+ evictedEvents.add(opEvent);
+ mCache.remove(opEvent);
+ }
+ }
+ return evictedEvents;
+ }
+ }
+
+ /**
+ * Remove all the entries from cache.
+ *
+ * @return return all removed entries.
+ */
+ public List<DiscreteOp> getAllEventsAndClear() {
+ synchronized (this) {
+ List<DiscreteOp> cachedOps = new ArrayList<>(mCache.size());
+ if (mCache.isEmpty()) {
+ return cachedOps;
+ }
+ cachedOps.addAll(mCache);
+ mCache.clear();
+ return cachedOps;
+ }
+ }
+
+ /**
+ * Remove all entries from the cache.
+ */
+ public void clear() {
+ synchronized (this) {
+ mCache.clear();
+ }
+ }
+
+ /**
+ * Offset access time by given offset milliseconds.
+ */
+ public void offsetTimestamp(long offsetMillis) {
+ synchronized (this) {
+ List<DiscreteOp> cachedOps = new ArrayList<>(mCache);
+ mCache.clear();
+ for (DiscreteOp discreteOp : cachedOps) {
+ add(new DiscreteOp(discreteOp.getUid(), discreteOp.mPackageName,
+ discreteOp.getAttributionTag(), discreteOp.getDeviceId(),
+ discreteOp.mOpCode, discreteOp.mOpFlags,
+ discreteOp.getAttributionFlags(), discreteOp.getUidState(),
+ discreteOp.getChainId(), discreteOp.mAccessTime - offsetMillis,
+ discreteOp.getDuration())
+ );
+ }
+ }
+ }
+
+ /** Remove cached events for given UID and package. */
+ public void clear(int uid, String packageName) {
+ synchronized (this) {
+ Set<DiscreteOp> snapshot = new ArraySet<>(mCache);
+ for (DiscreteOp currentEvent : snapshot) {
+ if (Objects.equals(packageName, currentEvent.mPackageName)
+ && uid == currentEvent.getUid()) {
+ mCache.remove(currentEvent);
+ }
+ }
+ }
+ }
+ }
+
+ /** Immutable discrete op object. */
+ static class DiscreteOp {
+ private final int mUid;
+ private final String mPackageName;
+ private final String mAttributionTag;
+ private final String mDeviceId;
+ private final int mOpCode;
+ private final int mOpFlags;
+ private final int mAttributionFlags;
+ private final int mUidState;
+ private final long mChainId;
+ private final long mAccessTime;
+ private final long mDuration;
+ // store discretized timestamp to avoid repeated calculations.
+ private final long mDiscretizedAccessTime;
+ private final long mDiscretizedDuration;
+
+ DiscreteOp(int uid, String packageName, String attributionTag, String deviceId,
+ int opCode,
+ int mOpFlags, int mAttributionFlags, int uidState, long chainId, long accessTime,
+ long duration) {
+ this.mUid = uid;
+ this.mPackageName = packageName.intern();
+ this.mAttributionTag = attributionTag;
+ this.mDeviceId = deviceId;
+ this.mOpCode = opCode;
+ this.mOpFlags = mOpFlags;
+ this.mAttributionFlags = mAttributionFlags;
+ this.mUidState = uidState;
+ this.mChainId = chainId;
+ this.mAccessTime = accessTime;
+ this.mDiscretizedAccessTime = discretizeTimeStamp(accessTime);
+ this.mDuration = duration;
+ this.mDiscretizedDuration = discretizeDuration(duration);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (!(o instanceof DiscreteOp that)) return false;
+
+ if (mUid != that.mUid) return false;
+ if (mOpCode != that.mOpCode) return false;
+ if (mOpFlags != that.mOpFlags) return false;
+ if (mAttributionFlags != that.mAttributionFlags) return false;
+ if (mUidState != that.mUidState) return false;
+ if (mChainId != that.mChainId) return false;
+ if (!Objects.equals(mPackageName, that.mPackageName)) {
+ return false;
+ }
+ if (!Objects.equals(mAttributionTag, that.mAttributionTag)) {
+ return false;
+ }
+ if (!Objects.equals(mDeviceId, that.mDeviceId)) {
+ return false;
+ }
+ if (mDiscretizedAccessTime != that.mDiscretizedAccessTime) {
+ return false;
+ }
+ return mDiscretizedDuration == that.mDiscretizedDuration;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = mUid;
+ result = 31 * result + (mPackageName != null ? mPackageName.hashCode() : 0);
+ result = 31 * result + (mAttributionTag != null ? mAttributionTag.hashCode() : 0);
+ result = 31 * result + (mDeviceId != null ? mDeviceId.hashCode() : 0);
+ result = 31 * result + mOpCode;
+ result = 31 * result + mOpFlags;
+ result = 31 * result + mAttributionFlags;
+ result = 31 * result + mUidState;
+ result = 31 * result + Objects.hash(mChainId);
+ result = 31 * result + Objects.hash(mDiscretizedAccessTime);
+ result = 31 * result + Objects.hash(mDiscretizedDuration);
+ return result;
+ }
+
+ public boolean equalsExceptDuration(DiscreteOp that) {
+ if (mUid != that.mUid) return false;
+ if (mOpCode != that.mOpCode) return false;
+ if (mOpFlags != that.mOpFlags) return false;
+ if (mAttributionFlags != that.mAttributionFlags) return false;
+ if (mUidState != that.mUidState) return false;
+ if (mChainId != that.mChainId) return false;
+ if (!Objects.equals(mPackageName, that.mPackageName)) {
+ return false;
+ }
+ if (!Objects.equals(mAttributionTag, that.mAttributionTag)) {
+ return false;
+ }
+ if (!Objects.equals(mDeviceId, that.mDeviceId)) {
+ return false;
+ }
+ return mAccessTime == that.mAccessTime;
+ }
+
+ @Override
+ public String toString() {
+ return "DiscreteOp{"
+ + "uid=" + mUid
+ + ", packageName='" + mPackageName + '\''
+ + ", attributionTag='" + mAttributionTag + '\''
+ + ", deviceId='" + mDeviceId + '\''
+ + ", opCode=" + AppOpsManager.opToName(mOpCode)
+ + ", opFlag=" + flagsToString(mOpFlags)
+ + ", attributionFlag=" + mAttributionFlags
+ + ", uidState=" + getUidStateName(mUidState)
+ + ", chainId=" + mChainId
+ + ", accessTime=" + mAccessTime
+ + ", duration=" + mDuration + '}';
+ }
+
+ public int getUid() {
+ return mUid;
+ }
+
+ public String getPackageName() {
+ return mPackageName;
+ }
+
+ public String getAttributionTag() {
+ return mAttributionTag;
+ }
+
+ public String getDeviceId() {
+ return mDeviceId;
+ }
+
+ public int getOpCode() {
+ return mOpCode;
+ }
+
+ @AppOpsManager.OpFlags
+ public int getOpFlags() {
+ return mOpFlags;
+ }
+
+
+ @AppOpsManager.AttributionFlags
+ public int getAttributionFlags() {
+ return mAttributionFlags;
+ }
+
+ @AppOpsManager.UidState
+ public int getUidState() {
+ return mUidState;
+ }
+
+ public long getChainId() {
+ return mChainId;
+ }
+
+ public long getAccessTime() {
+ return mAccessTime;
+ }
+
+ public long getDuration() {
+ return mDuration;
+ }
+ }
+
+ // API for tests only, can be removed or changed.
+ void recordDiscreteAccess(DiscreteOp discreteOpEvent) {
+ mDiscreteOpCache.add(discreteOpEvent);
+ }
+
+ // API for tests only, can be removed or changed.
+ List<DiscreteOp> getCachedDiscreteOps() {
+ return new ArrayList<>(mDiscreteOpCache.mCache);
+ }
+
+ // API for tests only, can be removed or changed.
+ List<DiscreteOp> getAllDiscreteOps() {
+ List<DiscreteOp> ops = new ArrayList<>(mDiscreteOpCache.mCache);
+ ops.addAll(mDiscreteOpsDbHelper.getAllDiscreteOps(DiscreteOpsTable.SELECT_TABLE_DATA));
+ return ops;
+ }
+
+ // API for testing and migration
+ long getLargestAttributionChainId() {
+ return mDiscreteOpsDbHelper.getLargestAttributionChainId();
+ }
+
+ // API for testing and migration
+ void deleteDatabase() {
+ mDiscreteOpsDbHelper.close();
+ mContext.deleteDatabase(mDatabaseFile.getName());
+ }
+}
diff --git a/services/core/java/com/android/server/appop/DiscreteOpsTable.java b/services/core/java/com/android/server/appop/DiscreteOpsTable.java
new file mode 100644
index 000000000000..9cb19aa30a15
--- /dev/null
+++ b/services/core/java/com/android/server/appop/DiscreteOpsTable.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2024 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.appop;
+
+
+/**
+ * SQLite table for storing app op accesses.
+ */
+final class DiscreteOpsTable {
+ private static final String TABLE_NAME = "app_op_accesses";
+ private static final String INDEX_APP_OP = "app_op_access_index";
+
+ static final class Columns {
+ /** Auto increment primary key. */
+ static final String ID = "id";
+ /** UID of the package accessing private data. */
+ static final String UID = "uid";
+ /** Package accessing private data. */
+ static final String PACKAGE_NAME = "package_name";
+ /** The device from which the private data is accessed. */
+ static final String DEVICE_ID = "device_id";
+ /** Op code representing private data i.e. location, mic etc. */
+ static final String OP_CODE = "op_code";
+ /** Attribution tag provided when accessing the private data. */
+ static final String ATTRIBUTION_TAG = "attribution_tag";
+ /** Timestamp when private data is accessed, number of milliseconds that have passed
+ * since Unix epoch */
+ static final String ACCESS_TIME = "access_time";
+ /** For how long the private data is accessed. */
+ static final String ACCESS_DURATION = "access_duration";
+ /** App process state, whether the app is in foreground, background or cached etc. */
+ static final String UID_STATE = "uid_state";
+ /** App op flags */
+ static final String OP_FLAGS = "op_flags";
+ /** Attribution flags */
+ static final String ATTRIBUTION_FLAGS = "attribution_flags";
+ /** Chain id */
+ static final String CHAIN_ID = "chain_id";
+ }
+
+ static final int UID_INDEX = 1;
+ static final int PACKAGE_NAME_INDEX = 2;
+ static final int DEVICE_ID_INDEX = 3;
+ static final int OP_CODE_INDEX = 4;
+ static final int ATTRIBUTION_TAG_INDEX = 5;
+ static final int ACCESS_TIME_INDEX = 6;
+ static final int ACCESS_DURATION_INDEX = 7;
+ static final int UID_STATE_INDEX = 8;
+ static final int OP_FLAGS_INDEX = 9;
+ static final int ATTRIBUTION_FLAGS_INDEX = 10;
+ static final int CHAIN_ID_INDEX = 11;
+
+ static final String CREATE_TABLE_SQL = "CREATE TABLE IF NOT EXISTS "
+ + TABLE_NAME + "("
+ + Columns.ID + " INTEGER PRIMARY KEY,"
+ + Columns.UID + " INTEGER,"
+ + Columns.PACKAGE_NAME + " TEXT,"
+ + Columns.DEVICE_ID + " TEXT NOT NULL,"
+ + Columns.OP_CODE + " INTEGER,"
+ + Columns.ATTRIBUTION_TAG + " TEXT,"
+ + Columns.ACCESS_TIME + " INTEGER,"
+ + Columns.ACCESS_DURATION + " INTEGER,"
+ + Columns.UID_STATE + " INTEGER,"
+ + Columns.OP_FLAGS + " INTEGER,"
+ + Columns.ATTRIBUTION_FLAGS + " INTEGER,"
+ + Columns.CHAIN_ID + " INTEGER"
+ + ")";
+
+ static final String INSERT_TABLE_SQL = "INSERT INTO " + TABLE_NAME + "("
+ + Columns.UID + ", "
+ + Columns.PACKAGE_NAME + ", "
+ + Columns.DEVICE_ID + ", "
+ + Columns.OP_CODE + ", "
+ + Columns.ATTRIBUTION_TAG + ", "
+ + Columns.ACCESS_TIME + ", "
+ + Columns.ACCESS_DURATION + ", "
+ + Columns.UID_STATE + ", "
+ + Columns.OP_FLAGS + ", "
+ + Columns.ATTRIBUTION_FLAGS + ", "
+ + Columns.CHAIN_ID + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+
+ static final String SELECT_MAX_ATTRIBUTION_CHAIN_ID = "SELECT MAX(" + Columns.CHAIN_ID + ")"
+ + " FROM " + TABLE_NAME;
+
+ static final String SELECT_TABLE_DATA = "SELECT DISTINCT "
+ + Columns.UID + ","
+ + Columns.PACKAGE_NAME + ","
+ + Columns.DEVICE_ID + ","
+ + Columns.OP_CODE + ","
+ + Columns.ATTRIBUTION_TAG + ","
+ + Columns.ACCESS_TIME + ","
+ + Columns.ACCESS_DURATION + ","
+ + Columns.UID_STATE + ","
+ + Columns.OP_FLAGS + ","
+ + Columns.ATTRIBUTION_FLAGS + ","
+ + Columns.CHAIN_ID
+ + " FROM " + TABLE_NAME;
+
+ static final String DELETE_TABLE_DATA = "DELETE FROM " + TABLE_NAME;
+
+ static final String DELETE_TABLE_DATA_BEFORE_ACCESS_TIME = "DELETE FROM " + TABLE_NAME
+ + " WHERE " + Columns.ACCESS_TIME + " < ?";
+
+ static final String DELETE_DATA_FOR_UID_PACKAGE = "DELETE FROM " + DiscreteOpsTable.TABLE_NAME
+ + " WHERE " + Columns.UID + " = ? AND " + Columns.PACKAGE_NAME + " = ?";
+
+ static final String OFFSET_ACCESS_TIME = "UPDATE " + DiscreteOpsTable.TABLE_NAME
+ + " SET " + Columns.ACCESS_TIME + " = ACCESS_TIME - ?";
+
+ // Index on access time, uid and op code
+ static final String CREATE_INDEX_SQL = "CREATE INDEX IF NOT EXISTS "
+ + INDEX_APP_OP + " ON " + TABLE_NAME
+ + " (" + Columns.ACCESS_TIME + ", " + Columns.UID + ", " + Columns.OP_CODE + ")";
+}
diff --git a/services/core/java/com/android/server/appop/DiscreteOpsTestingShim.java b/services/core/java/com/android/server/appop/DiscreteOpsTestingShim.java
new file mode 100644
index 000000000000..1523cca86607
--- /dev/null
+++ b/services/core/java/com/android/server/appop/DiscreteOpsTestingShim.java
@@ -0,0 +1,220 @@
+/*
+ * Copyright (C) 2024 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.appop;
+
+import android.annotation.NonNull;
+import android.annotation.Nullable;
+import android.app.AppOpsManager;
+import android.os.SystemClock;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.Slog;
+
+import java.io.PrintWriter;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * A testing class, which supports both xml and sqlite persistence for discrete ops, the class
+ * logs warning if there is a mismatch in the behavior.
+ */
+class DiscreteOpsTestingShim extends DiscreteOpsRegistry {
+ private static final String LOG_TAG = "DiscreteOpsTestingShim";
+ private final DiscreteOpsRegistry mXmlRegistry;
+ private final DiscreteOpsRegistry mSqlRegistry;
+
+ DiscreteOpsTestingShim(DiscreteOpsRegistry xmlRegistry,
+ DiscreteOpsRegistry sqlRegistry) {
+ mXmlRegistry = xmlRegistry;
+ mSqlRegistry = sqlRegistry;
+ }
+
+ @Override
+ void recordDiscreteAccess(int uid, String packageName, @NonNull String deviceId, int op,
+ @Nullable String attributionTag, int flags, int uidState, long accessTime,
+ long accessDuration, int attributionFlags, int attributionChainId, int accessType) {
+ long start = SystemClock.uptimeMillis();
+ mXmlRegistry.recordDiscreteAccess(uid, packageName, deviceId, op, attributionTag, flags,
+ uidState, accessTime, accessDuration, attributionFlags, attributionChainId,
+ accessType);
+ long start2 = SystemClock.uptimeMillis();
+ mSqlRegistry.recordDiscreteAccess(uid, packageName, deviceId, op, attributionTag, flags,
+ uidState, accessTime, accessDuration, attributionFlags, attributionChainId,
+ accessType);
+ long end = SystemClock.uptimeMillis();
+ long xmlTimeTaken = start2 - start;
+ long sqlTimeTaken = end - start2;
+ Log.i(LOG_TAG,
+ "recordDiscreteAccess: XML time taken : " + xmlTimeTaken + ", SQL time taken : "
+ + sqlTimeTaken + ", diff (sql - xml): " + (sqlTimeTaken - xmlTimeTaken));
+ }
+
+
+ @Override
+ void writeAndClearOldAccessHistory() {
+ mXmlRegistry.writeAndClearOldAccessHistory();
+ mSqlRegistry.writeAndClearOldAccessHistory();
+ }
+
+ @Override
+ void clearHistory() {
+ mXmlRegistry.clearHistory();
+ mSqlRegistry.clearHistory();
+ }
+
+ @Override
+ void clearHistory(int uid, String packageName) {
+ mXmlRegistry.clearHistory(uid, packageName);
+ mSqlRegistry.clearHistory(uid, packageName);
+ }
+
+ @Override
+ void offsetHistory(long offset) {
+ mXmlRegistry.offsetHistory(offset);
+ mSqlRegistry.offsetHistory(offset);
+ }
+
+ @Override
+ void addFilteredDiscreteOpsToHistoricalOps(AppOpsManager.HistoricalOps result,
+ long beginTimeMillis, long endTimeMillis, int filter, int uidFilter,
+ @Nullable String packageNameFilter, @Nullable String[] opNamesFilter,
+ @Nullable String attributionTagFilter, int flagsFilter,
+ Set<String> attributionExemptPkgs) {
+ AppOpsManager.HistoricalOps result2 =
+ new AppOpsManager.HistoricalOps(beginTimeMillis, endTimeMillis);
+
+ long start = System.currentTimeMillis();
+ mXmlRegistry.addFilteredDiscreteOpsToHistoricalOps(result2, beginTimeMillis, endTimeMillis,
+ filter, uidFilter, packageNameFilter, opNamesFilter, attributionTagFilter,
+ flagsFilter, attributionExemptPkgs);
+ long start2 = System.currentTimeMillis();
+ mSqlRegistry.addFilteredDiscreteOpsToHistoricalOps(result, beginTimeMillis, endTimeMillis,
+ filter, uidFilter, packageNameFilter, opNamesFilter, attributionTagFilter,
+ flagsFilter, attributionExemptPkgs);
+ long end = System.currentTimeMillis();
+ long xmlTimeTaken = start2 - start;
+ long sqlTimeTaken = end - start2;
+ try {
+ assertHistoricalOpsAreEquals(result, result2);
+ } catch (Exception ex) {
+ Slog.e(LOG_TAG, "different output when reading discrete ops", ex);
+ }
+ Log.i(LOG_TAG, "Read: XML time taken : " + xmlTimeTaken + ", SQL time taken : "
+ + sqlTimeTaken + ", diff (sql - xml): " + (sqlTimeTaken - xmlTimeTaken));
+ }
+
+ void assertHistoricalOpsAreEquals(AppOpsManager.HistoricalOps sqlResult,
+ AppOpsManager.HistoricalOps xmlResult) {
+ assertEquals(sqlResult.getUidCount(), xmlResult.getUidCount());
+ int uidCount = sqlResult.getUidCount();
+
+ for (int i = 0; i < uidCount; i++) {
+ AppOpsManager.HistoricalUidOps sqlUidOps = sqlResult.getUidOpsAt(i);
+ AppOpsManager.HistoricalUidOps xmlUidOps = xmlResult.getUidOpsAt(i);
+ Slog.i(LOG_TAG, "sql uid: " + sqlUidOps.getUid() + ", xml uid: " + xmlUidOps.getUid());
+ assertEquals(sqlUidOps.getUid(), xmlUidOps.getUid());
+ assertEquals(sqlUidOps.getPackageCount(), xmlUidOps.getPackageCount());
+
+ int packageCount = sqlUidOps.getPackageCount();
+ for (int p = 0; p < packageCount; p++) {
+ AppOpsManager.HistoricalPackageOps sqlPackageOps = sqlUidOps.getPackageOpsAt(p);
+ AppOpsManager.HistoricalPackageOps xmlPackageOps = xmlUidOps.getPackageOpsAt(p);
+ Slog.i(LOG_TAG, "sql package: " + sqlPackageOps.getPackageName() + ", xml package: "
+ + xmlPackageOps.getPackageName());
+ assertEquals(sqlPackageOps.getPackageName(), xmlPackageOps.getPackageName());
+ assertEquals(sqlPackageOps.getAttributedOpsCount(),
+ xmlPackageOps.getAttributedOpsCount());
+
+ int attrCount = sqlPackageOps.getAttributedOpsCount();
+ for (int a = 0; a < attrCount; a++) {
+ AppOpsManager.AttributedHistoricalOps sqlAttrOps =
+ sqlPackageOps.getAttributedOpsAt(a);
+ AppOpsManager.AttributedHistoricalOps xmlAttrOps =
+ xmlPackageOps.getAttributedOpsAt(a);
+ Slog.i(LOG_TAG, "sql tag: " + sqlAttrOps.getTag() + ", xml tag: "
+ + xmlAttrOps.getTag());
+ assertEquals(sqlAttrOps.getTag(), xmlAttrOps.getTag());
+ assertEquals(sqlAttrOps.getOpCount(), xmlAttrOps.getOpCount());
+
+ int opCount = sqlAttrOps.getOpCount();
+ for (int o = 0; o < opCount; o++) {
+ AppOpsManager.HistoricalOp sqlHistoricalOp = sqlAttrOps.getOpAt(o);
+ AppOpsManager.HistoricalOp xmlHistoricalOp = xmlAttrOps.getOpAt(o);
+ Slog.i(LOG_TAG, "sql op: " + sqlHistoricalOp.getOpName() + ", xml op: "
+ + xmlHistoricalOp.getOpName());
+ assertEquals(sqlHistoricalOp.getOpName(), xmlHistoricalOp.getOpName());
+ assertEquals(sqlHistoricalOp.getDiscreteAccessCount(),
+ xmlHistoricalOp.getDiscreteAccessCount());
+
+ int accessCount = sqlHistoricalOp.getDiscreteAccessCount();
+ for (int x = 0; x < accessCount; x++) {
+ AppOpsManager.AttributedOpEntry sqlOpEntry =
+ sqlHistoricalOp.getDiscreteAccessAt(x);
+ AppOpsManager.AttributedOpEntry xmlOpEntry =
+ xmlHistoricalOp.getDiscreteAccessAt(x);
+ Slog.i(LOG_TAG, "sql keys: " + sqlOpEntry.collectKeys() + ", xml keys: "
+ + xmlOpEntry.collectKeys());
+ assertEquals(sqlOpEntry.collectKeys(), xmlOpEntry.collectKeys());
+ assertEquals(sqlOpEntry.isRunning(), xmlOpEntry.isRunning());
+ ArraySet<Long> keys = sqlOpEntry.collectKeys();
+ final int keyCount = keys.size();
+ for (int k = 0; k < keyCount; k++) {
+ final long key = keys.valueAt(k);
+ final int flags = extractFlagsFromKey(key);
+ assertEquals(sqlOpEntry.getLastDuration(flags),
+ xmlOpEntry.getLastDuration(flags));
+ assertEquals(sqlOpEntry.getLastProxyInfo(flags),
+ xmlOpEntry.getLastProxyInfo(flags));
+ assertEquals(sqlOpEntry.getLastAccessTime(flags),
+ xmlOpEntry.getLastAccessTime(flags));
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // code duplicated for assertions
+ private static final int FLAGS_MASK = 0xFFFFFFFF;
+
+ public static int extractFlagsFromKey(@AppOpsManager.DataBucketKey long key) {
+ return (int) (key & FLAGS_MASK);
+ }
+
+ private void assertEquals(Object actual, Object expected) {
+ if (!Objects.equals(actual, expected)) {
+ throw new IllegalStateException("Actual (" + actual + ") is not equal to expected ("
+ + expected + ")");
+ }
+ }
+
+ @Override
+ void dump(@NonNull PrintWriter pw, int uidFilter, @Nullable String packageNameFilter,
+ @Nullable String attributionTagFilter, int filter, int dumpOp,
+ @NonNull SimpleDateFormat sdf, @NonNull Date date, @NonNull String prefix,
+ int nDiscreteOps) {
+ mXmlRegistry.dump(pw, uidFilter, packageNameFilter, attributionTagFilter, filter, dumpOp,
+ sdf, date, prefix, nDiscreteOps);
+ pw.println("--------------------------------------------------------");
+ pw.println("--------------------------------------------------------");
+ mSqlRegistry.dump(pw, uidFilter, packageNameFilter, attributionTagFilter, filter, dumpOp,
+ sdf, date, prefix, nDiscreteOps);
+ }
+}
diff --git a/services/core/java/com/android/server/appop/DiscreteOpsXmlRegistry.java b/services/core/java/com/android/server/appop/DiscreteOpsXmlRegistry.java
index 7245e8e5e4b9..a6e3fc7cc66a 100644
--- a/services/core/java/com/android/server/appop/DiscreteOpsXmlRegistry.java
+++ b/services/core/java/com/android/server/appop/DiscreteOpsXmlRegistry.java
@@ -136,10 +136,10 @@ class DiscreteOpsXmlRegistry extends DiscreteOpsRegistry {
private DiscreteOps mCachedOps = null;
DiscreteOpsXmlRegistry(Object inMemoryLock) {
- this(inMemoryLock, new File(new File(Environment.getDataSystemDirectory(), "appops"),
- "discrete"));
+ this(inMemoryLock, getDiscreteOpsDir());
}
+ // constructor for tests.
DiscreteOpsXmlRegistry(Object inMemoryLock, File discreteAccessDir) {
mInMemoryLock = inMemoryLock;
synchronized (mOnDiskLock) {
@@ -152,23 +152,19 @@ class DiscreteOpsXmlRegistry extends DiscreteOpsRegistry {
}
}
+ static File getDiscreteOpsDir() {
+ return new File(new File(Environment.getDataSystemDirectory(), "appops"), "discrete");
+ }
+
void recordDiscreteAccess(int uid, String packageName, @NonNull String deviceId, int op,
@Nullable String attributionTag, @AppOpsManager.OpFlags int flags,
@AppOpsManager.UidState int uidState, long accessTime, long accessDuration,
@AppOpsManager.AttributionFlags int attributionFlags, int attributionChainId,
@AccessType int accessType) {
if (shouldLogAccess(op)) {
- int firstChar = 0;
- if (attributionTag != null && attributionTag.startsWith(packageName)) {
- firstChar = packageName.length();
- if (firstChar < attributionTag.length() && attributionTag.charAt(firstChar)
- == '.') {
- firstChar++;
- }
- }
FrameworkStatsLog.write(FrameworkStatsLog.APP_OP_ACCESS_TRACKED, uid, op, accessType,
uidState, flags, attributionFlags,
- attributionTag == null ? null : attributionTag.substring(firstChar),
+ getAttributionTag(attributionTag, packageName),
attributionChainId);
}
@@ -189,7 +185,7 @@ class DiscreteOpsXmlRegistry extends DiscreteOpsRegistry {
}
}
- void writeAndClearAccessHistory() {
+ void writeAndClearOldAccessHistory() {
synchronized (mOnDiskLock) {
if (mDiscreteAccessDir == null) {
Slog.d(TAG, "State not saved - persistence not initialized.");
@@ -208,6 +204,22 @@ class DiscreteOpsXmlRegistry extends DiscreteOpsRegistry {
}
}
+ void migrateSqliteData(DiscreteOps sqliteOps) {
+ synchronized (mOnDiskLock) {
+ if (mDiscreteAccessDir == null) {
+ Slog.d(TAG, "State not saved - persistence not initialized.");
+ return;
+ }
+ synchronized (mInMemoryLock) {
+ mDiscreteOps.mLargestChainId = sqliteOps.mLargestChainId;
+ mDiscreteOps.mChainIdOffset = sqliteOps.mChainIdOffset;
+ }
+ if (!sqliteOps.isEmpty()) {
+ persistDiscreteOpsLocked(sqliteOps);
+ }
+ }
+ }
+
void addFilteredDiscreteOpsToHistoricalOps(AppOpsManager.HistoricalOps result,
long beginTimeMillis, long endTimeMillis,
@AppOpsManager.HistoricalOpsRequestFilter int filter, int uidFilter,
@@ -227,7 +239,7 @@ class DiscreteOpsXmlRegistry extends DiscreteOpsRegistry {
discreteOps.applyToHistoricalOps(result, attributionChains);
}
- private int readLargestChainIdFromDiskLocked() {
+ int readLargestChainIdFromDiskLocked() {
final File[] files = mDiscreteAccessDir.listFiles();
if (files != null && files.length > 0) {
File latestFile = null;
@@ -355,6 +367,13 @@ class DiscreteOpsXmlRegistry extends DiscreteOpsRegistry {
}
}
+ void deleteDiscreteOpsDir() {
+ synchronized (mOnDiskLock) {
+ mCachedOps = null;
+ FileUtils.deleteContentsAndDir(mDiscreteAccessDir);
+ }
+ }
+
void clearHistory(int uid, String packageName) {
synchronized (mOnDiskLock) {
DiscreteOps discreteOps;
@@ -1408,9 +1427,4 @@ class DiscreteOpsXmlRegistry extends DiscreteOpsRegistry {
}
return result;
}
-
- private static boolean shouldLogAccess(int op) {
- return Flags.appopAccessTrackingLoggingEnabled()
- && ArrayUtils.contains(sDiscreteOpsToLog, op);
- }
}
diff --git a/services/core/java/com/android/server/appop/HistoricalRegistry.java b/services/core/java/com/android/server/appop/HistoricalRegistry.java
index eba2033b362d..ba391d0a9995 100644
--- a/services/core/java/com/android/server/appop/HistoricalRegistry.java
+++ b/services/core/java/com/android/server/appop/HistoricalRegistry.java
@@ -35,6 +35,7 @@ import android.app.AppOpsManager.OpFlags;
import android.app.AppOpsManager.OpHistoryFlags;
import android.app.AppOpsManager.UidState;
import android.content.ContentResolver;
+import android.content.Context;
import android.database.ContentObserver;
import android.net.Uri;
import android.os.Build;
@@ -45,6 +46,7 @@ import android.os.Message;
import android.os.Process;
import android.os.RemoteCallback;
import android.os.UserHandle;
+import android.permission.flags.Flags;
import android.provider.Settings;
import android.util.ArraySet;
import android.util.LongSparseArray;
@@ -196,13 +198,30 @@ final class HistoricalRegistry {
@GuardedBy("mOnDiskLock")
private Persistence mPersistence;
- HistoricalRegistry(@NonNull Object lock) {
+ private final Context mContext;
+
+ HistoricalRegistry(@NonNull Object lock, Context context) {
mInMemoryLock = lock;
- mDiscreteRegistry = new DiscreteOpsXmlRegistry(lock);
+ mContext = context;
+ if (Flags.enableSqliteAppopsAccesses()) {
+ mDiscreteRegistry = new DiscreteOpsSqlRegistry(context);
+ if (DiscreteOpsXmlRegistry.getDiscreteOpsDir().exists()) {
+ DiscreteOpsSqlRegistry sqlRegistry = (DiscreteOpsSqlRegistry) mDiscreteRegistry;
+ DiscreteOpsXmlRegistry xmlRegistry = new DiscreteOpsXmlRegistry(context);
+ DiscreteOpsMigrationHelper.migrateDiscreteOpsToSqlite(xmlRegistry, sqlRegistry);
+ }
+ } else {
+ mDiscreteRegistry = new DiscreteOpsXmlRegistry(context);
+ if (DiscreteOpsDbHelper.getDatabaseFile().exists()) { // roll-back sqlite
+ DiscreteOpsSqlRegistry sqlRegistry = new DiscreteOpsSqlRegistry(context);
+ DiscreteOpsXmlRegistry xmlRegistry = (DiscreteOpsXmlRegistry) mDiscreteRegistry;
+ DiscreteOpsMigrationHelper.migrateDiscreteOpsToXml(sqlRegistry, xmlRegistry);
+ }
+ }
}
HistoricalRegistry(@NonNull HistoricalRegistry other) {
- this(other.mInMemoryLock);
+ this(other.mInMemoryLock, other.mContext);
mMode = other.mMode;
mBaseSnapshotInterval = other.mBaseSnapshotInterval;
mIntervalCompressionMultiplier = other.mIntervalCompressionMultiplier;
@@ -648,7 +667,7 @@ final class HistoricalRegistry {
}
void writeAndClearDiscreteHistory() {
- mDiscreteRegistry.writeAndClearAccessHistory();
+ mDiscreteRegistry.writeAndClearOldAccessHistory();
}
void clearAllHistory() {
@@ -743,7 +762,7 @@ final class HistoricalRegistry {
}
persistPendingHistory(pendingWrites);
}
- mDiscreteRegistry.writeAndClearAccessHistory();
+ mDiscreteRegistry.writeAndClearOldAccessHistory();
}
private void persistPendingHistory(@NonNull List<HistoricalOps> pendingWrites) {
diff --git a/services/core/java/com/android/server/pm/permission/PermissionManagerService.java b/services/core/java/com/android/server/pm/permission/PermissionManagerService.java
index 672eb4caf798..9d840d0c0d35 100644
--- a/services/core/java/com/android/server/pm/permission/PermissionManagerService.java
+++ b/services/core/java/com/android/server/pm/permission/PermissionManagerService.java
@@ -1681,8 +1681,8 @@ public class PermissionManagerService extends IPermissionManager.Stub {
// handle overflow
if (attributionChainId < 0) {
- attributionChainId = 0;
sAttributionChainIds.set(0);
+ attributionChainId = sAttributionChainIds.incrementAndGet();
}
return attributionChainId;
}
diff --git a/services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpSqlPersistenceTest.java b/services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpSqlPersistenceTest.java
new file mode 100644
index 000000000000..84713079c9d3
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpSqlPersistenceTest.java
@@ -0,0 +1,309 @@
+/*
+ * Copyright (C) 2024 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.appop;
+
+import static android.app.AppOpsManager.ATTRIBUTION_FLAG_ACCESSOR;
+import static android.app.AppOpsManager.ATTRIBUTION_FLAG_RECEIVER;
+import static android.app.AppOpsManager.ATTRIBUTION_FLAG_TRUSTED;
+import static android.app.AppOpsManager.UID_STATE_FOREGROUND;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import android.app.AppOpsManager;
+import android.content.Context;
+import android.os.Process;
+import android.util.ArraySet;
+import android.util.Log;
+import android.util.LongSparseArray;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import com.android.server.appop.DiscreteOpsSqlRegistry.DiscreteOp;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class DiscreteAppOpSqlPersistenceTest {
+ private static final String DATABASE_NAME = "test_app_ops.db";
+ private DiscreteOpsSqlRegistry mDiscreteRegistry;
+ private final Context mContext =
+ InstrumentationRegistry.getInstrumentation().getTargetContext();
+
+ @Before
+ public void setUp() {
+ mDiscreteRegistry = new DiscreteOpsSqlRegistry(mContext,
+ mContext.getDatabasePath(DATABASE_NAME));
+ mDiscreteRegistry.systemReady();
+ }
+
+ @After
+ public void cleanUp() {
+ mContext.deleteDatabase(DATABASE_NAME);
+ }
+
+ @Test
+ public void discreteOpEventIsRecorded() {
+ DiscreteOp opEvent = new DiscreteOpBuilder(mContext).build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent);
+ List<DiscreteOp> discreteOps = mDiscreteRegistry.getCachedDiscreteOps();
+ assertThat(discreteOps.size()).isEqualTo(1);
+ assertThat(discreteOps).contains(opEvent);
+ }
+
+ @Test
+ public void discreteOpEventIsPersistedToDisk() {
+ DiscreteOp opEvent = new DiscreteOpBuilder(mContext).build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent);
+ flushDiscreteOpsToDatabase();
+ assertThat(mDiscreteRegistry.getCachedDiscreteOps()).isEmpty();
+ List<DiscreteOp> discreteOps = mDiscreteRegistry.getAllDiscreteOps();
+ assertThat(discreteOps.size()).isEqualTo(1);
+ assertThat(discreteOps).contains(opEvent);
+ }
+
+ @Test
+ public void discreteOpEventInSameMinuteIsNotRecorded() {
+ long oneMinuteMillis = Duration.ofMinutes(1).toMillis();
+ // round timestamp at minute level and add 5 seconds
+ long accessTime = System.currentTimeMillis() / oneMinuteMillis * oneMinuteMillis + 5000;
+ DiscreteOp opEvent = new DiscreteOpBuilder(mContext).setAccessTime(accessTime).build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent);
+ // create duplicate event in same minute, with added 30 seconds
+ DiscreteOp opEvent2 =
+ new DiscreteOpBuilder(mContext).setAccessTime(accessTime + 30000).build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent2);
+ List<DiscreteOp> discreteOps = mDiscreteRegistry.getAllDiscreteOps();
+
+ assertThat(discreteOps.size()).isEqualTo(1);
+ assertThat(discreteOps).contains(opEvent);
+ }
+
+ @Test
+ public void multipleDiscreteOpEventAreRecorded() {
+ DiscreteOp opEvent = new DiscreteOpBuilder(mContext).build();
+ DiscreteOp opEvent2 = new DiscreteOpBuilder(mContext).setPackageName(
+ "test.package").build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent);
+ mDiscreteRegistry.recordDiscreteAccess(opEvent2);
+
+ List<DiscreteOp> discreteOps = mDiscreteRegistry.getAllDiscreteOps();
+ assertThat(discreteOps).contains(opEvent);
+ assertThat(discreteOps).contains(opEvent2);
+ assertThat(discreteOps.size()).isEqualTo(2);
+ }
+
+ @Test
+ public void clearDiscreteOps() {
+ DiscreteOp opEvent = new DiscreteOpBuilder(mContext).build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent);
+ flushDiscreteOpsToDatabase();
+ DiscreteOp opEvent2 = new DiscreteOpBuilder(mContext).setUid(12345).setPackageName(
+ "abc").build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent2);
+ mDiscreteRegistry.clearHistory();
+ assertThat(mDiscreteRegistry.getAllDiscreteOps()).isEmpty();
+ }
+
+ @Test
+ public void clearDiscreteOpsForPackage() {
+ DiscreteOp opEvent = new DiscreteOpBuilder(mContext).build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent);
+ flushDiscreteOpsToDatabase();
+ mDiscreteRegistry.recordDiscreteAccess(new DiscreteOpBuilder(mContext).build());
+ mDiscreteRegistry.clearHistory(Process.myUid(), mContext.getPackageName());
+
+ assertThat(mDiscreteRegistry.getAllDiscreteOps()).isEmpty();
+ }
+
+ @Test
+ public void offsetDiscreteOps() {
+ DiscreteOp opEvent = new DiscreteOpBuilder(mContext).build();
+ long event2AccessTime = System.currentTimeMillis() - 300000;
+ DiscreteOp opEvent2 = new DiscreteOpBuilder(mContext).setAccessTime(
+ event2AccessTime).build();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent);
+ flushDiscreteOpsToDatabase();
+ mDiscreteRegistry.recordDiscreteAccess(opEvent2);
+ long offset = Duration.ofMinutes(2).toMillis();
+
+ mDiscreteRegistry.offsetHistory(offset);
+
+ // adjust input for assertion
+ DiscreteOp e1 = new DiscreteOpBuilder(opEvent)
+ .setAccessTime(opEvent.getAccessTime() - offset).build();
+ DiscreteOp e2 = new DiscreteOpBuilder(opEvent2)
+ .setAccessTime(event2AccessTime - offset).build();
+
+ List<DiscreteOp> results = mDiscreteRegistry.getAllDiscreteOps();
+ assertThat(results.size()).isEqualTo(2);
+ assertThat(results).contains(e1);
+ assertThat(results).contains(e2);
+ }
+
+ @Test
+ public void completeAttributionChain() {
+ long chainId = 100;
+ DiscreteOp event1 = new DiscreteOpBuilder(mContext)
+ .setChainId(chainId)
+ .setAttributionFlags(ATTRIBUTION_FLAG_RECEIVER | ATTRIBUTION_FLAG_TRUSTED)
+ .build();
+ DiscreteOp event2 = new DiscreteOpBuilder(mContext)
+ .setChainId(chainId)
+ .setAttributionFlags(ATTRIBUTION_FLAG_ACCESSOR | ATTRIBUTION_FLAG_TRUSTED)
+ .build();
+ List<DiscreteOp> events = new ArrayList<>();
+ events.add(event1);
+ events.add(event2);
+
+ LongSparseArray<DiscreteOpsSqlRegistry.AttributionChain> chains =
+ mDiscreteRegistry.createAttributionChains(events, new ArraySet<>());
+
+ assertThat(chains.size()).isGreaterThan(0);
+ DiscreteOpsSqlRegistry.AttributionChain chain = chains.get(chainId);
+ assertThat(chain).isNotNull();
+ assertThat(chain.isComplete()).isTrue();
+ assertThat(chain.getStart()).isEqualTo(event1);
+ assertThat(chain.getLastVisible()).isEqualTo(event2);
+ }
+
+ @Test
+ public void addToHistoricalOps() {
+ long beginTimeMillis = System.currentTimeMillis();
+ DiscreteOp event1 = new DiscreteOpBuilder(mContext)
+ .build();
+ DiscreteOp event2 = new DiscreteOpBuilder(mContext)
+ .setUid(123457)
+ .build();
+ mDiscreteRegistry.recordDiscreteAccess(event1);
+ flushDiscreteOpsToDatabase();
+ mDiscreteRegistry.recordDiscreteAccess(event2);
+
+ long endTimeMillis = System.currentTimeMillis() + 500;
+ AppOpsManager.HistoricalOps results = new AppOpsManager.HistoricalOps(beginTimeMillis,
+ endTimeMillis);
+
+ mDiscreteRegistry.addFilteredDiscreteOpsToHistoricalOps(results, beginTimeMillis,
+ endTimeMillis, 0, 0, null, null, null, 0, new ArraySet<>());
+ Log.i("Manjeet", "TEST read " + results);
+ assertWithMessage("results shouldn't be empty").that(results.isEmpty()).isFalse();
+ }
+
+ @Test
+ public void dump() {
+ DiscreteOp event1 = new DiscreteOpBuilder(mContext)
+ .setAccessTime(1732221340628L)
+ .setUid(12345)
+ .build();
+ DiscreteOp event2 = new DiscreteOpBuilder(mContext)
+ .setAccessTime(1732227340628L)
+ .setUid(123457)
+ .build();
+ mDiscreteRegistry.recordDiscreteAccess(event1);
+ flushDiscreteOpsToDatabase();
+ mDiscreteRegistry.recordDiscreteAccess(event2);
+ }
+
+ /** This clears in-memory cache and push records into the database. */
+ private void flushDiscreteOpsToDatabase() {
+ mDiscreteRegistry.writeAndClearOldAccessHistory();
+ }
+
+ /**
+ * Creates default op event for CAMERA app op with current time as access time
+ * and 1 minute duration
+ */
+ private static class DiscreteOpBuilder {
+ private int mUid;
+ private String mPackageName;
+ private String mAttributionTag;
+ private String mDeviceId;
+ private int mOpCode;
+ private int mOpFlags;
+ private int mAttributionFlags;
+ private int mUidState;
+ private long mChainId;
+ private long mAccessTime;
+ private long mDuration;
+
+ DiscreteOpBuilder(Context context) {
+ mUid = Process.myUid();
+ mPackageName = context.getPackageName();
+ mAttributionTag = null;
+ mDeviceId = String.valueOf(context.getDeviceId());
+ mOpCode = AppOpsManager.OP_CAMERA;
+ mOpFlags = AppOpsManager.OP_FLAG_SELF;
+ mAttributionFlags = ATTRIBUTION_FLAG_ACCESSOR;
+ mUidState = UID_STATE_FOREGROUND;
+ mChainId = AppOpsManager.ATTRIBUTION_CHAIN_ID_NONE;
+ mAccessTime = System.currentTimeMillis();
+ mDuration = Duration.ofMinutes(1).toMillis();
+ }
+
+ DiscreteOpBuilder(DiscreteOp discreteOp) {
+ this.mUid = discreteOp.getUid();
+ this.mPackageName = discreteOp.getPackageName();
+ this.mAttributionTag = discreteOp.getAttributionTag();
+ this.mDeviceId = discreteOp.getDeviceId();
+ this.mOpCode = discreteOp.getOpCode();
+ this.mOpFlags = discreteOp.getOpFlags();
+ this.mAttributionFlags = discreteOp.getAttributionFlags();
+ this.mUidState = discreteOp.getUidState();
+ this.mChainId = discreteOp.getChainId();
+ this.mAccessTime = discreteOp.getAccessTime();
+ this.mDuration = discreteOp.getDuration();
+ }
+
+ public DiscreteOpBuilder setUid(int uid) {
+ this.mUid = uid;
+ return this;
+ }
+
+ public DiscreteOpBuilder setPackageName(String packageName) {
+ this.mPackageName = packageName;
+ return this;
+ }
+
+ public DiscreteOpBuilder setAttributionFlags(int attributionFlags) {
+ this.mAttributionFlags = attributionFlags;
+ return this;
+ }
+
+ public DiscreteOpBuilder setChainId(long chainId) {
+ this.mChainId = chainId;
+ return this;
+ }
+
+ public DiscreteOpBuilder setAccessTime(long accessTime) {
+ this.mAccessTime = accessTime;
+ return this;
+ }
+
+ public DiscreteOp build() {
+ return new DiscreteOp(mUid, mPackageName, mAttributionTag, mDeviceId, mOpCode, mOpFlags,
+ mAttributionFlags, mUidState, mChainId, mAccessTime, mDuration);
+ }
+ }
+}
diff --git a/services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpPersistenceTest.java b/services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpXmlPersistenceTest.java
index d3007624f0db..ae973be17904 100644
--- a/services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpPersistenceTest.java
+++ b/services/tests/servicestests/src/com/android/server/appop/DiscreteAppOpXmlPersistenceTest.java
@@ -51,7 +51,7 @@ import java.util.List;
* Test xml persistence implementation for discrete ops.
*/
@RunWith(AndroidJUnit4.class)
-public class DiscreteAppOpPersistenceTest {
+public class DiscreteAppOpXmlPersistenceTest {
private DiscreteOpsXmlRegistry mDiscreteRegistry;
private final Object mLock = new Object();
private File mMockDataDirectory;
@@ -70,7 +70,7 @@ public class DiscreteAppOpPersistenceTest {
@After
public void cleanUp() {
- mDiscreteRegistry.writeAndClearAccessHistory();
+ mDiscreteRegistry.writeAndClearOldAccessHistory();
FileUtils.deleteContents(mMockDataDirectory);
}
@@ -97,7 +97,7 @@ public class DiscreteAppOpPersistenceTest {
duration, uidState, opFlags, attributionFlags, attributionChainId);
// Write to disk and clear the in-memory object
- mDiscreteRegistry.writeAndClearAccessHistory();
+ mDiscreteRegistry.writeAndClearOldAccessHistory();
// Verify the storage file is created and then verify its content is correct
File[] files = FileUtils.listFilesOrEmpty(mMockDataDirectory);
@@ -127,7 +127,7 @@ public class DiscreteAppOpPersistenceTest {
fetchDiscreteOpsAndValidate(uid, packageName, op, deviceId, null, accessTime,
duration, uidState, opFlags, attributionFlags, attributionChainId);
- mDiscreteRegistry.writeAndClearAccessHistory();
+ mDiscreteRegistry.writeAndClearOldAccessHistory();
File[] files = FileUtils.listFilesOrEmpty(mMockDataDirectory);
assertThat(files.length).isEqualTo(1);
diff --git a/services/tests/servicestests/src/com/android/server/appop/DiscreteOpsMigrationAndRollbackTest.java b/services/tests/servicestests/src/com/android/server/appop/DiscreteOpsMigrationAndRollbackTest.java
new file mode 100644
index 000000000000..21cc3bac3938
--- /dev/null
+++ b/services/tests/servicestests/src/com/android/server/appop/DiscreteOpsMigrationAndRollbackTest.java
@@ -0,0 +1,168 @@
+/*
+ * Copyright (C) 2024 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.appop;
+
+import static android.app.AppOpsManager.ATTRIBUTION_FLAG_ACCESSOR;
+import static android.app.AppOpsManager.UID_STATE_FOREGROUND;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.app.AppOpsManager;
+import android.companion.virtual.VirtualDeviceManager;
+import android.content.Context;
+import android.os.FileUtils;
+import android.os.Process;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.time.Duration;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class DiscreteOpsMigrationAndRollbackTest {
+ private final Context mContext =
+ InstrumentationRegistry.getInstrumentation().getTargetContext();
+ private static final String DATABASE_NAME = "test_app_ops.db";
+ private static final int RECORD_COUNT = 500;
+ private final File mMockDataDirectory = mContext.getDir("mock_data", Context.MODE_PRIVATE);
+ final Object mLock = new Object();
+
+ @After
+ @Before
+ public void clean() {
+ mContext.deleteDatabase(DATABASE_NAME);
+ FileUtils.deleteContents(mMockDataDirectory);
+ }
+
+ @Test
+ public void migrateFromXmlToSqlite() {
+ // write records to xml registry
+ DiscreteOpsXmlRegistry xmlRegistry = new DiscreteOpsXmlRegistry(mLock, mMockDataDirectory);
+ xmlRegistry.systemReady();
+ for (int i = 1; i <= RECORD_COUNT; i++) {
+ DiscreteOpsSqlRegistry.DiscreteOp opEvent =
+ new DiscreteOpBuilder(mContext)
+ .setChainId(i)
+ .setUid(10000 + i) // make all records unique
+ .build();
+ xmlRegistry.recordDiscreteAccess(opEvent.getUid(), opEvent.getPackageName(),
+ opEvent.getDeviceId(), opEvent.getOpCode(), opEvent.getAttributionTag(),
+ opEvent.getOpFlags(), opEvent.getUidState(), opEvent.getAccessTime(),
+ opEvent.getDuration(), opEvent.getAttributionFlags(),
+ (int) opEvent.getChainId(), DiscreteOpsRegistry.ACCESS_TYPE_NOTE_OP);
+ }
+ xmlRegistry.writeAndClearOldAccessHistory();
+ assertThat(xmlRegistry.readLargestChainIdFromDiskLocked()).isEqualTo(RECORD_COUNT);
+ assertThat(xmlRegistry.getAllDiscreteOps().mUids.size()).isEqualTo(RECORD_COUNT);
+
+ // migration to sql registry
+ DiscreteOpsSqlRegistry sqlRegistry = new DiscreteOpsSqlRegistry(mContext,
+ mContext.getDatabasePath(DATABASE_NAME));
+ sqlRegistry.systemReady();
+ DiscreteOpsMigrationHelper.migrateDiscreteOpsToSqlite(xmlRegistry, sqlRegistry);
+ List<DiscreteOpsSqlRegistry.DiscreteOp> sqlOps = sqlRegistry.getAllDiscreteOps();
+
+ assertThat(xmlRegistry.getAllDiscreteOps().mUids).isEmpty();
+ assertThat(sqlOps.size()).isEqualTo(RECORD_COUNT);
+ assertThat(sqlRegistry.getLargestAttributionChainId()).isEqualTo(RECORD_COUNT);
+ }
+
+ @Test
+ public void migrateFromSqliteToXml() {
+ // write to sql registry
+ DiscreteOpsSqlRegistry sqlRegistry = new DiscreteOpsSqlRegistry(mContext,
+ mContext.getDatabasePath(DATABASE_NAME));
+ sqlRegistry.systemReady();
+ for (int i = 1; i <= RECORD_COUNT; i++) {
+ DiscreteOpsSqlRegistry.DiscreteOp opEvent =
+ new DiscreteOpBuilder(mContext)
+ .setChainId(i)
+ .setUid(RECORD_COUNT + i) // make all records unique
+ .build();
+ sqlRegistry.recordDiscreteAccess(opEvent.getUid(), opEvent.getPackageName(),
+ opEvent.getDeviceId(), opEvent.getOpCode(), opEvent.getAttributionTag(),
+ opEvent.getOpFlags(), opEvent.getUidState(), opEvent.getAccessTime(),
+ opEvent.getDuration(), opEvent.getAttributionFlags(),
+ (int) opEvent.getChainId(), DiscreteOpsRegistry.ACCESS_TYPE_NOTE_OP);
+ }
+ sqlRegistry.writeAndClearOldAccessHistory();
+ assertThat(sqlRegistry.getAllDiscreteOps().size()).isEqualTo(RECORD_COUNT);
+ assertThat(sqlRegistry.getLargestAttributionChainId()).isEqualTo(RECORD_COUNT);
+
+ // migration to xml registry
+ DiscreteOpsXmlRegistry xmlRegistry = new DiscreteOpsXmlRegistry(mLock, mMockDataDirectory);
+ xmlRegistry.systemReady();
+ DiscreteOpsMigrationHelper.migrateDiscreteOpsToXml(sqlRegistry, xmlRegistry);
+ DiscreteOpsXmlRegistry.DiscreteOps xmlOps = xmlRegistry.getAllDiscreteOps();
+
+ assertThat(sqlRegistry.getAllDiscreteOps()).isEmpty();
+ assertThat(xmlOps.mLargestChainId).isEqualTo(RECORD_COUNT);
+ assertThat(xmlOps.mUids.size()).isEqualTo(RECORD_COUNT);
+ }
+
+ private static class DiscreteOpBuilder {
+ private int mUid;
+ private String mPackageName;
+ private String mAttributionTag;
+ private String mDeviceId;
+ private int mOpCode;
+ private int mOpFlags;
+ private int mAttributionFlags;
+ private int mUidState;
+ private int mChainId;
+ private long mAccessTime;
+ private long mDuration;
+
+ DiscreteOpBuilder(Context context) {
+ mUid = Process.myUid();
+ mPackageName = context.getPackageName();
+ mAttributionTag = null;
+ mDeviceId = VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT;
+ mOpCode = AppOpsManager.OP_CAMERA;
+ mOpFlags = AppOpsManager.OP_FLAG_SELF;
+ mAttributionFlags = ATTRIBUTION_FLAG_ACCESSOR;
+ mUidState = UID_STATE_FOREGROUND;
+ mChainId = AppOpsManager.ATTRIBUTION_CHAIN_ID_NONE;
+ mAccessTime = System.currentTimeMillis();
+ mDuration = Duration.ofMinutes(1).toMillis();
+ }
+
+ public DiscreteOpBuilder setUid(int uid) {
+ this.mUid = uid;
+ return this;
+ }
+
+ public DiscreteOpBuilder setChainId(int chainId) {
+ this.mChainId = chainId;
+ return this;
+ }
+
+ public DiscreteOpsSqlRegistry.DiscreteOp build() {
+ return new DiscreteOpsSqlRegistry.DiscreteOp(mUid, mPackageName, mAttributionTag,
+ mDeviceId,
+ mOpCode, mOpFlags, mAttributionFlags, mUidState, mChainId, mAccessTime,
+ mDuration);
+ }
+ }
+}