From e59ff0197c972ea7a9af709a4e5db2c44b69dddf Mon Sep 17 00:00:00 2001 From: Yuting Date: Fri, 26 Apr 2024 13:43:49 -0700 Subject: [DeviceAwareAppOp] Add device attributed AppOp accesses to the recent access file This CL contains following changes: 1) Create a new AppOpsRecentAccessPersistence class to manage read/write of AppOp recent access file. 2) Instead of calling getPackagesForOps() to get all op accesses from the memory, use AppOpsService.mUidStates. This is to make it easy to get AppOp accesses from all devices by reading the raw data from mUidStates. 3) Add "dv" as a new XML attribute on the attributed op tag to represent an access entry from an external device. Add "pdv" as a new XML attribute to indicate the proxy is on an external device. Bug: 336802155 Test: Added a new unit test for persistence: atest AppOpsRecentAccessPersistenceTest Change-Id: I9a781f7de19dc0fc30de1f1335e21cf724ed2c88 --- core/java/android/permission/flags.aconfig | 8 + .../appop/AppOpsRecentAccessPersistence.java | 403 +++++++++++++++++++++ .../com/android/server/appop/AppOpsService.java | 25 +- .../AppOpsPersistenceTest/recent_accesses.xml | 11 + .../appop/AppOpsRecentAccessPersistenceTest.java | 188 ++++++++++ 5 files changed, 631 insertions(+), 4 deletions(-) create mode 100644 services/core/java/com/android/server/appop/AppOpsRecentAccessPersistence.java create mode 100644 services/tests/servicestests/assets/AppOpsPersistenceTest/recent_accesses.xml create mode 100644 services/tests/servicestests/src/com/android/server/appop/AppOpsRecentAccessPersistenceTest.java diff --git a/core/java/android/permission/flags.aconfig b/core/java/android/permission/flags.aconfig index 2ca58d16eaae..09be00ac86e2 100644 --- a/core/java/android/permission/flags.aconfig +++ b/core/java/android/permission/flags.aconfig @@ -189,4 +189,12 @@ flag { namespace: "permissions" description: "Enable getDeviceId API in OpEventProxyInfo" bug: "337340961" + } + +flag { + name: "device_aware_app_op_new_schema_enabled" + is_fixed_read_only: true + namespace: "permissions" + description: "Persist device attributed AppOp accesses on the disk" + bug: "308201969" } \ No newline at end of file diff --git a/services/core/java/com/android/server/appop/AppOpsRecentAccessPersistence.java b/services/core/java/com/android/server/appop/AppOpsRecentAccessPersistence.java new file mode 100644 index 000000000000..238d9b968e88 --- /dev/null +++ b/services/core/java/com/android/server/appop/AppOpsRecentAccessPersistence.java @@ -0,0 +1,403 @@ +/* + * 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.extractFlagsFromKey; +import static android.app.AppOpsManager.extractUidStateFromKey; +import static android.companion.virtual.VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT; + +import android.annotation.NonNull; +import android.annotation.Nullable; +import android.app.AppOpsManager; +import android.companion.virtual.VirtualDeviceManager; +import android.os.Process; +import android.util.ArrayMap; +import android.util.ArraySet; +import android.util.AtomicFile; +import android.util.Slog; +import android.util.SparseArray; +import android.util.Xml; + +import com.android.internal.util.XmlUtils; +import com.android.modules.utils.TypedXmlPullParser; +import com.android.modules.utils.TypedXmlSerializer; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.Objects; + +/** + * This class manages the read/write of AppOp recent accesses between memory and disk. + */ +final class AppOpsRecentAccessPersistence { + static final String TAG = "AppOpsRecentAccessPersistence"; + final AtomicFile mRecentAccessesFile; + final AppOpsService mAppOpsService; + + private static final String TAG_APP_OPS = "app-ops"; + private static final String TAG_PACKAGE = "pkg"; + private static final String TAG_UID = "uid"; + private static final String TAG_OP = "op"; + private static final String TAG_ATTRIBUTION_OP = "st"; + + private static final String ATTR_NAME = "n"; + private static final String ATTR_ID = "id"; + private static final String ATTR_DEVICE_ID = "dv"; + private static final String ATTR_ACCESS_TIME = "t"; + private static final String ATTR_REJECT_TIME = "r"; + private static final String ATTR_ACCESS_DURATION = "d"; + private static final String ATTR_PROXY_PACKAGE = "pp"; + private static final String ATTR_PROXY_UID = "pu"; + private static final String ATTR_PROXY_ATTRIBUTION_TAG = "pc"; + private static final String ATTR_PROXY_DEVICE_ID = "pdv"; + + /** + * Version of the mRecentAccessesFile. + * Increment by one every time an upgrade step is added at boot, none currently exists. + */ + private static final int CURRENT_VERSION = 1; + + AppOpsRecentAccessPersistence( + @NonNull AtomicFile recentAccessesFile, @NonNull AppOpsService appOpsService) { + mRecentAccessesFile = recentAccessesFile; + mAppOpsService = appOpsService; + } + + /** + * Load AppOp recent access data from disk into uidStates. The target uidStates will first clear + * itself before loading. + * + * @param uidStates The in-memory object where you want to populate data from disk + */ + void readRecentAccesses(@NonNull SparseArray uidStates) { + synchronized (mRecentAccessesFile) { + FileInputStream stream; + try { + stream = mRecentAccessesFile.openRead(); + } catch (FileNotFoundException e) { + Slog.i( + TAG, + "No existing app ops " + + mRecentAccessesFile.getBaseFile() + + "; starting empty"); + return; + } + boolean success = false; + uidStates.clear(); + mAppOpsService.mAppOpsCheckingService.clearAllModes(); + try { + TypedXmlPullParser parser = Xml.resolvePullParser(stream); + int type; + while ((type = parser.next()) != XmlPullParser.START_TAG + && type != XmlPullParser.END_DOCUMENT) { + // Parse next until we reach the start or end + } + + if (type != XmlPullParser.START_TAG) { + throw new IllegalStateException("no start tag found"); + } + + int outerDepth = parser.getDepth(); + while ((type = parser.next()) != XmlPullParser.END_DOCUMENT + && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { + if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { + continue; + } + + String tagName = parser.getName(); + if (tagName.equals(TAG_PACKAGE)) { + readPackage(parser, uidStates); + } else if (tagName.equals(TAG_UID)) { + // uid tag may be present during migration, don't print warning. + XmlUtils.skipCurrentTag(parser); + } else { + Slog.w(TAG, "Unknown element under : " + parser.getName()); + XmlUtils.skipCurrentTag(parser); + } + } + + success = true; + } catch (IllegalStateException | NullPointerException | NumberFormatException + | XmlPullParserException | IOException | IndexOutOfBoundsException e) { + Slog.w(TAG, "Failed parsing " + e); + } finally { + if (!success) { + uidStates.clear(); + mAppOpsService.mAppOpsCheckingService.clearAllModes(); + } + try { + stream.close(); + } catch (IOException ignored) { + } + } + } + } + + private void readPackage( + TypedXmlPullParser parser, SparseArray uidStates) + throws NumberFormatException, XmlPullParserException, IOException { + String pkgName = parser.getAttributeValue(null, ATTR_NAME); + int outerDepth = parser.getDepth(); + int type; + while ((type = parser.next()) != XmlPullParser.END_DOCUMENT + && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { + if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { + continue; + } + + String tagName = parser.getName(); + if (tagName.equals(TAG_UID)) { + readUid(parser, pkgName, uidStates); + } else { + Slog.w(TAG, "Unknown element under : " + + parser.getName()); + XmlUtils.skipCurrentTag(parser); + } + } + } + + private void readUid(TypedXmlPullParser parser, @NonNull String pkgName, + SparseArray uidStates) + throws NumberFormatException, XmlPullParserException, IOException { + int uid = parser.getAttributeInt(null, ATTR_NAME); + final AppOpsService.UidState uidState = mAppOpsService.new UidState(uid); + uidStates.put(uid, uidState); + + int outerDepth = parser.getDepth(); + int type; + while ((type = parser.next()) != XmlPullParser.END_DOCUMENT + && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { + if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { + continue; + } + String tagName = parser.getName(); + if (tagName.equals(TAG_OP)) { + readOp(parser, uidState, pkgName); + } else { + Slog.w(TAG, "Unknown element under : " + + parser.getName()); + XmlUtils.skipCurrentTag(parser); + } + } + } + + private void readOp(TypedXmlPullParser parser, + @NonNull AppOpsService.UidState uidState, @NonNull String pkgName) + throws NumberFormatException, XmlPullParserException, IOException { + int opCode = parser.getAttributeInt(null, ATTR_NAME); + AppOpsService.Op op = mAppOpsService.new Op(uidState, pkgName, opCode, uidState.uid); + + int outerDepth = parser.getDepth(); + int type; + while ((type = parser.next()) != XmlPullParser.END_DOCUMENT + && (type != XmlPullParser.END_TAG || parser.getDepth() > outerDepth)) { + if (type == XmlPullParser.END_TAG || type == XmlPullParser.TEXT) { + continue; + } + String tagName = parser.getName(); + if (tagName.equals(TAG_ATTRIBUTION_OP)) { + readAttributionOp(parser, op, XmlUtils.readStringAttribute(parser, ATTR_ID)); + } else { + Slog.w(TAG, "Unknown element under : " + + parser.getName()); + XmlUtils.skipCurrentTag(parser); + } + } + + AppOpsService.Ops ops = uidState.pkgOps.get(pkgName); + if (ops == null) { + ops = new AppOpsService.Ops(pkgName, uidState); + uidState.pkgOps.put(pkgName, ops); + } + ops.put(op.op, op); + } + + private void readAttributionOp(TypedXmlPullParser parser, @NonNull AppOpsService.Op parent, + @Nullable String attribution) + throws NumberFormatException, IOException, XmlPullParserException { + final long key = parser.getAttributeLong(null, ATTR_NAME); + final int uidState = extractUidStateFromKey(key); + final int opFlags = extractFlagsFromKey(key); + + String deviceId = parser.getAttributeValue(null, ATTR_DEVICE_ID); + final long accessTime = parser.getAttributeLong(null, ATTR_ACCESS_TIME, 0); + final long rejectTime = parser.getAttributeLong(null, ATTR_REJECT_TIME, 0); + final long accessDuration = parser.getAttributeLong(null, ATTR_ACCESS_DURATION, -1); + final String proxyPkg = XmlUtils.readStringAttribute(parser, ATTR_PROXY_PACKAGE); + final int proxyUid = parser.getAttributeInt(null, ATTR_PROXY_UID, Process.INVALID_UID); + final String proxyAttributionTag = + XmlUtils.readStringAttribute(parser, ATTR_PROXY_ATTRIBUTION_TAG); + final String proxyDeviceId = parser.getAttributeValue(null, ATTR_PROXY_DEVICE_ID); + + if (deviceId == null || Objects.equals(deviceId, "")) { + deviceId = PERSISTENT_DEVICE_ID_DEFAULT; + } + + AttributedOp attributedOp = parent.getOrCreateAttribution(parent, attribution, deviceId); + + if (accessTime > 0) { + attributedOp.accessed(accessTime, accessDuration, proxyUid, proxyPkg, + proxyAttributionTag, proxyDeviceId, uidState, opFlags); + } + if (rejectTime > 0) { + attributedOp.rejected(rejectTime, uidState, opFlags); + } + } + + /** + * Write uidStates into an XML file on the disk. It's a complete dump from memory, the XML file + * will be re-written. + * + * @param uidStates The in-memory object that holds all AppOp recent access data. + */ + void writeRecentAccesses(SparseArray uidStates) { + synchronized (mRecentAccessesFile) { + FileOutputStream stream; + try { + stream = mRecentAccessesFile.startWrite(); + } catch (IOException e) { + Slog.w(TAG, "Failed to write state: " + e); + return; + } + + try { + TypedXmlSerializer out = Xml.resolveSerializer(stream); + out.startDocument(null, true); + out.startTag(null, TAG_APP_OPS); + out.attributeInt(null, "v", CURRENT_VERSION); + + for (int uidIndex = 0; uidIndex < uidStates.size(); uidIndex++) { + AppOpsService.UidState uidState = uidStates.valueAt(uidIndex); + int uid = uidState.uid; + + for (int pkgIndex = 0; pkgIndex < uidState.pkgOps.size(); pkgIndex++) { + String packageName = uidState.pkgOps.keyAt(pkgIndex); + AppOpsService.Ops ops = uidState.pkgOps.valueAt(pkgIndex); + + out.startTag(null, TAG_PACKAGE); + out.attribute(null, ATTR_NAME, packageName); + out.startTag(null, TAG_UID); + out.attributeInt(null, ATTR_NAME, uid); + + for (int opIndex = 0; opIndex < ops.size(); opIndex++) { + AppOpsService.Op op = ops.valueAt(opIndex); + + out.startTag(null, TAG_OP); + out.attributeInt(null, ATTR_NAME, op.op); + + writeDeviceAttributedOps(out, op); + + out.endTag(null, TAG_OP); + } + + out.endTag(null, TAG_UID); + out.endTag(null, TAG_PACKAGE); + } + } + + out.endTag(null, TAG_APP_OPS); + out.endDocument(); + mRecentAccessesFile.finishWrite(stream); + } catch (IOException e) { + Slog.w(TAG, "Failed to write state, restoring backup.", e); + mRecentAccessesFile.failWrite(stream); + } + } + } + + private void writeDeviceAttributedOps(TypedXmlSerializer out, AppOpsService.Op op) + throws IOException { + for (String deviceId : op.mDeviceAttributedOps.keySet()) { + ArrayMap attributedOps = + op.mDeviceAttributedOps.get(deviceId); + + for (int attrIndex = 0; attrIndex < attributedOps.size(); attrIndex++) { + String attributionTag = attributedOps.keyAt(attrIndex); + AppOpsManager.AttributedOpEntry attributedOpEntry = + attributedOps.valueAt(attrIndex).createAttributedOpEntryLocked(); + + final ArraySet keys = attributedOpEntry.collectKeys(); + for (int k = 0; k < keys.size(); k++) { + final long key = keys.valueAt(k); + + final int uidState = AppOpsManager.extractUidStateFromKey(key); + final int flags = AppOpsManager.extractFlagsFromKey(key); + + final long accessTime = + attributedOpEntry.getLastAccessTime(uidState, uidState, flags); + final long rejectTime = + attributedOpEntry.getLastRejectTime(uidState, uidState, flags); + final long accessDuration = + attributedOpEntry.getLastDuration(uidState, uidState, flags); + + // Proxy information for rejections is not backed up + final AppOpsManager.OpEventProxyInfo proxy = + attributedOpEntry.getLastProxyInfo(uidState, uidState, flags); + + if (accessTime <= 0 && rejectTime <= 0 && accessDuration <= 0 + && proxy == null) { + continue; + } + + out.startTag(null, TAG_ATTRIBUTION_OP); + if (attributionTag != null) { + out.attribute(null, ATTR_ID, attributionTag); + } + out.attributeLong(null, ATTR_NAME, key); + + if (!Objects.equals( + deviceId, VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT)) { + out.attribute(null, ATTR_DEVICE_ID, deviceId); + } + if (accessTime > 0) { + out.attributeLong(null, ATTR_ACCESS_TIME, accessTime); + } + if (rejectTime > 0) { + out.attributeLong(null, ATTR_REJECT_TIME, rejectTime); + } + if (accessDuration > 0) { + out.attributeLong(null, ATTR_ACCESS_DURATION, accessDuration); + } + if (proxy != null) { + out.attributeInt(null, ATTR_PROXY_UID, proxy.getUid()); + + if (proxy.getPackageName() != null) { + out.attribute(null, ATTR_PROXY_PACKAGE, proxy.getPackageName()); + } + if (proxy.getAttributionTag() != null) { + out.attribute( + null, ATTR_PROXY_ATTRIBUTION_TAG, proxy.getAttributionTag()); + } + if (proxy.getDeviceId() != null + && !Objects.equals( + proxy.getDeviceId(), + VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT)) { + out.attribute(null, ATTR_PROXY_DEVICE_ID, proxy.getDeviceId()); + } + } + + out.endTag(null, TAG_ATTRIBUTION_OP); + } + } + } + } +} diff --git a/services/core/java/com/android/server/appop/AppOpsService.java b/services/core/java/com/android/server/appop/AppOpsService.java index ad93f6fc8d9f..b9ee68688718 100644 --- a/services/core/java/com/android/server/appop/AppOpsService.java +++ b/services/core/java/com/android/server/appop/AppOpsService.java @@ -71,6 +71,7 @@ import static android.content.Intent.ACTION_PACKAGE_REMOVED; import static android.content.Intent.EXTRA_REPLACING; import static android.content.pm.PermissionInfo.PROTECTION_DANGEROUS; import static android.content.pm.PermissionInfo.PROTECTION_FLAG_APPOP; +import static android.permission.flags.Flags.deviceAwareAppOpNewSchemaEnabled; import static com.android.server.appop.AppOpsService.ModeCallback.ALL_OPS; @@ -261,6 +262,7 @@ public class AppOpsService extends IAppOpsService.Stub { private final @Nullable File mNoteOpCallerStacktracesFile; final Handler mHandler; + private final AppOpsRecentAccessPersistence mRecentAccessPersistence; /** * Pool for {@link AttributedOp.OpEventProxyInfoPool} to avoid to constantly reallocate new * objects @@ -408,7 +410,7 @@ public class AppOpsService extends IAppOpsService.Stub { private @Nullable UserManagerInternal mUserManagerInternal; /** Interface for app-op modes.*/ - @VisibleForTesting + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) AppOpsCheckingServiceInterface mAppOpsCheckingService; /** Interface for app-op restrictions.*/ @@ -528,7 +530,7 @@ public class AppOpsService extends IAppOpsService.Stub { @VisibleForTesting final Constants mConstants; - @VisibleForTesting + @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) final class UidState { public final int uid; @@ -642,7 +644,7 @@ public class AppOpsService extends IAppOpsService.Stub { } } - private @NonNull AttributedOp getOrCreateAttribution(@NonNull Op parent, + @NonNull AttributedOp getOrCreateAttribution(@NonNull Op parent, @Nullable String attributionTag, String persistentDeviceId) { ArrayMap attributedOps = mDeviceAttributedOps.get( persistentDeviceId); @@ -1003,6 +1005,7 @@ public class AppOpsService extends IAppOpsService.Stub { LockGuard.installLock(this, LockGuard.INDEX_APP_OPS); mStorageFile = new AtomicFile(storageFile, "appops_legacy"); mRecentAccessesFile = new AtomicFile(recentAccessesFile, "appops_accesses"); + mRecentAccessPersistence = new AppOpsRecentAccessPersistence(mRecentAccessesFile, this); if (AppOpsManager.NOTE_OP_COLLECTION_ENABLED) { mNoteOpCallerStacktracesFile = new File(SystemServiceManager.ensureSystemDir(), @@ -4909,7 +4912,13 @@ public class AppOpsService extends IAppOpsService.Stub { if (!mRecentAccessesFile.exists()) { readRecentAccesses(mStorageFile); } else { - readRecentAccesses(mRecentAccessesFile); + if (deviceAwareAppOpNewSchemaEnabled()) { + synchronized (this) { + mRecentAccessPersistence.readRecentAccesses(mUidStates); + } + } else { + readRecentAccesses(mRecentAccessesFile); + } } } @@ -5090,6 +5099,14 @@ public class AppOpsService extends IAppOpsService.Stub { @VisibleForTesting void writeRecentAccesses() { + if (deviceAwareAppOpNewSchemaEnabled()) { + synchronized (this) { + mRecentAccessPersistence.writeRecentAccesses(mUidStates); + } + mHistoricalRegistry.writeAndClearDiscreteHistory(); + return; + } + synchronized (mRecentAccessesFile) { FileOutputStream stream; try { diff --git a/services/tests/servicestests/assets/AppOpsPersistenceTest/recent_accesses.xml b/services/tests/servicestests/assets/AppOpsPersistenceTest/recent_accesses.xml new file mode 100644 index 000000000000..5aceea3530ad --- /dev/null +++ b/services/tests/servicestests/assets/AppOpsPersistenceTest/recent_accesses.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/services/tests/servicestests/src/com/android/server/appop/AppOpsRecentAccessPersistenceTest.java b/services/tests/servicestests/src/com/android/server/appop/AppOpsRecentAccessPersistenceTest.java new file mode 100644 index 000000000000..c4b3c149bd8d --- /dev/null +++ b/services/tests/servicestests/src/com/android/server/appop/AppOpsRecentAccessPersistenceTest.java @@ -0,0 +1,188 @@ +/* + * 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.OP_FLAGS_ALL; + +import static com.google.common.truth.Truth.assertThat; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +import android.app.AppOpsManager; +import android.companion.virtual.VirtualDeviceManager; +import android.content.Context; +import android.os.FileUtils; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.ArrayMap; +import android.util.AtomicFile; +import android.util.SparseArray; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.server.LocalServices; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnit; +import org.mockito.junit.MockitoRule; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStreamReader; + +@RunWith(AndroidJUnit4.class) +public class AppOpsRecentAccessPersistenceTest { + private static final String TAG = AppOpsRecentAccessPersistenceTest.class.getSimpleName(); + private static final String TEST_XML = "AppOpsPersistenceTest/recent_accesses.xml"; + + private final Context mContext = + InstrumentationRegistry.getInstrumentation().getTargetContext(); + private File mMockDataDirectory; + private File mRecentAccessFile; + private AppOpsService mAppOpsService; + + @Rule public final MockitoRule mockito = MockitoJUnit.rule(); + @Mock private AppOpsServiceTestingShim mAppOpCheckingService; + + @Before + public void setUp() { + when(mAppOpCheckingService.addAppOpsModeChangedListener(any())).thenReturn(true); + LocalServices.addService(AppOpsCheckingServiceInterface.class, mAppOpCheckingService); + + mMockDataDirectory = mContext.getDir("mock_data", Context.MODE_PRIVATE); + mRecentAccessFile = new File(mMockDataDirectory, "test_accesses.xml"); + + HandlerThread handlerThread = new HandlerThread(TAG); + handlerThread.start(); + Handler handler = new Handler(handlerThread.getLooper()); + mAppOpsService = new AppOpsService(mRecentAccessFile, mRecentAccessFile, handler, mContext); + } + + @After + public void cleanUp() { + FileUtils.deleteContents(mMockDataDirectory); + } + + @Test + public void readAndWriteRecentAccesses() throws Exception { + copyRecentAccessFromAsset(mContext, TEST_XML, mRecentAccessFile); + SparseArray uidStates = new SparseArray<>(); + + AtomicFile recentAccessFile = new AtomicFile(mRecentAccessFile); + AppOpsRecentAccessPersistence persistence = + new AppOpsRecentAccessPersistence(recentAccessFile, mAppOpsService); + + persistence.readRecentAccesses(uidStates); + validateUidStates(uidStates); + + // Now we clear the xml file and write uidStates to it, then read again to verify data + // written to the xml is correct. + recentAccessFile.delete(); + persistence.writeRecentAccesses(uidStates); + + SparseArray newUidStates = new SparseArray<>(); + persistence.readRecentAccesses(newUidStates); + validateUidStates(newUidStates); + } + + // We compare data loaded into uidStates with original data in recent_accesses.xml + private void validateUidStates(SparseArray uidStates) { + assertThat(uidStates.size()).isEqualTo(1); + + AppOpsService.UidState uidState = uidStates.get(10001); + assertThat(uidState.uid).isEqualTo(10001); + + ArrayMap packageOps = uidState.pkgOps; + assertThat(packageOps.size()).isEqualTo(1); + + AppOpsService.Ops ops = packageOps.get("com.android.servicestests.apps.testapp"); + assertThat(ops.size()).isEqualTo(1); + + AppOpsService.Op op = ops.get(26); + assertThat(op.mDeviceAttributedOps.size()).isEqualTo(2); + + // Test AppOp access for the default device + AttributedOp attributedOp = + op.mDeviceAttributedOps + .get(VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT) + .get("attribution.tag.test.1"); + assertThat(attributedOp.persistentDeviceId) + .isEqualTo(VirtualDeviceManager.PERSISTENT_DEVICE_ID_DEFAULT); + assertThat(attributedOp.tag).isEqualTo("attribution.tag.test.1"); + + AppOpsManager.AttributedOpEntry attributedOpEntry = + attributedOp.createAttributedOpEntryLocked(); + + assertThat(attributedOpEntry.getLastAccessTime(OP_FLAGS_ALL)).isEqualTo(1710799464518L); + assertThat(attributedOpEntry.getLastDuration(OP_FLAGS_ALL)).isEqualTo(2963); + + // Test AppOp access for an external device + AttributedOp attributedOpForDevice = op.mDeviceAttributedOps.get("companion:1").get(null); + assertThat(attributedOpForDevice.persistentDeviceId).isEqualTo("companion:1"); + + AppOpsManager.AttributedOpEntry attributedOpEntryForDevice = + attributedOpForDevice.createAttributedOpEntryLocked(); + assertThat(attributedOpEntryForDevice.getLastAccessTime(OP_FLAGS_ALL)) + .isEqualTo(1712610342977L); + assertThat(attributedOpEntryForDevice.getLastDuration(OP_FLAGS_ALL)).isEqualTo(7596); + + AppOpsManager.OpEventProxyInfo proxyInfo = + attributedOpEntryForDevice.getLastProxyInfo(OP_FLAGS_ALL); + assertThat(proxyInfo.getUid()).isEqualTo(10002); + assertThat(proxyInfo.getPackageName()).isEqualTo("com.android.servicestests.apps.proxy"); + assertThat(proxyInfo.getAttributionTag()) + .isEqualTo("com.android.servicestests.apps.proxy.attrtag"); + assertThat(proxyInfo.getDeviceId()).isEqualTo("companion:2"); + } + + private static void copyRecentAccessFromAsset(Context context, String xmlAsset, File outFile) + throws IOException { + writeToFile(outFile, readAsset(context, xmlAsset)); + } + + private static String readAsset(Context context, String assetPath) throws IOException { + final StringBuilder sb = new StringBuilder(); + try (BufferedReader br = + new BufferedReader( + new InputStreamReader( + context.getResources().getAssets().open(assetPath)))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + sb.append(System.lineSeparator()); + } + } + return sb.toString(); + } + + private static void writeToFile(File path, String content) throws IOException { + path.getParentFile().mkdirs(); + + try (FileWriter writer = new FileWriter(path)) { + writer.write(content); + } + } +} -- cgit v1.2.3-59-g8ed1b