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