From 8a747d7e9145fe9994c76e0b7c32dd094b12aa06 Mon Sep 17 00:00:00 2001 From: Kweku Adams Date: Wed, 29 Sep 2021 15:51:45 -0700 Subject: [PATCH] Add TARE state persistence. Bug: 158300259 Test: atest FrameworksMockingServicesTests:ScribeTest Test: Verify contents of file on disk Test: Verify persisted state is loaded back via dumpsys Change-Id: I0ad0bd2646814d630b7aaecedcee1a006aa92340 --- .../server/tare/InternalResourceService.java | 5 +- .../java/com/android/server/tare/Ledger.java | 10 + .../java/com/android/server/tare/Scribe.java | 380 ++++++++++++++++-- .../com/android/server/tare/AgentTest.java | 5 + .../com/android/server/tare/ScribeTest.java | 222 ++++++++++ 5 files changed, 596 insertions(+), 26 deletions(-) create mode 100644 services/tests/mockingservicestests/src/com/android/server/tare/ScribeTest.java diff --git a/apex/jobscheduler/service/java/com/android/server/tare/InternalResourceService.java b/apex/jobscheduler/service/java/com/android/server/tare/InternalResourceService.java index e9fa926b13e0d..a39fd474c8ac2 100644 --- a/apex/jobscheduler/service/java/com/android/server/tare/InternalResourceService.java +++ b/apex/jobscheduler/service/java/com/android/server/tare/InternalResourceService.java @@ -537,10 +537,11 @@ public class InternalResourceService extends SystemService { private void setupHeavyWork() { synchronized (mLock) { loadInstalledPackageListLocked(); - // TODO: base on if we have anything persisted - final boolean isFirstSetup = true; + final boolean isFirstSetup = !mScribe.recordExists(); if (isFirstSetup) { mAgent.grantBirthrightsLocked(); + } else { + mScribe.loadFromDiskLocked(); } scheduleUnusedWealthReclamationLocked(); } diff --git a/apex/jobscheduler/service/java/com/android/server/tare/Ledger.java b/apex/jobscheduler/service/java/com/android/server/tare/Ledger.java index f2b78c0cd3e5c..a234ae6142fc5 100644 --- a/apex/jobscheduler/service/java/com/android/server/tare/Ledger.java +++ b/apex/jobscheduler/service/java/com/android/server/tare/Ledger.java @@ -61,6 +61,11 @@ class Ledger { Ledger() { } + Ledger(long currentBalance, @NonNull List transactions) { + mCurrentBalance = currentBalance; + mTransactions.addAll(transactions); + } + long getCurrentBalance() { return mCurrentBalance; } @@ -73,6 +78,11 @@ class Ledger { return null; } + @NonNull + List getTransactions() { + return mTransactions; + } + void recordTransaction(@NonNull Transaction transaction) { mTransactions.add(transaction); mCurrentBalance += transaction.delta; diff --git a/apex/jobscheduler/service/java/com/android/server/tare/Scribe.java b/apex/jobscheduler/service/java/com/android/server/tare/Scribe.java index 2c133dcbf73f2..48a373b2c9244 100644 --- a/apex/jobscheduler/service/java/com/android/server/tare/Scribe.java +++ b/apex/jobscheduler/service/java/com/android/server/tare/Scribe.java @@ -21,11 +21,34 @@ import static android.text.format.DateUtils.HOUR_IN_MILLIS; import static com.android.server.tare.TareUtils.appToString; import android.annotation.NonNull; +import android.annotation.Nullable; +import android.hardware.biometrics.face.V1_0.UserHandle; +import android.os.Environment; +import android.util.AtomicFile; import android.util.IndentingPrintWriter; import android.util.Log; +import android.util.Pair; +import android.util.Slog; import android.util.SparseArrayMap; +import android.util.TypedXmlPullParser; +import android.util.TypedXmlSerializer; +import android.util.Xml; import com.android.internal.annotations.GuardedBy; +import com.android.internal.annotations.VisibleForTesting; +import com.android.server.LocalServices; +import com.android.server.pm.UserManagerInternal; + +import org.xmlpull.v1.XmlPullParser; +import org.xmlpull.v1.XmlPullParserException; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; /** * Maintains the current TARE state and handles writing it to disk and reading it back from disk. @@ -44,40 +67,76 @@ public class Scribe { */ private static final long MAX_TRANSACTION_AGE_MS = 24 * HOUR_IN_MILLIS; + private static final String XML_TAG_HIGH_LEVEL_STATE = "irs-state"; + private static final String XML_TAG_LEDGER = "ledger"; + private static final String XML_TAG_TARE = "tare"; + private static final String XML_TAG_TRANSACTION = "transaction"; + private static final String XML_TAG_USER = "user"; + + private static final String XML_ATTR_DELTA = "delta"; + private static final String XML_ATTR_EVENT_ID = "eventId"; + private static final String XML_ATTR_TAG = "tag"; + private static final String XML_ATTR_START_TIME = "startTime"; + private static final String XML_ATTR_END_TIME = "endTime"; + private static final String XML_ATTR_PACKAGE_NAME = "pkgName"; + private static final String XML_ATTR_CURRENT_BALANCE = "currentBalance"; + private static final String XML_ATTR_USER_ID = "userId"; + private static final String XML_ATTR_VERSION = "version"; + private static final String XML_ATTR_LAST_RECLAMATION_TIME = "lastReclamationTime"; + + /** Version of the file schema. */ + private static final int STATE_FILE_VERSION = 0; + /** Minimum amount of time between consecutive writes. */ + private static final long WRITE_DELAY = 30_000L; + + private final AtomicFile mStateFile; private final InternalResourceService mIrs; - @GuardedBy("mIrs.mLock") + @GuardedBy("mIrs.getLock()") private long mLastReclamationTime; - @GuardedBy("mIrs.mLock") + @GuardedBy("mIrs.getLock()") private long mNarcsInCirculation; - @GuardedBy("mIrs.mLock") + @GuardedBy("mIrs.getLock()") private final SparseArrayMap mLedgers = new SparseArrayMap<>(); private final Runnable mCleanRunnable = this::cleanupLedgers; + private final Runnable mWriteRunnable = this::writeState; Scribe(InternalResourceService irs) { - mIrs = irs; + this(irs, Environment.getDataSystemDirectory()); } - @GuardedBy("mIrs.mLock") + @VisibleForTesting + Scribe(InternalResourceService irs, File dataDir) { + mIrs = irs; + + final File tareDir = new File(dataDir, "tare"); + //noinspection ResultOfMethodCallIgnored + tareDir.mkdirs(); + mStateFile = new AtomicFile(new File(tareDir, "state.xml"), "tare"); + } + + @GuardedBy("mIrs.getLock()") void adjustNarcsInCirculationLocked(long delta) { if (delta != 0) { // No point doing any work if the change is 0. mNarcsInCirculation += delta; + postWrite(); } } - @GuardedBy("mIrs.mLock") + @GuardedBy("mIrs.getLock()") void discardLedgerLocked(final int userId, @NonNull final String pkgName) { mLedgers.delete(userId, pkgName); + postWrite(); } - @GuardedBy("mIrs.mLock") + @GuardedBy("mIrs.getLock()") long getLastReclamationTimeLocked() { return mLastReclamationTime; } - @GuardedBy("mIrs.mLock") + @GuardedBy("mIrs.getLock()") @NonNull Ledger getLedgerLocked(final int userId, @NonNull final String pkgName) { Ledger ledger = mLedgers.get(userId, pkgName); @@ -89,33 +148,107 @@ public class Scribe { } /** Returns the total amount of narcs currently allocated to apps. */ - @GuardedBy("mIrs.mLock") + @GuardedBy("mIrs.getLock()") long getNarcsInCirculationLocked() { return mNarcsInCirculation; } - @GuardedBy("mIrs.mLock") - void setLastReclamationTimeLocked(long time) { - mLastReclamationTime = time; + @GuardedBy("mIrs.getLock()") + void loadFromDiskLocked() { + mLedgers.clear(); + mNarcsInCirculation = 0; + if (!recordExists()) { + return; + } + + UserManagerInternal userManagerInternal = + LocalServices.getService(UserManagerInternal.class); + final int[] userIds = userManagerInternal.getUserIds(); + Arrays.sort(userIds); + + try (FileInputStream fis = mStateFile.openRead()) { + TypedXmlPullParser parser = Xml.resolvePullParser(fis); + + int eventType = parser.getEventType(); + while (eventType != XmlPullParser.START_TAG + && eventType != XmlPullParser.END_DOCUMENT) { + eventType = parser.next(); + } + if (eventType == XmlPullParser.END_DOCUMENT) { + if (DEBUG) { + Slog.w(TAG, "No persisted state."); + } + return; + } + + String tagName = parser.getName(); + if (XML_TAG_TARE.equals(tagName)) { + final int version = parser.getAttributeInt(null, XML_ATTR_VERSION); + if (version < 0 || version > STATE_FILE_VERSION) { + Slog.e(TAG, "Invalid version number (" + version + "), aborting file read"); + return; + } + } + + final long endTimeCutoff = System.currentTimeMillis() - MAX_TRANSACTION_AGE_MS; + long earliestEndTime = Long.MAX_VALUE; + for (eventType = parser.next(); eventType != XmlPullParser.END_DOCUMENT; + eventType = parser.next()) { + if (eventType != XmlPullParser.START_TAG) { + continue; + } + tagName = parser.getName(); + if (tagName == null) { + continue; + } + + switch (tagName) { + case XML_TAG_HIGH_LEVEL_STATE: + mLastReclamationTime = + parser.getAttributeLong(null, XML_ATTR_LAST_RECLAMATION_TIME); + break; + case XML_TAG_USER: + earliestEndTime = Math.min(earliestEndTime, + readUserFromXmlLocked(parser, userIds, endTimeCutoff)); + break; + default: + Slog.e(TAG, "Unexpected tag: " + tagName); + break; + } + } + scheduleCleanup(earliestEndTime); + } catch (IOException | XmlPullParserException e) { + Slog.wtf(TAG, "Error reading state from disk", e); + } } - @GuardedBy("mIrs.mLock") + @VisibleForTesting + void postWrite() { + TareHandlerThread.getHandler().postDelayed(mWriteRunnable, WRITE_DELAY); + } + + boolean recordExists() { + return mStateFile.exists(); + } + + @GuardedBy("mIrs.getLock()") + void setLastReclamationTimeLocked(long time) { + mLastReclamationTime = time; + postWrite(); + } + + @GuardedBy("mIrs.getLock()") void tearDownLocked() { + TareHandlerThread.getHandler().removeCallbacks(mCleanRunnable); + TareHandlerThread.getHandler().removeCallbacks(mWriteRunnable); mLedgers.clear(); mNarcsInCirculation = 0; mLastReclamationTime = 0; } - private void scheduleCleanup(long earliestEndTime) { - if (earliestEndTime == Long.MAX_VALUE) { - return; - } - // This is just cleanup to manage memory. We don't need to do it too often or at the exact - // intended real time, so the delay that comes from using the Handler (and is limited - // to uptime) should be fine. - final long delayMs = Math.max(HOUR_IN_MILLIS, - earliestEndTime + MAX_TRANSACTION_AGE_MS - System.currentTimeMillis()); - TareHandlerThread.getHandler().postDelayed(mCleanRunnable, delayMs); + @VisibleForTesting + void writeImmediatelyForTesting() { + mWriteRunnable.run(); } private void cleanupLedgers() { @@ -139,7 +272,206 @@ public class Scribe { } } - @GuardedBy("mIrs.mLock") + /** + * @param parser Xml parser at the beginning of a "" tag. The next "parser.next()" call + * will take the parser into the body of the ledger tag. + * @return Newly instantiated ledger holding all the information we just read out of the xml + * tag, and the package name associated with the ledger. + */ + @Nullable + private static Pair readLedgerFromXml(TypedXmlPullParser parser, + long endTimeCutoff) throws XmlPullParserException, IOException { + final String pkgName; + final long curBalance; + final List transactions = new ArrayList<>(); + + pkgName = parser.getAttributeValue(null, XML_ATTR_PACKAGE_NAME); + curBalance = parser.getAttributeLong(null, XML_ATTR_CURRENT_BALANCE); + + for (int eventType = parser.next(); eventType != XmlPullParser.END_DOCUMENT; + eventType = parser.next()) { + final String tagName = parser.getName(); + if (eventType == XmlPullParser.END_TAG) { + if (XML_TAG_LEDGER.equals(tagName)) { + // We've reached the end of the ledger tag. + break; + } + continue; + } + if (eventType != XmlPullParser.START_TAG || !"transaction".equals(tagName)) { + // Expecting only "transaction" tags. + Slog.e(TAG, "Unexpected event: (" + eventType + ") " + tagName); + return null; + } + if (DEBUG) { + Slog.d(TAG, "Starting ledger tag: " + tagName); + } + final String tag = parser.getAttributeValue(null, XML_ATTR_TAG); + final long startTime = parser.getAttributeLong(null, XML_ATTR_START_TIME); + final long endTime = parser.getAttributeLong(null, XML_ATTR_END_TIME); + final int eventId = parser.getAttributeInt(null, XML_ATTR_EVENT_ID); + final long delta = parser.getAttributeLong(null, XML_ATTR_DELTA); + if (endTime <= endTimeCutoff) { + if (DEBUG) { + Slog.d(TAG, "Skipping event because it's too old."); + } + continue; + } + transactions.add(new Ledger.Transaction(startTime, endTime, eventId, tag, delta)); + } + + return Pair.create(pkgName, new Ledger(curBalance, transactions)); + } + + /** + * @param parser Xml parser at the beginning of a "" tag. The next "parser.next()" call + * will take the parser into the body of the user tag. + * @return The earliest valid transaction end time found for the user. + */ + @GuardedBy("mIrs.getLock()") + private long readUserFromXmlLocked(TypedXmlPullParser parser, int[] validUserIds, + long endTimeCutoff) throws XmlPullParserException, IOException { + int curUser = parser.getAttributeInt(null, XML_ATTR_USER_ID); + if (Arrays.binarySearch(validUserIds, curUser) < 0) { + Slog.w(TAG, "Invalid user " + curUser + " is saved to disk"); + curUser = UserHandle.NONE; + // Don't return early since we need to go through all the ledger tags and get to the end + // of the user tag. + } + long earliestEndTime = Long.MAX_VALUE; + + for (int eventType = parser.next(); eventType != XmlPullParser.END_DOCUMENT; + eventType = parser.next()) { + final String tagName = parser.getName(); + if (eventType == XmlPullParser.END_TAG) { + if (XML_TAG_USER.equals(tagName)) { + // We've reached the end of the user tag. + break; + } + continue; + } + if (XML_TAG_LEDGER.equals(tagName)) { + if (curUser == UserHandle.NONE) { + continue; + } + final Pair ledgerData = readLedgerFromXml(parser, endTimeCutoff); + final Ledger ledger = ledgerData.second; + if (ledger != null) { + mLedgers.add(curUser, ledgerData.first, ledger); + mNarcsInCirculation += Math.max(0, ledger.getCurrentBalance()); + final Ledger.Transaction transaction = ledger.getEarliestTransaction(); + if (transaction != null) { + earliestEndTime = Math.min(earliestEndTime, transaction.endTimeMs); + } + } + } else { + Slog.e(TAG, "Unknown tag: " + tagName); + } + } + + return earliestEndTime; + } + + private void scheduleCleanup(long earliestEndTime) { + if (earliestEndTime == Long.MAX_VALUE) { + return; + } + // This is just cleanup to manage memory. We don't need to do it too often or at the exact + // intended real time, so the delay that comes from using the Handler (and is limited + // to uptime) should be fine. + final long delayMs = Math.max(HOUR_IN_MILLIS, + earliestEndTime + MAX_TRANSACTION_AGE_MS - System.currentTimeMillis()); + TareHandlerThread.getHandler().postDelayed(mCleanRunnable, delayMs); + } + + private void writeState() { + synchronized (mIrs.getLock()) { + TareHandlerThread.getHandler().removeCallbacks(mWriteRunnable); + // Remove mCleanRunnable callbacks since we're going to clean up the ledgers before + // writing anyway. + TareHandlerThread.getHandler().removeCallbacks(mCleanRunnable); + if (!mIrs.isEnabled()) { + // If it's no longer enabled, we would have cleared all the data in memory and would + // accidentally write an empty file, thus deleting all the history. + return; + } + long earliestStoredEndTime = Long.MAX_VALUE; + try (FileOutputStream fos = mStateFile.startWrite()) { + TypedXmlSerializer out = Xml.resolveSerializer(fos); + out.startDocument(null, true); + + out.startTag(null, XML_TAG_TARE); + out.attributeInt(null, XML_ATTR_VERSION, STATE_FILE_VERSION); + + out.startTag(null, XML_TAG_HIGH_LEVEL_STATE); + out.attributeLong(null, XML_ATTR_LAST_RECLAMATION_TIME, mLastReclamationTime); + out.endTag(null, XML_TAG_HIGH_LEVEL_STATE); + + for (int uIdx = mLedgers.numMaps() - 1; uIdx >= 0; --uIdx) { + final int userId = mLedgers.keyAt(uIdx); + earliestStoredEndTime = Math.min(earliestStoredEndTime, + writeUserLocked(out, userId)); + } + + out.endTag(null, XML_TAG_TARE); + + out.endDocument(); + mStateFile.finishWrite(fos); + } catch (IOException e) { + Slog.e(TAG, "Error writing state to disk", e); + } + scheduleCleanup(earliestStoredEndTime); + } + } + + @GuardedBy("mIrs.getLock()") + private long writeUserLocked(@NonNull TypedXmlSerializer out, final int userId) + throws IOException { + final int uIdx = mLedgers.indexOfKey(userId); + long earliestStoredEndTime = Long.MAX_VALUE; + + out.startTag(null, XML_TAG_USER); + out.attributeInt(null, XML_ATTR_USER_ID, userId); + for (int pIdx = mLedgers.numElementsForKey(userId) - 1; pIdx >= 0; --pIdx) { + final String pkgName = mLedgers.keyAt(uIdx, pIdx); + final Ledger ledger = mLedgers.get(userId, pkgName); + // Remove old transactions so we don't waste space storing them. + ledger.removeOldTransactions(MAX_TRANSACTION_AGE_MS); + + out.startTag(null, XML_TAG_LEDGER); + out.attribute(null, XML_ATTR_PACKAGE_NAME, pkgName); + out.attributeLong(null, + XML_ATTR_CURRENT_BALANCE, ledger.getCurrentBalance()); + + final List transactions = ledger.getTransactions(); + for (int t = 0; t < transactions.size(); ++t) { + Ledger.Transaction transaction = transactions.get(t); + if (t == 0) { + earliestStoredEndTime = Math.min(earliestStoredEndTime, transaction.endTimeMs); + } + writeTransaction(out, transaction); + } + out.endTag(null, XML_TAG_LEDGER); + } + out.endTag(null, XML_TAG_USER); + + return earliestStoredEndTime; + } + + private static void writeTransaction(@NonNull TypedXmlSerializer out, + @NonNull Ledger.Transaction transaction) throws IOException { + out.startTag(null, XML_TAG_TRANSACTION); + out.attributeLong(null, XML_ATTR_START_TIME, transaction.startTimeMs); + out.attributeLong(null, XML_ATTR_END_TIME, transaction.endTimeMs); + out.attributeInt(null, XML_ATTR_EVENT_ID, transaction.eventId); + if (transaction.tag != null) { + out.attribute(null, XML_ATTR_TAG, transaction.tag); + } + out.attributeLong(null, XML_ATTR_DELTA, transaction.delta); + out.endTag(null, XML_TAG_TRANSACTION); + } + + @GuardedBy("mIrs.getLock()") void dumpLocked(IndentingPrintWriter pw) { pw.println("Ledgers:"); pw.increaseIndent(); diff --git a/services/tests/mockingservicestests/src/com/android/server/tare/AgentTest.java b/services/tests/mockingservicestests/src/com/android/server/tare/AgentTest.java index 9e480453b4f4d..6751b804ad9e9 100644 --- a/services/tests/mockingservicestests/src/com/android/server/tare/AgentTest.java +++ b/services/tests/mockingservicestests/src/com/android/server/tare/AgentTest.java @@ -57,6 +57,11 @@ public class AgentTest { MockScribe(InternalResourceService irs) { super(irs); } + + @Override + void postWrite() { + // Do nothing + } } @Before diff --git a/services/tests/mockingservicestests/src/com/android/server/tare/ScribeTest.java b/services/tests/mockingservicestests/src/com/android/server/tare/ScribeTest.java new file mode 100644 index 0000000000000..e2a37eec40062 --- /dev/null +++ b/services/tests/mockingservicestests/src/com/android/server/tare/ScribeTest.java @@ -0,0 +1,222 @@ +/* + * Copyright (C) 2021 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.tare; + + +import static com.android.dx.mockito.inline.extended.ExtendedMockito.doReturn; +import static com.android.dx.mockito.inline.extended.ExtendedMockito.mockitoSession; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.Mockito.when; + +import android.content.Context; +import android.util.Log; +import android.util.SparseArrayMap; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + +import com.android.server.LocalServices; +import com.android.server.pm.UserManagerInternal; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoSession; +import org.mockito.quality.Strictness; + +import java.io.File; +import java.util.List; + +/** + * Tests for various Scribe behavior, including reading and writing correctly from file. + * + * atest FrameworksServicesTests:ScribeTest + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class ScribeTest { + private static final String TAG = "ScribeTest"; + + private static final int TEST_USER_ID = 27; + private static final String TEST_PACKAGE = "com.android.test"; + + private MockitoSession mMockingSession; + private Scribe mScribeUnderTest; + private File mTestFileDir; + + @Mock + private InternalResourceService mIrs; + @Mock + private UserManagerInternal mUserManagerInternal; + + private Context getContext() { + return InstrumentationRegistry.getContext(); + } + + @Before + public void setUp() throws Exception { + mMockingSession = mockitoSession() + .initMocks(this) + .strictness(Strictness.LENIENT) + .mockStatic(LocalServices.class) + .startMocking(); + doReturn(mUserManagerInternal) + .when(() -> LocalServices.getService(UserManagerInternal.class)); + when(mIrs.getLock()).thenReturn(new Object()); + when(mIrs.isEnabled()).thenReturn(true); + when(mUserManagerInternal.getUserIds()).thenReturn(new int[]{TEST_USER_ID}); + mTestFileDir = new File(getContext().getFilesDir(), "scribe_test"); + //noinspection ResultOfMethodCallIgnored + mTestFileDir.mkdirs(); + Log.d(TAG, "Saving data to '" + mTestFileDir + "'"); + mScribeUnderTest = new Scribe(mIrs, mTestFileDir); + } + + @After + public void tearDown() throws Exception { + mScribeUnderTest.tearDownLocked(); + if (mTestFileDir.exists() && !mTestFileDir.delete()) { + Log.w(TAG, "Failed to delete test file directory"); + } + if (mMockingSession != null) { + mMockingSession.finishMocking(); + } + } + + @Test + public void testWriteHighLevelStateToDisk() { + long lastReclamationTime = System.currentTimeMillis(); + long narcsInCirculation = 2000L; + + Ledger ledger = mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE); + ledger.recordTransaction(new Ledger.Transaction(0, 1000L, 1, null, 2000)); + // Negative ledger balance shouldn't affect the total circulation value. + ledger = mScribeUnderTest.getLedgerLocked(TEST_USER_ID + 1, TEST_PACKAGE); + ledger.recordTransaction(new Ledger.Transaction(0, 1000L, 1, null, -5000)); + mScribeUnderTest.setLastReclamationTimeLocked(lastReclamationTime); + mScribeUnderTest.writeImmediatelyForTesting(); + + mScribeUnderTest.loadFromDiskLocked(); + + assertEquals(lastReclamationTime, mScribeUnderTest.getLastReclamationTimeLocked()); + assertEquals(narcsInCirculation, mScribeUnderTest.getNarcsInCirculationLocked()); + } + + @Test + public void testWritingEmptyLedgerToDisk() { + final Ledger ogLedger = mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE); + mScribeUnderTest.writeImmediatelyForTesting(); + + mScribeUnderTest.loadFromDiskLocked(); + assertLedgersEqual(ogLedger, mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE)); + } + + @Test + public void testWritingPopulatedLedgerToDisk() { + final Ledger ogLedger = mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE); + ogLedger.recordTransaction(new Ledger.Transaction(0, 1000, 1, null, 51)); + ogLedger.recordTransaction(new Ledger.Transaction(1500, 2000, 2, "green", 52)); + ogLedger.recordTransaction(new Ledger.Transaction(2500, 3000, 3, "blue", 3)); + mScribeUnderTest.writeImmediatelyForTesting(); + + mScribeUnderTest.loadFromDiskLocked(); + assertLedgersEqual(ogLedger, mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE)); + } + + @Test + public void testWritingMultipleLedgersToDisk() { + final SparseArrayMap ledgers = new SparseArrayMap<>(); + final int numUsers = 3; + final int numLedgers = 5; + final int[] userIds = new int[numUsers]; + when(mUserManagerInternal.getUserIds()).thenReturn(userIds); + for (int u = 0; u < numUsers; ++u) { + final int userId = TEST_USER_ID + u; + userIds[u] = userId; + for (int l = 0; l < numLedgers; ++l) { + final String pkgName = TEST_PACKAGE + l; + final Ledger ledger = mScribeUnderTest.getLedgerLocked(userId, pkgName); + ledger.recordTransaction(new Ledger.Transaction( + 0, 1000L * u + l, 1, null, 51L * u + l)); + ledger.recordTransaction(new Ledger.Transaction( + 1500L * u + l, 2000L * u + l, 2 * u + l, "green" + u + l, 52L * u + l)); + ledger.recordTransaction(new Ledger.Transaction( + 2500L * u + l, 3000L * u + l, 3 * u + l, "blue" + u + l, 3L * u + l)); + ledgers.add(userId, pkgName, ledger); + } + } + mScribeUnderTest.writeImmediatelyForTesting(); + + mScribeUnderTest.loadFromDiskLocked(); + ledgers.forEach((userId, pkgName, ledger) + -> assertLedgersEqual(ledger, mScribeUnderTest.getLedgerLocked(userId, pkgName))); + } + + @Test + public void testDiscardLedgerFromDisk() { + final Ledger ogLedger = mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE); + ogLedger.recordTransaction(new Ledger.Transaction(0, 1000, 1, null, 51)); + ogLedger.recordTransaction(new Ledger.Transaction(1500, 2000, 2, "green", 52)); + ogLedger.recordTransaction(new Ledger.Transaction(2500, 3000, 3, "blue", 3)); + mScribeUnderTest.writeImmediatelyForTesting(); + + mScribeUnderTest.loadFromDiskLocked(); + assertLedgersEqual(ogLedger, mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE)); + + mScribeUnderTest.discardLedgerLocked(TEST_USER_ID, TEST_PACKAGE); + mScribeUnderTest.writeImmediatelyForTesting(); + + // Make sure there's no more saved ledger. + mScribeUnderTest.loadFromDiskLocked(); + assertLedgersEqual(new Ledger(), + mScribeUnderTest.getLedgerLocked(TEST_USER_ID, TEST_PACKAGE)); + } + + private void assertLedgersEqual(Ledger expected, Ledger actual) { + if (expected == null) { + assertNull(actual); + return; + } + assertNotNull(actual); + assertEquals(expected.getCurrentBalance(), actual.getCurrentBalance()); + List expectedTransactions = expected.getTransactions(); + List actualTransactions = actual.getTransactions(); + assertEquals(expectedTransactions.size(), actualTransactions.size()); + for (int i = 0; i < expectedTransactions.size(); ++i) { + assertTransactionsEqual(expectedTransactions.get(i), actualTransactions.get(i)); + } + } + + private void assertTransactionsEqual(Ledger.Transaction expected, Ledger.Transaction actual) { + if (expected == null) { + assertNull(actual); + return; + } + assertNotNull(actual); + assertEquals(expected.startTimeMs, actual.startTimeMs); + assertEquals(expected.endTimeMs, actual.endTimeMs); + assertEquals(expected.eventId, actual.eventId); + assertEquals(expected.tag, actual.tag); + assertEquals(expected.delta, actual.delta); + } +}