Merge "Allow using display port for window settings"
diff --git a/services/core/java/com/android/server/wm/DisplayWindowSettings.java b/services/core/java/com/android/server/wm/DisplayWindowSettings.java
index db96847..a46fa13 100644
--- a/services/core/java/com/android/server/wm/DisplayWindowSettings.java
+++ b/services/core/java/com/android/server/wm/DisplayWindowSettings.java
@@ -26,6 +26,8 @@
 import static com.android.server.wm.WindowManagerDebugConfig.TAG_WITH_CLASS_NAME;
 import static com.android.server.wm.WindowManagerDebugConfig.TAG_WM;
 
+import android.annotation.IntDef;
+import android.annotation.Nullable;
 import android.app.WindowConfiguration;
 import android.os.Environment;
 import android.provider.Settings;
@@ -33,6 +35,7 @@
 import android.util.Slog;
 import android.util.Xml;
 import android.view.Display;
+import android.view.DisplayAddress;
 import android.view.DisplayInfo;
 import android.view.Surface;
 
@@ -47,10 +50,11 @@
 import org.xmlpull.v1.XmlSerializer;
 
 import java.io.File;
-import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.FileOutputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.nio.charset.StandardCharsets;
 import java.util.HashMap;
 
@@ -60,9 +64,33 @@
 class DisplayWindowSettings {
     private static final String TAG = TAG_WITH_CLASS_NAME ? "DisplayWindowSettings" : TAG_WM;
 
+    private static final int IDENTIFIER_UNIQUE_ID = 0;
+    private static final int IDENTIFIER_PORT = 1;
+    @IntDef(prefix = { "IDENTIFIER_" }, value = {
+            IDENTIFIER_UNIQUE_ID,
+            IDENTIFIER_PORT,
+    })
+    @interface DisplayIdentifierType {}
+
     private final WindowManagerService mService;
-    private final AtomicFile mFile;
-    private final HashMap<String, Entry> mEntries = new HashMap<String, Entry>();
+    private final HashMap<String, Entry> mEntries = new HashMap<>();
+    private final SettingPersister mStorage;
+
+    /**
+     * The preferred type of a display identifier to use when storing and retrieving entries.
+     * {@link #getIdentifier(DisplayInfo)} must be used to get current preferred identifier for each
+     * display. It will fall back to using {@link #IDENTIFIER_UNIQUE_ID} if the currently selected
+     * one is not applicable to a particular display.
+     */
+    @DisplayIdentifierType
+    private int mIdentifier = IDENTIFIER_UNIQUE_ID;
+
+    /** Interface for persisting the display window settings. */
+    interface SettingPersister {
+        InputStream openRead() throws IOException;
+        OutputStream startWrite() throws IOException;
+        void finishWrite(OutputStream os, boolean success);
+    }
 
     private static class Entry {
         private final String mName;
@@ -88,6 +116,26 @@
             mName = name;
         }
 
+        private Entry(String name, Entry copyFrom) {
+            this(name);
+            mOverscanLeft = copyFrom.mOverscanLeft;
+            mOverscanTop = copyFrom.mOverscanTop;
+            mOverscanRight = copyFrom.mOverscanRight;
+            mOverscanBottom = copyFrom.mOverscanBottom;
+            mWindowingMode = copyFrom.mWindowingMode;
+            mUserRotationMode = copyFrom.mUserRotationMode;
+            mUserRotation = copyFrom.mUserRotation;
+            mForcedWidth = copyFrom.mForcedWidth;
+            mForcedHeight = copyFrom.mForcedHeight;
+            mForcedDensity = copyFrom.mForcedDensity;
+            mForcedScalingMode = copyFrom.mForcedScalingMode;
+            mRemoveContentMode = copyFrom.mRemoveContentMode;
+            mShouldShowWithInsecureKeyguard = copyFrom.mShouldShowWithInsecureKeyguard;
+            mShouldShowSystemDecors = copyFrom.mShouldShowSystemDecors;
+            mShouldShowIme = copyFrom.mShouldShowIme;
+            mFixedToUserRotation = copyFrom.mFixedToUserRotation;
+        }
+
         /** @return {@code true} if all values are default. */
         private boolean isEmpty() {
             return mOverscanLeft == 0 && mOverscanTop == 0 && mOverscanRight == 0
@@ -106,29 +154,46 @@
     }
 
     DisplayWindowSettings(WindowManagerService service) {
-        this(service, new File(Environment.getDataDirectory(), "system"));
+        this(service, new AtomicFileStorage());
     }
 
     @VisibleForTesting
-    DisplayWindowSettings(WindowManagerService service, File folder) {
+    DisplayWindowSettings(WindowManagerService service, SettingPersister storageImpl) {
         mService = service;
-        mFile = new AtomicFile(new File(folder, "display_settings.xml"), "wm-displays");
+        mStorage = storageImpl;
         readSettings();
     }
 
-    private Entry getEntry(DisplayInfo displayInfo) {
-        // Try to get the entry with the unique if possible.
-        // Else, fall back on the display name.
+    private @Nullable Entry getEntry(DisplayInfo displayInfo) {
+        final String identifier = getIdentifier(displayInfo);
         Entry entry;
-        if (displayInfo.uniqueId == null || (entry = mEntries.get(displayInfo.uniqueId)) == null) {
-            entry = mEntries.get(displayInfo.name);
+        // Try to get corresponding entry using preferred identifier for the current config.
+        if ((entry = mEntries.get(identifier)) != null) {
+            return entry;
         }
-        return entry;
+        // Else, fall back to the display name.
+        if ((entry = mEntries.get(displayInfo.name)) != null) {
+            // Found an entry stored with old identifier - upgrade to the new type now.
+            return updateIdentifierForEntry(entry, displayInfo);
+        }
+        return null;
     }
 
     private Entry getOrCreateEntry(DisplayInfo displayInfo) {
         final Entry entry = getEntry(displayInfo);
-        return entry != null ? entry : new Entry(displayInfo.uniqueId);
+        return entry != null ? entry : new Entry(getIdentifier(displayInfo));
+    }
+
+    /**
+     * Upgrades the identifier of a legacy entry. Does it by copying the data from the old record
+     * and clearing the old key in memory. The entry will be written to storage next time when a
+     * setting changes.
+     */
+    private Entry updateIdentifierForEntry(Entry entry, DisplayInfo displayInfo) {
+        final Entry newEntry = new Entry(getIdentifier(displayInfo), entry);
+        removeEntry(displayInfo);
+        mEntries.put(newEntry.mName, newEntry);
+        return newEntry;
     }
 
     void setOverscanLocked(DisplayInfo displayInfo, int left, int top, int right, int bottom) {
@@ -371,12 +436,11 @@
     }
 
     private void readSettings() {
-        FileInputStream stream;
+        InputStream stream;
         try {
-            stream = mFile.openRead();
-        } catch (FileNotFoundException e) {
-            Slog.i(TAG, "No existing display settings " + mFile.getBaseFile()
-                    + "; starting empty");
+            stream = mStorage.openRead();
+        } catch (IOException e) {
+            Slog.i(TAG, "No existing display settings, starting empty");
             return;
         }
         boolean success = false;
@@ -403,6 +467,8 @@
                 String tagName = parser.getName();
                 if (tagName.equals("display")) {
                     readDisplay(parser);
+                } else if (tagName.equals("config")) {
+                    readConfig(parser);
                 } else {
                     Slog.w(TAG, "Unknown element under <display-settings>: "
                             + parser.getName());
@@ -491,22 +557,26 @@
         XmlUtils.skipCurrentTag(parser);
     }
 
+    private void readConfig(XmlPullParser parser) throws NumberFormatException,
+            XmlPullParserException, IOException {
+        mIdentifier = getIntAttribute(parser, "identifier");
+        XmlUtils.skipCurrentTag(parser);
+    }
+
     private void writeSettingsIfNeeded(Entry changedEntry, DisplayInfo displayInfo) {
-        if (changedEntry.isEmpty()) {
-            boolean removed = mEntries.remove(displayInfo.uniqueId) != null;
-            // Legacy name might have been in used, so we need to clear it.
-            removed |= mEntries.remove(displayInfo.name) != null;
-            if (!removed) {
-                // The entry didn't exist so nothing is changed and no need to update the file.
-                return;
-            }
-        } else {
-            mEntries.put(displayInfo.uniqueId, changedEntry);
+        if (changedEntry.isEmpty() && !removeEntry(displayInfo)) {
+            // The entry didn't exist so nothing is changed and no need to update the file.
+            return;
         }
 
-        FileOutputStream stream;
+        mEntries.put(getIdentifier(displayInfo), changedEntry);
+        writeSettings();
+    }
+
+    private void writeSettings() {
+        OutputStream stream;
         try {
-            stream = mFile.startWrite();
+            stream = mStorage.startWrite();
         } catch (IOException e) {
             Slog.w(TAG, "Failed to write display settings: " + e);
             return;
@@ -516,8 +586,13 @@
             XmlSerializer out = new FastXmlSerializer();
             out.setOutput(stream, StandardCharsets.UTF_8.name());
             out.startDocument(null, true);
+
             out.startTag(null, "display-settings");
 
+            out.startTag(null, "config");
+            out.attribute(null, "identifier", Integer.toString(mIdentifier));
+            out.endTag(null, "config");
+
             for (Entry entry : mEntries.values()) {
                 out.startTag(null, "display");
                 out.attribute(null, "name", entry.mName);
@@ -578,10 +653,66 @@
 
             out.endTag(null, "display-settings");
             out.endDocument();
-            mFile.finishWrite(stream);
+            mStorage.finishWrite(stream, true /* success */);
         } catch (IOException e) {
-            Slog.w(TAG, "Failed to write display settings, restoring backup.", e);
-            mFile.failWrite(stream);
+            Slog.w(TAG, "Failed to write display window settings.", e);
+            mStorage.finishWrite(stream, false /* success */);
+        }
+    }
+
+    /**
+     * Removes an entry from {@link #mEntries} cache. Looks up by new and previously used
+     * identifiers.
+     */
+    private boolean removeEntry(DisplayInfo displayInfo) {
+        // Remove entry based on primary identifier.
+        boolean removed = mEntries.remove(getIdentifier(displayInfo)) != null;
+        // Ensure that legacy entries are cleared as well.
+        removed |= mEntries.remove(displayInfo.uniqueId) != null;
+        removed |= mEntries.remove(displayInfo.name) != null;
+        return removed;
+    }
+
+    /** Gets the identifier of choice for the current config. */
+    private String getIdentifier(DisplayInfo displayInfo) {
+        if (mIdentifier == IDENTIFIER_PORT && displayInfo.address != null) {
+            // Config suggests using port as identifier for physical displays.
+            if (displayInfo.address instanceof DisplayAddress.Physical) {
+                return "port:" + ((DisplayAddress.Physical) displayInfo.address).getPort();
+            }
+        }
+        return displayInfo.uniqueId;
+    }
+
+    private static class AtomicFileStorage implements SettingPersister {
+        private final AtomicFile mAtomicFile;
+
+        AtomicFileStorage() {
+            final File folder = new File(Environment.getDataDirectory(), "system");
+            mAtomicFile = new AtomicFile(new File(folder, "display_settings.xml"), "wm-displays");
+        }
+
+        @Override
+        public InputStream openRead() throws FileNotFoundException {
+            return mAtomicFile.openRead();
+        }
+
+        @Override
+        public OutputStream startWrite() throws IOException {
+            return mAtomicFile.startWrite();
+        }
+
+        @Override
+        public void finishWrite(OutputStream os, boolean success) {
+            if (!(os instanceof FileOutputStream)) {
+                throw new IllegalArgumentException("Unexpected OutputStream as argument: " + os);
+            }
+            FileOutputStream fos = (FileOutputStream) os;
+            if (success) {
+                mAtomicFile.finishWrite(fos);
+            } else {
+                mAtomicFile.failWrite(fos);
+            }
         }
     }
 }
diff --git a/services/tests/wmtests/src/com/android/server/wm/DisplayWindowSettingsTests.java b/services/tests/wmtests/src/com/android/server/wm/DisplayWindowSettingsTests.java
index 9a8a732..652ea7d 100644
--- a/services/tests/wmtests/src/com/android/server/wm/DisplayWindowSettingsTests.java
+++ b/services/tests/wmtests/src/com/android/server/wm/DisplayWindowSettingsTests.java
@@ -16,6 +16,8 @@
 
 package com.android.server.wm;
 
+import static android.app.WindowConfiguration.WINDOWING_MODE_FREEFORM;
+import static android.app.WindowConfiguration.WINDOWING_MODE_PINNED;
 import static android.view.WindowManager.REMOVE_CONTENT_MODE_DESTROY;
 import static android.view.WindowManager.REMOVE_CONTENT_MODE_MOVE_TO_PRIMARY;
 
@@ -40,7 +42,9 @@
 
 import android.app.WindowConfiguration;
 import android.platform.test.annotations.Presubmit;
+import android.util.Xml;
 import android.view.Display;
+import android.view.DisplayAddress;
 import android.view.DisplayInfo;
 import android.view.Surface;
 
@@ -53,14 +57,22 @@
 import org.junit.Before;
 import org.junit.Test;
 import org.mockito.MockitoSession;
+import org.xmlpull.v1.XmlPullParser;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
 import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
 
 /**
  * Tests for the {@link DisplayWindowSettings} class.
  *
  * Build/Install/Run:
- *  atest FrameworksServicesTests:DisplayWindowSettingsTests
+ *  atest WmTests:DisplayWindowSettingsTests
  */
 @SmallTest
 @Presubmit
@@ -69,12 +81,14 @@
     private static final File TEST_FOLDER = getInstrumentation().getTargetContext().getCacheDir();
     private DisplayWindowSettings mTarget;
 
-    DisplayInfo mPrivateDisplayInfo;
+    private DisplayInfo mPrivateDisplayInfo;
 
     private DisplayContent mPrimaryDisplay;
     private DisplayContent mSecondaryDisplay;
     private DisplayContent mPrivateDisplay;
 
+    private TestStorage mStorage;
+
     @Before
     public void setUp() throws Exception {
         deleteRecursively(TEST_FOLDER);
@@ -83,7 +97,8 @@
         mWm.setIsPc(false);
         mWm.setForceDesktopModeOnExternalDisplays(false);
 
-        mTarget = new DisplayWindowSettings(mWm, TEST_FOLDER);
+        mStorage = new TestStorage();
+        mTarget = new DisplayWindowSettings(mWm, mStorage);
 
         mPrimaryDisplay = mWm.getDefaultDisplayContentLocked();
         mSecondaryDisplay = mDisplayContent;
@@ -143,7 +158,7 @@
 
         mTarget.applySettingsToDisplayLocked(mPrimaryDisplay);
 
-        assertEquals(WindowConfiguration.WINDOWING_MODE_FREEFORM,
+        assertEquals(WINDOWING_MODE_FREEFORM,
                 mPrimaryDisplay.getWindowingMode());
     }
 
@@ -185,7 +200,7 @@
 
         mTarget.applySettingsToDisplayLocked(mSecondaryDisplay);
 
-        assertEquals(WindowConfiguration.WINDOWING_MODE_FREEFORM,
+        assertEquals(WINDOWING_MODE_FREEFORM,
                 mSecondaryDisplay.getWindowingMode());
     }
 
@@ -196,7 +211,7 @@
 
         mTarget.applySettingsToDisplayLocked(mSecondaryDisplay);
 
-        assertEquals(WindowConfiguration.WINDOWING_MODE_FREEFORM,
+        assertEquals(WINDOWING_MODE_FREEFORM,
                 mSecondaryDisplay.getWindowingMode());
     }
 
@@ -474,6 +489,171 @@
         mockitoSession.finishMocking();
     }
 
+    @Test
+    public void testReadingDisplaySettingsFromStorage() {
+        final String displayIdentifier = mSecondaryDisplay.getDisplayInfo().uniqueId;
+        prepareDisplaySettings(displayIdentifier);
+
+        readAndAssertDisplaySettings(mPrimaryDisplay);
+    }
+
+    @Test
+    public void testReadingDisplaySettingsFromStorage_LegacyDisplayId() {
+        final String displayIdentifier = mPrimaryDisplay.getDisplayInfo().name;
+        prepareDisplaySettings(displayIdentifier);
+
+        readAndAssertDisplaySettings(mPrimaryDisplay);
+    }
+
+    @Test
+    public void testReadingDisplaySettingsFromStorage_LegacyDisplayId_UpdateAfterAccess()
+            throws Exception {
+        // Store display settings with legacy display identifier.
+        final String displayIdentifier = mPrimaryDisplay.getDisplayInfo().name;
+        prepareDisplaySettings(displayIdentifier);
+
+        // Update settings with new value, should trigger write to injector.
+        final DisplayWindowSettings settings = new DisplayWindowSettings(mWm, mStorage);
+        settings.setRemoveContentModeLocked(mPrimaryDisplay, REMOVE_CONTENT_MODE_MOVE_TO_PRIMARY);
+        assertEquals("Settings value must be updated", REMOVE_CONTENT_MODE_MOVE_TO_PRIMARY,
+                settings.getRemoveContentModeLocked(mPrimaryDisplay));
+        assertTrue(mStorage.wasWriteSuccessful());
+
+        // Verify that display identifier was updated.
+        final String newDisplayIdentifier = getStoredDisplayAttributeValue("name");
+        assertEquals("Display identifier must be updated to use uniqueId",
+                mPrimaryDisplay.getDisplayInfo().uniqueId, newDisplayIdentifier);
+    }
+
+    @Test
+    public void testReadingDisplaySettingsFromStorage_UsePortAsId() {
+        final DisplayAddress.Physical displayAddress = DisplayAddress.fromPhysicalDisplayId(123456);
+        mPrimaryDisplay.getDisplayInfo().address = displayAddress;
+
+        final String displayIdentifier = "port:" + displayAddress.getPort();
+        prepareDisplaySettings(displayIdentifier, true /* usePortAsId */);
+
+        readAndAssertDisplaySettings(mPrimaryDisplay);
+    }
+
+    @Test
+    public void testReadingDisplaySettingsFromStorage_UsePortAsId_IncorrectAddress() {
+        final String displayIdentifier = mPrimaryDisplay.getDisplayInfo().uniqueId;
+        prepareDisplaySettings(displayIdentifier, true /* usePortAsId */);
+
+        mPrimaryDisplay.getDisplayInfo().address = DisplayAddress.fromPhysicalDisplayId(123456);
+
+        // Verify that the entry is not matched and default settings are returned instead.
+        final DisplayWindowSettings settings = new DisplayWindowSettings(mWm);
+        assertNotEquals("Default setting must be returned for new entry",
+                WINDOWING_MODE_PINNED, settings.getWindowingModeLocked(mPrimaryDisplay));
+    }
+
+    @Test
+    public void testWritingDisplaySettingsToStorage() throws Exception {
+        // Write some settings to storage.
+        final DisplayWindowSettings settings = new DisplayWindowSettings(mWm, mStorage);
+        settings.setShouldShowSystemDecorsLocked(mSecondaryDisplay, true);
+        settings.setShouldShowImeLocked(mSecondaryDisplay, true);
+        assertTrue(mStorage.wasWriteSuccessful());
+
+        // Verify that settings were stored correctly.
+        assertEquals("Attribute value must be stored", mSecondaryDisplay.getDisplayInfo().uniqueId,
+                getStoredDisplayAttributeValue("name"));
+        assertEquals("Attribute value must be stored", "true",
+                getStoredDisplayAttributeValue("shouldShowSystemDecors"));
+        assertEquals("Attribute value must be stored", "true",
+                getStoredDisplayAttributeValue("shouldShowIme"));
+    }
+
+    @Test
+    public void testWritingDisplaySettingsToStorage_UsePortAsId() throws Exception {
+        // Store config to use port as identifier.
+        final DisplayAddress.Physical displayAddress = DisplayAddress.fromPhysicalDisplayId(123456);
+        mSecondaryDisplay.getDisplayInfo().address = displayAddress;
+        prepareDisplaySettings(null /* displayIdentifier */, true /* usePortAsId */);
+
+        // Write some settings.
+        final DisplayWindowSettings settings = new DisplayWindowSettings(mWm, mStorage);
+        settings.setShouldShowSystemDecorsLocked(mSecondaryDisplay, true);
+        settings.setShouldShowImeLocked(mSecondaryDisplay, true);
+        assertTrue(mStorage.wasWriteSuccessful());
+
+        // Verify that settings were stored correctly.
+        assertEquals("Attribute value must be stored", "port:" + displayAddress.getPort(),
+                getStoredDisplayAttributeValue("name"));
+        assertEquals("Attribute value must be stored", "true",
+                getStoredDisplayAttributeValue("shouldShowSystemDecors"));
+        assertEquals("Attribute value must be stored", "true",
+                getStoredDisplayAttributeValue("shouldShowIme"));
+    }
+
+    /**
+     * Prepares display settings and stores in {@link #mStorage}. Uses provided display identifier
+     * and stores windowingMode=WINDOWING_MODE_PINNED.
+     */
+    private void prepareDisplaySettings(String displayIdentifier) {
+        prepareDisplaySettings(displayIdentifier, false /* usePortAsId */);
+    }
+
+    private void prepareDisplaySettings(String displayIdentifier, boolean usePortAsId) {
+        String contents = "<?xml version='1.0' encoding='utf-8' standalone='yes' ?>\n"
+                + "<display-settings>\n";
+        if (usePortAsId) {
+            contents += "  <config identifier=\"1\"/>\n";
+        }
+        if (displayIdentifier != null) {
+            contents += "  <display\n"
+                    + "    name=\"" + displayIdentifier + "\"\n"
+                    + "    windowingMode=\"" + WINDOWING_MODE_PINNED + "\"/>\n";
+        }
+        contents += "</display-settings>\n";
+
+        final InputStream is = new ByteArrayInputStream(contents.getBytes(StandardCharsets.UTF_8));
+        mStorage.setReadStream(is);
+    }
+
+    private void readAndAssertDisplaySettings(DisplayContent displayContent) {
+        final DisplayWindowSettings settings = new DisplayWindowSettings(mWm, mStorage);
+        assertEquals("Stored setting must be read",
+                WINDOWING_MODE_PINNED, settings.getWindowingModeLocked(displayContent));
+        assertEquals("Not stored setting must be set to default value",
+                REMOVE_CONTENT_MODE_MOVE_TO_PRIMARY,
+                settings.getRemoveContentModeLocked(displayContent));
+    }
+
+    private String getStoredDisplayAttributeValue(String attr) throws Exception {
+        try (InputStream stream = mStorage.openRead()) {
+            XmlPullParser parser = Xml.newPullParser();
+            parser.setInput(stream, StandardCharsets.UTF_8.name());
+            int type;
+            while ((type = parser.next()) != XmlPullParser.START_TAG
+                    && type != XmlPullParser.END_DOCUMENT) {
+                // Do nothing.
+            }
+
+            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("display")) {
+                    return parser.getAttributeValue(null, attr);
+                }
+            }
+        } finally {
+            mStorage.closeRead();
+        }
+        return null;
+    }
+
     private static void assertOverscan(DisplayContent display, int left, int top, int right,
             int bottom) {
         final DisplayInfo info = display.getDisplayInfo();
@@ -490,7 +670,11 @@
      * path that also means the previous state must be written correctly.
      */
     private void applySettingsToDisplayByNewInstance(DisplayContent display) {
-        new DisplayWindowSettings(mWm, TEST_FOLDER).applySettingsToDisplayLocked(display);
+        // Assert that prior write completed successfully.
+        assertTrue(mStorage.wasWriteSuccessful());
+
+        // Read and apply settings.
+        new DisplayWindowSettings(mWm, mStorage).applySettingsToDisplayLocked(display);
     }
 
     private static boolean deleteRecursively(File file) {
@@ -506,4 +690,81 @@
         }
         return fullyDeleted;
     }
+
+    /** In-memory storage implementation. */
+    public class TestStorage implements DisplayWindowSettings.SettingPersister {
+        private InputStream mReadStream;
+        private ByteArrayOutputStream mWriteStream;
+
+        private boolean mWasSuccessful;
+
+        /**
+         * Returns input stream for reading. By default tries forward the output stream if previous
+         * write was successful.
+         * @see #closeRead()
+         */
+        @Override
+        public InputStream openRead() throws FileNotFoundException {
+            if (mReadStream == null && mWasSuccessful) {
+                mReadStream = new ByteArrayInputStream(mWriteStream.toByteArray());
+            }
+            if (mReadStream == null) {
+                throw new FileNotFoundException();
+            }
+            if (mReadStream.markSupported()) {
+                mReadStream.mark(Integer.MAX_VALUE);
+            }
+            return mReadStream;
+        }
+
+        /** Must be called after each {@link #openRead} to reset the position in the stream. */
+        void closeRead() throws IOException {
+            if (mReadStream == null) {
+                throw new FileNotFoundException();
+            }
+            if (mReadStream.markSupported()) {
+                mReadStream.reset();
+            }
+            mReadStream = null;
+        }
+
+        /**
+         * Creates new or resets existing output stream for write. Automatically closes previous
+         * read stream, since following reads should happen based on this new write.
+         */
+        @Override
+        public OutputStream startWrite() throws IOException {
+            if (mWriteStream == null) {
+                mWriteStream = new ByteArrayOutputStream();
+            } else {
+                mWriteStream.reset();
+            }
+            if (mReadStream != null) {
+                closeRead();
+            }
+            return mWriteStream;
+        }
+
+        @Override
+        public void finishWrite(OutputStream os, boolean success) {
+            mWasSuccessful = success;
+            try {
+                os.close();
+            } catch (IOException e) {
+                // This method can't throw IOException since the super implementation doesn't, so
+                // we just wrap it in a RuntimeException so we end up crashing the test all the
+                // same.
+                throw new RuntimeException(e);
+            }
+        }
+
+        /** Override the read stream of the injector. By default it uses current write stream. */
+        private void setReadStream(InputStream is) {
+            mReadStream = is;
+        }
+
+        private boolean wasWriteSuccessful() {
+            return mWasSuccessful;
+        }
+    }
 }