Move backup encryption to separate APK

Test: atest -c --rebuild-module-info BackupEncryptionRoboTests
Change-Id: I5a8ac3a9c010bd3c516464dee333cef406c5dcfa
diff --git a/packages/BackupEncryption/test/robolectric/Android.bp b/packages/BackupEncryption/test/robolectric/Android.bp
new file mode 100644
index 0000000..6d1abbb
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/Android.bp
@@ -0,0 +1,32 @@
+// Copyright (C) 2018 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.
+
+android_robolectric_test {
+    name: "BackupEncryptionRoboTests",
+    srcs: [
+        "src/**/*.java",
+        ":FrameworksServicesRoboShadows",
+    ],
+    java_resource_dirs: ["config"],
+    libs: [
+        "platform-test-annotations",
+        "testng",
+    ],
+    instrumentation_for: "BackupEncryption",
+}
+
+filegroup {
+    name: "BackupEncryptionRoboShadows",
+    srcs: ["src/com/android/server/testing/shadows/**/*.java"],
+}
diff --git a/packages/BackupEncryption/test/robolectric/AndroidManifest.xml b/packages/BackupEncryption/test/robolectric/AndroidManifest.xml
new file mode 100644
index 0000000..ae5cdd9
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/AndroidManifest.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  Copyright (C) 2019 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.
+  -->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          coreApp="true"
+          package="com.android.server.backup.encryption.robotests">
+
+    <application/>
+
+</manifest>
diff --git a/packages/BackupEncryption/test/robolectric/config/robolectric.properties b/packages/BackupEncryption/test/robolectric/config/robolectric.properties
new file mode 100644
index 0000000..26fceb3
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/config/robolectric.properties
@@ -0,0 +1,17 @@
+#
+# Copyright (C) 2019 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.
+#
+
+sdk=NEWEST_SDK
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkHashTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkHashTest.java
new file mode 100644
index 0000000..c12464c
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkHashTest.java
@@ -0,0 +1,100 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunk;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Arrays;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ChunkHashTest {
+    private static final int HASH_LENGTH_BYTES = 256 / 8;
+    private static final byte[] TEST_HASH_1 = Arrays.copyOf(new byte[] {1}, HASH_LENGTH_BYTES);
+    private static final byte[] TEST_HASH_2 = Arrays.copyOf(new byte[] {2}, HASH_LENGTH_BYTES);
+
+    @Test
+    public void testGetHash_returnsHash() {
+        ChunkHash chunkHash = new ChunkHash(TEST_HASH_1);
+
+        byte[] hash = chunkHash.getHash();
+
+        assertThat(hash).asList().containsExactlyElementsIn(Bytes.asList(TEST_HASH_1)).inOrder();
+    }
+
+    @Test
+    public void testEquals() {
+        ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1);
+        ChunkHash equalChunkHash1 = new ChunkHash(TEST_HASH_1);
+        ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2);
+
+        assertThat(chunkHash1).isEqualTo(equalChunkHash1);
+        assertThat(chunkHash1).isNotEqualTo(chunkHash2);
+    }
+
+    @Test
+    public void testHashCode() {
+        ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1);
+        ChunkHash equalChunkHash1 = new ChunkHash(TEST_HASH_1);
+        ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2);
+
+        int hash1 = chunkHash1.hashCode();
+        int equalHash1 = equalChunkHash1.hashCode();
+        int hash2 = chunkHash2.hashCode();
+
+        assertThat(hash1).isEqualTo(equalHash1);
+        assertThat(hash1).isNotEqualTo(hash2);
+    }
+
+    @Test
+    public void testCompareTo_whenEqual_returnsZero() {
+        ChunkHash chunkHash = new ChunkHash(TEST_HASH_1);
+        ChunkHash equalChunkHash = new ChunkHash(TEST_HASH_1);
+
+        int result = chunkHash.compareTo(equalChunkHash);
+
+        assertThat(result).isEqualTo(0);
+    }
+
+    @Test
+    public void testCompareTo_whenArgumentGreater_returnsNegative() {
+        ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1);
+        ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2);
+
+        int result = chunkHash1.compareTo(chunkHash2);
+
+        assertThat(result).isLessThan(0);
+    }
+
+    @Test
+    public void testCompareTo_whenArgumentSmaller_returnsPositive() {
+        ChunkHash chunkHash1 = new ChunkHash(TEST_HASH_1);
+        ChunkHash chunkHash2 = new ChunkHash(TEST_HASH_2);
+
+        int result = chunkHash2.compareTo(chunkHash1);
+
+        assertThat(result).isGreaterThan(0);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkListingMapTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkListingMapTest.java
new file mode 100644
index 0000000..24e5573
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkListingMapTest.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2019 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.backup.encryption.chunk;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+import android.util.proto.ProtoInputStream;
+import android.util.proto.ProtoOutputStream;
+
+import com.android.internal.util.Preconditions;
+
+import com.google.common.base.Charsets;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.ByteArrayInputStream;
+import java.util.Arrays;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ChunkListingMapTest {
+    private static final String CHUNK_A = "CHUNK_A";
+    private static final String CHUNK_B = "CHUNK_B";
+    private static final String CHUNK_C = "CHUNK_C";
+
+    private static final int CHUNK_A_LENGTH = 256;
+    private static final int CHUNK_B_LENGTH = 1024;
+    private static final int CHUNK_C_LENGTH = 4055;
+
+    private ChunkHash mChunkHashA;
+    private ChunkHash mChunkHashB;
+    private ChunkHash mChunkHashC;
+
+    @Before
+    public void setUp() throws Exception {
+        mChunkHashA = getHash(CHUNK_A);
+        mChunkHashB = getHash(CHUNK_B);
+        mChunkHashC = getHash(CHUNK_C);
+    }
+
+    @Test
+    public void testHasChunk_whenChunkInListing_returnsTrue() throws Exception {
+        byte[] chunkListingProto =
+                createChunkListingProto(
+                        new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC},
+                        new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH});
+        ChunkListingMap chunkListingMap =
+                ChunkListingMap.readFromProto(
+                        new ProtoInputStream(new ByteArrayInputStream(chunkListingProto)));
+
+        boolean chunkAInList = chunkListingMap.hasChunk(mChunkHashA);
+        boolean chunkBInList = chunkListingMap.hasChunk(mChunkHashB);
+        boolean chunkCInList = chunkListingMap.hasChunk(mChunkHashC);
+
+        assertThat(chunkAInList).isTrue();
+        assertThat(chunkBInList).isTrue();
+        assertThat(chunkCInList).isTrue();
+    }
+
+    @Test
+    public void testHasChunk_whenChunkNotInListing_returnsFalse() throws Exception {
+        byte[] chunkListingProto =
+                createChunkListingProto(
+                        new ChunkHash[] {mChunkHashA, mChunkHashB},
+                        new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH});
+        ChunkListingMap chunkListingMap =
+                ChunkListingMap.readFromProto(
+                        new ProtoInputStream(new ByteArrayInputStream(chunkListingProto)));
+        ChunkHash chunkHashEmpty = getHash("");
+
+        boolean chunkCInList = chunkListingMap.hasChunk(mChunkHashC);
+        boolean emptyChunkInList = chunkListingMap.hasChunk(chunkHashEmpty);
+
+        assertThat(chunkCInList).isFalse();
+        assertThat(emptyChunkInList).isFalse();
+    }
+
+    @Test
+    public void testGetChunkEntry_returnsEntryWithCorrectLength() throws Exception {
+        byte[] chunkListingProto =
+                createChunkListingProto(
+                        new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC},
+                        new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH});
+        ChunkListingMap chunkListingMap =
+                ChunkListingMap.readFromProto(
+                        new ProtoInputStream(new ByteArrayInputStream(chunkListingProto)));
+
+        ChunkListingMap.Entry entryA = chunkListingMap.getChunkEntry(mChunkHashA);
+        ChunkListingMap.Entry entryB = chunkListingMap.getChunkEntry(mChunkHashB);
+        ChunkListingMap.Entry entryC = chunkListingMap.getChunkEntry(mChunkHashC);
+
+        assertThat(entryA.getLength()).isEqualTo(CHUNK_A_LENGTH);
+        assertThat(entryB.getLength()).isEqualTo(CHUNK_B_LENGTH);
+        assertThat(entryC.getLength()).isEqualTo(CHUNK_C_LENGTH);
+    }
+
+    @Test
+    public void testGetChunkEntry_returnsEntryWithCorrectStart() throws Exception {
+        byte[] chunkListingProto =
+                createChunkListingProto(
+                        new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC},
+                        new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH});
+        ChunkListingMap chunkListingMap =
+                ChunkListingMap.readFromProto(
+                        new ProtoInputStream(new ByteArrayInputStream(chunkListingProto)));
+
+        ChunkListingMap.Entry entryA = chunkListingMap.getChunkEntry(mChunkHashA);
+        ChunkListingMap.Entry entryB = chunkListingMap.getChunkEntry(mChunkHashB);
+        ChunkListingMap.Entry entryC = chunkListingMap.getChunkEntry(mChunkHashC);
+
+        assertThat(entryA.getStart()).isEqualTo(0);
+        assertThat(entryB.getStart()).isEqualTo(CHUNK_A_LENGTH);
+        assertThat(entryC.getStart()).isEqualTo(CHUNK_A_LENGTH + CHUNK_B_LENGTH);
+    }
+
+    @Test
+    public void testGetChunkEntry_returnsNullForNonExistentChunk() throws Exception {
+        byte[] chunkListingProto =
+                createChunkListingProto(
+                        new ChunkHash[] {mChunkHashA, mChunkHashB},
+                        new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH});
+        ChunkListingMap chunkListingMap =
+                ChunkListingMap.readFromProto(
+                        new ProtoInputStream(new ByteArrayInputStream(chunkListingProto)));
+
+        ChunkListingMap.Entry chunkEntryNonexistentChunk =
+                chunkListingMap.getChunkEntry(mChunkHashC);
+
+        assertThat(chunkEntryNonexistentChunk).isNull();
+    }
+
+    @Test
+    public void testReadFromProto_whenEmptyProto_returnsChunkListingMapWith0Chunks()
+            throws Exception {
+        ProtoInputStream emptyProto = new ProtoInputStream(new ByteArrayInputStream(new byte[] {}));
+
+        ChunkListingMap chunkListingMap = ChunkListingMap.readFromProto(emptyProto);
+
+        assertThat(chunkListingMap.getChunkCount()).isEqualTo(0);
+    }
+
+    @Test
+    public void testReadFromProto_returnsChunkListingWithCorrectSize() throws Exception {
+        byte[] chunkListingProto =
+                createChunkListingProto(
+                        new ChunkHash[] {mChunkHashA, mChunkHashB, mChunkHashC},
+                        new int[] {CHUNK_A_LENGTH, CHUNK_B_LENGTH, CHUNK_C_LENGTH});
+
+        ChunkListingMap chunkListingMap =
+                ChunkListingMap.readFromProto(
+                        new ProtoInputStream(new ByteArrayInputStream(chunkListingProto)));
+
+        assertThat(chunkListingMap.getChunkCount()).isEqualTo(3);
+    }
+
+    private byte[] createChunkListingProto(ChunkHash[] hashes, int[] lengths) {
+        Preconditions.checkArgument(hashes.length == lengths.length);
+        ProtoOutputStream outputStream = new ProtoOutputStream();
+
+        for (int i = 0; i < hashes.length; ++i) {
+            writeToProtoOutputStream(outputStream, hashes[i], lengths[i]);
+        }
+        outputStream.flush();
+
+        return outputStream.getBytes();
+    }
+
+    private void writeToProtoOutputStream(ProtoOutputStream out, ChunkHash chunkHash, int length) {
+        long token = out.start(ChunksMetadataProto.ChunkListing.CHUNKS);
+        out.write(ChunksMetadataProto.Chunk.HASH, chunkHash.getHash());
+        out.write(ChunksMetadataProto.Chunk.LENGTH, length);
+        out.end(token);
+    }
+
+    private ChunkHash getHash(String name) {
+        return new ChunkHash(
+                Arrays.copyOf(name.getBytes(Charsets.UTF_8), ChunkHash.HASH_LENGTH_BYTES));
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkTest.java
new file mode 100644
index 0000000..1796f56
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/ChunkTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunk;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+import android.util.proto.ProtoInputStream;
+import android.util.proto.ProtoOutputStream;
+
+import com.google.common.base.Charsets;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.ByteArrayInputStream;
+import java.util.Arrays;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ChunkTest {
+    private static final String CHUNK_A = "CHUNK_A";
+    private static final int CHUNK_A_LENGTH = 256;
+
+    private ChunkHash mChunkHashA;
+
+    @Before
+    public void setUp() throws Exception {
+        mChunkHashA = getHash(CHUNK_A);
+    }
+
+    @Test
+    public void testReadFromProto_readsCorrectly() throws Exception {
+        ProtoOutputStream out = new ProtoOutputStream();
+        out.write(ChunksMetadataProto.Chunk.HASH, mChunkHashA.getHash());
+        out.write(ChunksMetadataProto.Chunk.LENGTH, CHUNK_A_LENGTH);
+        out.flush();
+        byte[] protoBytes = out.getBytes();
+
+        Chunk chunk =
+                Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes)));
+
+        assertThat(chunk.getHash()).isEqualTo(mChunkHashA.getHash());
+        assertThat(chunk.getLength()).isEqualTo(CHUNK_A_LENGTH);
+    }
+
+    @Test
+    public void testReadFromProto_whenFieldsWrittenInReversedOrder_readsCorrectly()
+            throws Exception {
+        ProtoOutputStream out = new ProtoOutputStream();
+        // Write fields of Chunk proto in reverse order.
+        out.write(ChunksMetadataProto.Chunk.LENGTH, CHUNK_A_LENGTH);
+        out.write(ChunksMetadataProto.Chunk.HASH, mChunkHashA.getHash());
+        out.flush();
+        byte[] protoBytes = out.getBytes();
+
+        Chunk chunk =
+                Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes)));
+
+        assertThat(chunk.getHash()).isEqualTo(mChunkHashA.getHash());
+        assertThat(chunk.getLength()).isEqualTo(CHUNK_A_LENGTH);
+    }
+
+    @Test
+    public void testReadFromProto_whenEmptyProto_returnsEmptyHash() throws Exception {
+        ProtoInputStream emptyProto = new ProtoInputStream(new ByteArrayInputStream(new byte[] {}));
+
+        Chunk chunk = Chunk.readFromProto(emptyProto);
+
+        assertThat(chunk.getHash()).asList().hasSize(0);
+        assertThat(chunk.getLength()).isEqualTo(0);
+    }
+
+    @Test
+    public void testReadFromProto_whenOnlyHashSet_returnsChunkWithOnlyHash() throws Exception {
+        ProtoOutputStream out = new ProtoOutputStream();
+        out.write(ChunksMetadataProto.Chunk.HASH, mChunkHashA.getHash());
+        out.flush();
+        byte[] protoBytes = out.getBytes();
+
+        Chunk chunk =
+                Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes)));
+
+        assertThat(chunk.getHash()).isEqualTo(mChunkHashA.getHash());
+        assertThat(chunk.getLength()).isEqualTo(0);
+    }
+
+    @Test
+    public void testReadFromProto_whenOnlyLengthSet_returnsChunkWithOnlyLength() throws Exception {
+        ProtoOutputStream out = new ProtoOutputStream();
+        out.write(ChunksMetadataProto.Chunk.LENGTH, CHUNK_A_LENGTH);
+        out.flush();
+        byte[] protoBytes = out.getBytes();
+
+        Chunk chunk =
+                Chunk.readFromProto(new ProtoInputStream(new ByteArrayInputStream(protoBytes)));
+
+        assertThat(chunk.getHash()).isEqualTo(new byte[] {});
+        assertThat(chunk.getLength()).isEqualTo(CHUNK_A_LENGTH);
+    }
+
+    private ChunkHash getHash(String name) {
+        return new ChunkHash(
+                Arrays.copyOf(name.getBytes(Charsets.UTF_8), ChunkHash.HASH_LENGTH_BYTES));
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrderingTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrderingTest.java
new file mode 100644
index 0000000..c6b29b7b
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunk/EncryptedChunkOrderingTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunk;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class EncryptedChunkOrderingTest {
+    private static final byte[] TEST_BYTE_ARRAY_1 = new byte[] {1, 2, 3, 4, 5};
+    private static final byte[] TEST_BYTE_ARRAY_2 = new byte[] {5, 4, 3, 2, 1};
+
+    @Test
+    public void testEncryptedChunkOrdering_returnsValue() {
+        EncryptedChunkOrdering encryptedChunkOrdering =
+                EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1);
+
+        byte[] bytes = encryptedChunkOrdering.encryptedChunkOrdering();
+
+        assertThat(bytes)
+                .asList()
+                .containsExactlyElementsIn(Bytes.asList(TEST_BYTE_ARRAY_1))
+                .inOrder();
+    }
+
+    @Test
+    public void testEquals() {
+        EncryptedChunkOrdering chunkOrdering1 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1);
+        EncryptedChunkOrdering equalChunkOrdering1 =
+                EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1);
+        EncryptedChunkOrdering chunkOrdering2 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_2);
+
+        assertThat(chunkOrdering1).isEqualTo(equalChunkOrdering1);
+        assertThat(chunkOrdering1).isNotEqualTo(chunkOrdering2);
+    }
+
+    @Test
+    public void testHashCode() {
+        EncryptedChunkOrdering chunkOrdering1 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1);
+        EncryptedChunkOrdering equalChunkOrdering1 =
+                EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_1);
+        EncryptedChunkOrdering chunkOrdering2 = EncryptedChunkOrdering.create(TEST_BYTE_ARRAY_2);
+
+        int hash1 = chunkOrdering1.hashCode();
+        int equalHash1 = equalChunkOrdering1.hashCode();
+        int hash2 = chunkOrdering2.hashCode();
+
+        assertThat(hash1).isEqualTo(equalHash1);
+        assertThat(hash1).isNotEqualTo(hash2);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ByteRangeTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ByteRangeTest.java
new file mode 100644
index 0000000..8df0826
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ByteRangeTest.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static org.junit.Assert.assertEquals;
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link ByteRange}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ByteRangeTest {
+    @Test
+    public void getLength_includesEnd() throws Exception {
+        ByteRange byteRange = new ByteRange(5, 10);
+
+        int length = byteRange.getLength();
+
+        assertEquals(6, length);
+    }
+
+    @Test
+    public void constructor_rejectsNegativeStart() {
+        assertThrows(IllegalArgumentException.class, () -> new ByteRange(-1, 10));
+    }
+
+    @Test
+    public void constructor_rejectsEndBeforeStart() {
+        assertThrows(IllegalArgumentException.class, () -> new ByteRange(10, 9));
+    }
+
+    @Test
+    public void extend_withZeroLength_throwsException() {
+        ByteRange byteRange = new ByteRange(5, 10);
+
+        assertThrows(IllegalArgumentException.class, () -> byteRange.extend(0));
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkEncryptorTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkEncryptorTest.java
new file mode 100644
index 0000000..19e3b28
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkEncryptorTest.java
@@ -0,0 +1,157 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.chunk.ChunkHash;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.invocation.InvocationOnMock;
+import org.mockito.stubbing.Answer;
+import org.robolectric.RobolectricTestRunner;
+
+import java.security.SecureRandom;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.GCMParameterSpec;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ChunkEncryptorTest {
+    private static final String MAC_ALGORITHM = "HmacSHA256";
+    private static final String CIPHER_ALGORITHM = "AES/GCM/NoPadding";
+    private static final int GCM_NONCE_LENGTH_BYTES = 12;
+    private static final int GCM_TAG_LENGTH_BYTES = 16;
+    private static final String CHUNK_PLAINTEXT =
+            "A little Learning is a dang'rous Thing;\n"
+                    + "Drink deep, or taste not the Pierian Spring:\n"
+                    + "There shallow Draughts intoxicate the Brain,\n"
+                    + "And drinking largely sobers us again.";
+    private static final byte[] PLAINTEXT_BYTES = CHUNK_PLAINTEXT.getBytes(UTF_8);
+    private static final byte[] NONCE_1 = "0123456789abc".getBytes(UTF_8);
+    private static final byte[] NONCE_2 = "123456789abcd".getBytes(UTF_8);
+
+    private static final byte[][] NONCES = new byte[][] {NONCE_1, NONCE_2};
+
+    @Mock private SecureRandom mSecureRandomMock;
+    private SecretKey mSecretKey;
+    private ChunkHash mPlaintextHash;
+    private ChunkEncryptor mChunkEncryptor;
+
+    @Before
+    public void setUp() throws Exception {
+        mSecretKey = generateAesKey();
+        ChunkHasher chunkHasher = new ChunkHasher(mSecretKey);
+        mPlaintextHash = chunkHasher.computeHash(PLAINTEXT_BYTES);
+        mSecureRandomMock = mock(SecureRandom.class);
+        mChunkEncryptor = new ChunkEncryptor(mSecretKey, mSecureRandomMock);
+
+        // Return NONCE_1, then NONCE_2 for invocations of mSecureRandomMock.nextBytes().
+        doAnswer(
+                    new Answer<Void>() {
+                        private int mInvocation = 0;
+
+                        @Override
+                        public Void answer(InvocationOnMock invocation) {
+                            byte[] nonceDestination = invocation.getArgument(0);
+                            System.arraycopy(
+                                    NONCES[this.mInvocation],
+                                    0,
+                                    nonceDestination,
+                                    0,
+                                    GCM_NONCE_LENGTH_BYTES);
+                            this.mInvocation++;
+                            return null;
+                        }
+                    })
+                .when(mSecureRandomMock)
+                .nextBytes(any(byte[].class));
+    }
+
+    @Test
+    public void encrypt_withHash_resultContainsHashAsKey() throws Exception {
+        EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        assertThat(chunk.key()).isEqualTo(mPlaintextHash);
+    }
+
+    @Test
+    public void encrypt_generatesHmacOfPlaintext() throws Exception {
+        EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        byte[] generatedHash = chunk.key().getHash();
+        Mac mac = Mac.getInstance(MAC_ALGORITHM);
+        mac.init(mSecretKey);
+        byte[] plaintextHmac = mac.doFinal(PLAINTEXT_BYTES);
+        assertThat(generatedHash).isEqualTo(plaintextHmac);
+    }
+
+    @Test
+    public void encrypt_whenInvokedAgain_generatesNewNonce() throws Exception {
+        EncryptedChunk chunk1 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        EncryptedChunk chunk2 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        assertThat(chunk1.nonce()).isNotEqualTo(chunk2.nonce());
+    }
+
+    @Test
+    public void encrypt_whenInvokedAgain_generatesNewCiphertext() throws Exception {
+        EncryptedChunk chunk1 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        EncryptedChunk chunk2 = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        assertThat(chunk1.encryptedBytes()).isNotEqualTo(chunk2.encryptedBytes());
+    }
+
+    @Test
+    public void encrypt_generates12ByteNonce() throws Exception {
+        EncryptedChunk encryptedChunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        byte[] nonce = encryptedChunk.nonce();
+        assertThat(nonce).hasLength(GCM_NONCE_LENGTH_BYTES);
+    }
+
+    @Test
+    public void encrypt_decryptedResultCorrespondsToPlaintext() throws Exception {
+        EncryptedChunk chunk = mChunkEncryptor.encrypt(mPlaintextHash, PLAINTEXT_BYTES);
+
+        Cipher cipher = Cipher.getInstance(CIPHER_ALGORITHM);
+        cipher.init(
+                Cipher.DECRYPT_MODE,
+                mSecretKey,
+                new GCMParameterSpec(GCM_TAG_LENGTH_BYTES * 8, chunk.nonce()));
+        byte[] decrypted = cipher.doFinal(chunk.encryptedBytes());
+        assertThat(decrypted).isEqualTo(PLAINTEXT_BYTES);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkHasherTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkHasherTest.java
new file mode 100644
index 0000000..72a927d
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/ChunkHasherTest.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.chunk.ChunkHash;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import javax.crypto.Mac;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ChunkHasherTest {
+    private static final String KEY_ALGORITHM = "AES";
+    private static final String MAC_ALGORITHM = "HmacSHA256";
+
+    private static final byte[] TEST_KEY = {100, 120};
+    private static final byte[] TEST_DATA = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13};
+
+    private SecretKey mSecretKey;
+    private ChunkHasher mChunkHasher;
+
+    @Before
+    public void setUp() throws Exception {
+        mSecretKey = new SecretKeySpec(TEST_KEY, KEY_ALGORITHM);
+        mChunkHasher = new ChunkHasher(mSecretKey);
+    }
+
+    @Test
+    public void computeHash_returnsHmacForData() throws Exception {
+        ChunkHash chunkHash = mChunkHasher.computeHash(TEST_DATA);
+
+        byte[] hash = chunkHash.getHash();
+        Mac mac = Mac.getInstance(MAC_ALGORITHM);
+        mac.init(mSecretKey);
+        byte[] expectedHash = mac.doFinal(TEST_DATA);
+        assertThat(hash).isEqualTo(expectedHash);
+    }
+
+    @Test
+    public void computeHash_generates256BitHmac() throws Exception {
+        int expectedLength = 256 / Byte.SIZE;
+
+        byte[] hash = mChunkHasher.computeHash(TEST_DATA).getHash();
+
+        assertThat(hash).hasLength(expectedLength);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutputTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutputTest.java
new file mode 100644
index 0000000..823a63c
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DecryptedChunkFileOutputTest.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright (C) 2019 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.tasks.DecryptedChunkOutput;
+
+import com.google.common.io.Files;
+import com.google.common.primitives.Bytes;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.security.MessageDigest;
+import java.util.Arrays;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class DecryptedChunkFileOutputTest {
+    private static final byte[] TEST_CHUNK_1 = {1, 2, 3};
+    private static final byte[] TEST_CHUNK_2 = {4, 5, 6, 7, 8, 9, 10};
+    private static final int TEST_BUFFER_LENGTH =
+            Math.max(TEST_CHUNK_1.length, TEST_CHUNK_2.length);
+
+    @Rule
+    public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+    private File mOutputFile;
+    private DecryptedChunkFileOutput mDecryptedChunkFileOutput;
+
+    @Before
+    public void setUp() throws Exception {
+        mOutputFile = temporaryFolder.newFile();
+        mDecryptedChunkFileOutput = new DecryptedChunkFileOutput(mOutputFile);
+    }
+
+    @Test
+    public void open_returnsInstance() throws Exception {
+        DecryptedChunkOutput result = mDecryptedChunkFileOutput.open();
+        assertThat(result).isEqualTo(mDecryptedChunkFileOutput);
+    }
+
+    @Test
+    public void open_nonExistentOutputFolder_throwsException() throws Exception {
+        mDecryptedChunkFileOutput =
+                new DecryptedChunkFileOutput(
+                        new File(temporaryFolder.newFolder(), "mOutput/directory"));
+        assertThrows(FileNotFoundException.class, () -> mDecryptedChunkFileOutput.open());
+    }
+
+    @Test
+    public void open_whenRunTwice_throwsException() throws Exception {
+        mDecryptedChunkFileOutput.open();
+        assertThrows(IllegalStateException.class, () -> mDecryptedChunkFileOutput.open());
+    }
+
+    @Test
+    public void processChunk_beforeOpen_throwsException() throws Exception {
+        assertThrows(IllegalStateException.class,
+                () -> mDecryptedChunkFileOutput.processChunk(new byte[0], 0));
+    }
+
+    @Test
+    public void processChunk_writesChunksToFile() throws Exception {
+        processTestChunks();
+
+        assertThat(Files.toByteArray(mOutputFile))
+                .isEqualTo(Bytes.concat(TEST_CHUNK_1, TEST_CHUNK_2));
+    }
+
+    @Test
+    public void getDigest_beforeClose_throws() throws Exception {
+        mDecryptedChunkFileOutput.open();
+        assertThrows(IllegalStateException.class, () -> mDecryptedChunkFileOutput.getDigest());
+    }
+
+    @Test
+    public void getDigest_returnsCorrectDigest() throws Exception {
+        processTestChunks();
+
+        byte[] actualDigest = mDecryptedChunkFileOutput.getDigest();
+
+        MessageDigest expectedDigest =
+                MessageDigest.getInstance(DecryptedChunkFileOutput.DIGEST_ALGORITHM);
+        expectedDigest.update(TEST_CHUNK_1);
+        expectedDigest.update(TEST_CHUNK_2);
+        assertThat(actualDigest).isEqualTo(expectedDigest.digest());
+    }
+
+    @Test
+    public void getDigest_whenRunTwice_returnsIdenticalDigestBothTimes() throws Exception {
+        processTestChunks();
+
+        byte[] digest1 = mDecryptedChunkFileOutput.getDigest();
+        byte[] digest2 = mDecryptedChunkFileOutput.getDigest();
+
+        assertThat(digest1).isEqualTo(digest2);
+    }
+
+    private void processTestChunks() throws IOException {
+        mDecryptedChunkFileOutput.open();
+        mDecryptedChunkFileOutput.processChunk(Arrays.copyOf(TEST_CHUNK_1, TEST_BUFFER_LENGTH),
+                TEST_CHUNK_1.length);
+        mDecryptedChunkFileOutput.processChunk(Arrays.copyOf(TEST_CHUNK_2, TEST_BUFFER_LENGTH),
+                TEST_CHUNK_2.length);
+        mDecryptedChunkFileOutput.close();
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriterTest.java
new file mode 100644
index 0000000..2af6f2b
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/DiffScriptBackupWriterTest.java
@@ -0,0 +1,90 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Captor;
+import org.mockito.Mock;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.IOException;
+
+/** Tests for {@link DiffScriptBackupWriter}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class DiffScriptBackupWriterTest {
+    private static final byte[] TEST_BYTES = {1, 2, 3, 4, 5, 6, 7, 8, 9};
+
+    @Captor private ArgumentCaptor<Byte> mBytesCaptor;
+    @Mock private SingleStreamDiffScriptWriter mDiffScriptWriter;
+    private BackupWriter mBackupWriter;
+
+    @Before
+    public void setUp() {
+        mDiffScriptWriter = mock(SingleStreamDiffScriptWriter.class);
+        mBackupWriter = new DiffScriptBackupWriter(mDiffScriptWriter);
+        mBytesCaptor = ArgumentCaptor.forClass(Byte.class);
+    }
+
+    @Test
+    public void writeBytes_writesBytesToWriter() throws Exception {
+        mBackupWriter.writeBytes(TEST_BYTES);
+
+        verify(mDiffScriptWriter, atLeastOnce()).writeByte(mBytesCaptor.capture());
+        assertThat(mBytesCaptor.getAllValues())
+                .containsExactlyElementsIn(Bytes.asList(TEST_BYTES))
+                .inOrder();
+    }
+
+    @Test
+    public void writeChunk_writesChunkToWriter() throws Exception {
+        mBackupWriter.writeChunk(0, 10);
+
+        verify(mDiffScriptWriter).writeChunk(0, 10);
+    }
+
+    @Test
+    public void getBytesWritten_returnsTotalSum() throws Exception {
+        mBackupWriter.writeBytes(TEST_BYTES);
+        mBackupWriter.writeBytes(TEST_BYTES);
+        mBackupWriter.writeChunk(/*start=*/ 0, /*length=*/ 10);
+
+        long bytesWritten = mBackupWriter.getBytesWritten();
+
+        assertThat(bytesWritten).isEqualTo(2 * TEST_BYTES.length + 10);
+    }
+
+    @Test
+    public void flush_flushesWriter() throws IOException {
+        mBackupWriter.flush();
+
+        verify(mDiffScriptWriter).flush();
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/EncryptedChunkTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/EncryptedChunkTest.java
new file mode 100644
index 0000000..325b601
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/EncryptedChunkTest.java
@@ -0,0 +1,133 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.chunk.ChunkHash;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Arrays;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class EncryptedChunkTest {
+    private static final byte[] CHUNK_HASH_1_BYTES =
+            Arrays.copyOf(new byte[] {1}, ChunkHash.HASH_LENGTH_BYTES);
+    private static final byte[] NONCE_1 =
+            Arrays.copyOf(new byte[] {2}, EncryptedChunk.NONCE_LENGTH_BYTES);
+    private static final byte[] ENCRYPTED_BYTES_1 =
+            Arrays.copyOf(new byte[] {3}, EncryptedChunk.KEY_LENGTH_BYTES);
+
+    private static final byte[] CHUNK_HASH_2_BYTES =
+            Arrays.copyOf(new byte[] {4}, ChunkHash.HASH_LENGTH_BYTES);
+    private static final byte[] NONCE_2 =
+            Arrays.copyOf(new byte[] {5}, EncryptedChunk.NONCE_LENGTH_BYTES);
+    private static final byte[] ENCRYPTED_BYTES_2 =
+            Arrays.copyOf(new byte[] {6}, EncryptedChunk.KEY_LENGTH_BYTES);
+
+    @Test
+    public void testCreate_withIncorrectLength_throwsException() {
+        ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES);
+        byte[] shortNonce = Arrays.copyOf(new byte[] {2}, EncryptedChunk.NONCE_LENGTH_BYTES - 1);
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> EncryptedChunk.create(chunkHash, shortNonce, ENCRYPTED_BYTES_1));
+    }
+
+    @Test
+    public void testEncryptedBytes_forNewlyCreatedObject_returnsCorrectValue() {
+        ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES);
+        EncryptedChunk encryptedChunk =
+                EncryptedChunk.create(chunkHash, NONCE_1, ENCRYPTED_BYTES_1);
+
+        byte[] returnedBytes = encryptedChunk.encryptedBytes();
+
+        assertThat(returnedBytes)
+                .asList()
+                .containsExactlyElementsIn(Bytes.asList(ENCRYPTED_BYTES_1))
+                .inOrder();
+    }
+
+    @Test
+    public void testKey_forNewlyCreatedObject_returnsCorrectValue() {
+        ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES);
+        EncryptedChunk encryptedChunk =
+                EncryptedChunk.create(chunkHash, NONCE_1, ENCRYPTED_BYTES_1);
+
+        ChunkHash returnedKey = encryptedChunk.key();
+
+        assertThat(returnedKey).isEqualTo(chunkHash);
+    }
+
+    @Test
+    public void testNonce_forNewlycreatedObject_returnCorrectValue() {
+        ChunkHash chunkHash = new ChunkHash(CHUNK_HASH_1_BYTES);
+        EncryptedChunk encryptedChunk =
+                EncryptedChunk.create(chunkHash, NONCE_1, ENCRYPTED_BYTES_1);
+
+        byte[] returnedNonce = encryptedChunk.nonce();
+
+        assertThat(returnedNonce).asList().containsExactlyElementsIn(Bytes.asList(NONCE_1));
+    }
+
+    @Test
+    public void testEquals() {
+        ChunkHash chunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES);
+        ChunkHash equalChunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES);
+        ChunkHash chunkHash2 = new ChunkHash(CHUNK_HASH_2_BYTES);
+        EncryptedChunk encryptedChunk1 =
+                EncryptedChunk.create(chunkHash1, NONCE_1, ENCRYPTED_BYTES_1);
+        EncryptedChunk equalEncryptedChunk1 =
+                EncryptedChunk.create(equalChunkHash1, NONCE_1, ENCRYPTED_BYTES_1);
+        EncryptedChunk encryptedChunk2 =
+                EncryptedChunk.create(chunkHash2, NONCE_2, ENCRYPTED_BYTES_2);
+
+        assertThat(encryptedChunk1).isEqualTo(equalEncryptedChunk1);
+        assertThat(encryptedChunk1).isNotEqualTo(encryptedChunk2);
+    }
+
+    @Test
+    public void testHashCode() {
+        ChunkHash chunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES);
+        ChunkHash equalChunkHash1 = new ChunkHash(CHUNK_HASH_1_BYTES);
+        ChunkHash chunkHash2 = new ChunkHash(CHUNK_HASH_2_BYTES);
+        EncryptedChunk encryptedChunk1 =
+                EncryptedChunk.create(chunkHash1, NONCE_1, ENCRYPTED_BYTES_1);
+        EncryptedChunk equalEncryptedChunk1 =
+                EncryptedChunk.create(equalChunkHash1, NONCE_1, ENCRYPTED_BYTES_1);
+        EncryptedChunk encryptedChunk2 =
+                EncryptedChunk.create(chunkHash2, NONCE_2, ENCRYPTED_BYTES_2);
+
+        int hash1 = encryptedChunk1.hashCode();
+        int equalHash1 = equalEncryptedChunk1.hashCode();
+        int hash2 = encryptedChunk2.hashCode();
+
+        assertThat(hash1).isEqualTo(equalHash1);
+        assertThat(hash1).isNotEqualTo(hash2);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoderTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoderTest.java
new file mode 100644
index 0000000..634acdc
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/InlineLengthsEncryptedChunkEncoderTest.java
@@ -0,0 +1,85 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.chunk.ChunkHash;
+import com.android.server.backup.encryption.chunk.ChunksMetadataProto;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Arrays;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class InlineLengthsEncryptedChunkEncoderTest {
+
+    private static final byte[] TEST_NONCE =
+            Arrays.copyOf(new byte[] {1}, EncryptedChunk.NONCE_LENGTH_BYTES);
+    private static final byte[] TEST_KEY_DATA =
+            Arrays.copyOf(new byte[] {2}, EncryptedChunk.KEY_LENGTH_BYTES);
+    private static final byte[] TEST_DATA = {5, 4, 5, 7, 10, 12, 1, 2, 9};
+
+    @Mock private BackupWriter mMockBackupWriter;
+    private ChunkHash mTestKey;
+    private EncryptedChunk mTestChunk;
+    private EncryptedChunkEncoder mEncoder;
+
+    @Before
+    public void setUp() throws Exception {
+        mMockBackupWriter = mock(BackupWriter.class);
+        mTestKey = new ChunkHash(TEST_KEY_DATA);
+        mTestChunk = EncryptedChunk.create(mTestKey, TEST_NONCE, TEST_DATA);
+        mEncoder = new InlineLengthsEncryptedChunkEncoder();
+    }
+
+    @Test
+    public void writeChunkToWriter_writesLengthThenNonceThenData() throws Exception {
+        mEncoder.writeChunkToWriter(mMockBackupWriter, mTestChunk);
+
+        InOrder inOrder = inOrder(mMockBackupWriter);
+        inOrder.verify(mMockBackupWriter)
+                .writeBytes(
+                        InlineLengthsEncryptedChunkEncoder.toByteArray(
+                                TEST_NONCE.length + TEST_DATA.length));
+        inOrder.verify(mMockBackupWriter).writeBytes(TEST_NONCE);
+        inOrder.verify(mMockBackupWriter).writeBytes(TEST_DATA);
+    }
+
+    @Test
+    public void getEncodedLengthOfChunk_returnsSumOfNonceAndDataLengths() {
+        int encodedLength = mEncoder.getEncodedLengthOfChunk(mTestChunk);
+
+        assertThat(encodedLength).isEqualTo(Integer.BYTES + TEST_NONCE.length + TEST_DATA.length);
+    }
+
+    @Test
+    public void getChunkOrderingType_returnsExplicitStartsType() {
+        assertThat(mEncoder.getChunkOrderingType()).isEqualTo(ChunksMetadataProto.INLINE_LENGTHS);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoderTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoderTest.java
new file mode 100644
index 0000000..d231603
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/LengthlessEncryptedChunkEncoderTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.chunk.ChunkHash;
+import com.android.server.backup.encryption.chunk.ChunksMetadataProto;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InOrder;
+import org.mockito.Mock;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Arrays;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class LengthlessEncryptedChunkEncoderTest {
+    private static final byte[] TEST_NONCE =
+            Arrays.copyOf(new byte[] {1}, EncryptedChunk.NONCE_LENGTH_BYTES);
+    private static final byte[] TEST_KEY_DATA =
+            Arrays.copyOf(new byte[] {2}, EncryptedChunk.KEY_LENGTH_BYTES);
+    private static final byte[] TEST_DATA = {5, 4, 5, 7, 10, 12, 1, 2, 9};
+
+    @Mock private BackupWriter mMockBackupWriter;
+    private ChunkHash mTestKey;
+    private EncryptedChunk mTestChunk;
+    private EncryptedChunkEncoder mEncoder;
+
+    @Before
+    public void setUp() throws Exception {
+        mMockBackupWriter = mock(BackupWriter.class);
+        mTestKey = new ChunkHash(TEST_KEY_DATA);
+        mTestChunk = EncryptedChunk.create(mTestKey, TEST_NONCE, TEST_DATA);
+        mEncoder = new LengthlessEncryptedChunkEncoder();
+    }
+
+    @Test
+    public void writeChunkToWriter_writesNonceThenData() throws Exception {
+        mEncoder.writeChunkToWriter(mMockBackupWriter, mTestChunk);
+
+        InOrder inOrder = inOrder(mMockBackupWriter);
+        inOrder.verify(mMockBackupWriter).writeBytes(TEST_NONCE);
+        inOrder.verify(mMockBackupWriter).writeBytes(TEST_DATA);
+    }
+
+    @Test
+    public void getEncodedLengthOfChunk_returnsSumOfNonceAndDataLengths() {
+        int encodedLength = mEncoder.getEncodedLengthOfChunk(mTestChunk);
+
+        assertThat(encodedLength).isEqualTo(TEST_NONCE.length + TEST_DATA.length);
+    }
+
+    @Test
+    public void getChunkOrderingType_returnsExplicitStartsType() {
+        assertThat(mEncoder.getChunkOrderingType()).isEqualTo(ChunksMetadataProto.EXPLICIT_STARTS);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/RawBackupWriterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/RawBackupWriterTest.java
new file mode 100644
index 0000000..966d3e2
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/RawBackupWriterTest.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.google.common.primitives.Bytes;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.ByteArrayOutputStream;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class RawBackupWriterTest {
+    private static final byte[] TEST_BYTES = {1, 2, 3, 4, 5, 6};
+
+    private BackupWriter mWriter;
+    private ByteArrayOutputStream mOutput;
+
+    @Before
+    public void setUp() {
+        mOutput = new ByteArrayOutputStream();
+        mWriter = new RawBackupWriter(mOutput);
+    }
+
+    @Test
+    public void writeBytes_writesToOutputStream() throws Exception {
+        mWriter.writeBytes(TEST_BYTES);
+
+        assertThat(mOutput.toByteArray())
+                .asList()
+                .containsExactlyElementsIn(Bytes.asList(TEST_BYTES))
+                .inOrder();
+    }
+
+    @Test
+    public void writeChunk_throwsUnsupportedOperationException() throws Exception {
+        assertThrows(UnsupportedOperationException.class, () -> mWriter.writeChunk(0, 0));
+    }
+
+    @Test
+    public void getBytesWritten_returnsTotalSum() throws Exception {
+        mWriter.writeBytes(TEST_BYTES);
+        mWriter.writeBytes(TEST_BYTES);
+
+        long bytesWritten = mWriter.getBytesWritten();
+
+        assertThat(bytesWritten).isEqualTo(2 * TEST_BYTES.length);
+    }
+
+    @Test
+    public void flush_flushesOutputStream() throws Exception {
+        mOutput = mock(ByteArrayOutputStream.class);
+        mWriter = new RawBackupWriter(mOutput);
+
+        mWriter.flush();
+
+        verify(mOutput).flush();
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriterTest.java
new file mode 100644
index 0000000..73baf80
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/SingleStreamDiffScriptWriterTest.java
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Locale;
+
+/** Tests for {@link SingleStreamDiffScriptWriter}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class SingleStreamDiffScriptWriterTest {
+    private static final int MAX_CHUNK_SIZE_IN_BYTES = 256;
+    /** By default this Locale does not use Arabic numbers for %d formatting. */
+    private static final Locale HINDI = new Locale("hi", "IN");
+
+    private Locale mDefaultLocale;
+    private ByteArrayOutputStream mOutputStream;
+    private SingleStreamDiffScriptWriter mDiffScriptWriter;
+
+    @Before
+    public void setUp() {
+        mDefaultLocale = Locale.getDefault();
+        mOutputStream = new ByteArrayOutputStream();
+        mDiffScriptWriter =
+                new SingleStreamDiffScriptWriter(mOutputStream, MAX_CHUNK_SIZE_IN_BYTES);
+    }
+
+    @After
+    public void tearDown() {
+        Locale.setDefault(mDefaultLocale);
+    }
+
+    @Test
+    public void writeChunk_withNegativeStart_throwsException() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mDiffScriptWriter.writeChunk(-1, 50));
+    }
+
+    @Test
+    public void writeChunk_withZeroLength_throwsException() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> mDiffScriptWriter.writeChunk(0, 0));
+    }
+
+    @Test
+    public void writeChunk_withExistingBytesInBuffer_writesBufferFirst()
+            throws IOException {
+        String testString = "abcd";
+        writeStringAsBytesToWriter(testString, mDiffScriptWriter);
+
+        mDiffScriptWriter.writeChunk(0, 20);
+        mDiffScriptWriter.flush();
+
+        // Expected format: length of abcd, newline, abcd, newline, chunk start - chunk end
+        assertThat(mOutputStream.toString("UTF-8")).isEqualTo(
+                String.format("%d\n%s\n%d-%d\n", testString.length(), testString, 0, 19));
+    }
+
+    @Test
+    public void writeChunk_overlappingPreviousChunk_combinesChunks() throws IOException {
+        mDiffScriptWriter.writeChunk(3, 4);
+
+        mDiffScriptWriter.writeChunk(7, 5);
+        mDiffScriptWriter.flush();
+
+        assertThat(mOutputStream.toString("UTF-8")).isEqualTo(String.format("3-11\n"));
+    }
+
+    @Test
+    public void writeChunk_formatsByteIndexesUsingArabicNumbers() throws Exception {
+        Locale.setDefault(HINDI);
+
+        mDiffScriptWriter.writeChunk(0, 12345);
+        mDiffScriptWriter.flush();
+
+        assertThat(mOutputStream.toString("UTF-8")).isEqualTo("0-12344\n");
+    }
+
+    @Test
+    public void flush_flushesOutputStream() throws IOException {
+        ByteArrayOutputStream mockOutputStream = mock(ByteArrayOutputStream.class);
+        SingleStreamDiffScriptWriter diffScriptWriter =
+                new SingleStreamDiffScriptWriter(mockOutputStream, MAX_CHUNK_SIZE_IN_BYTES);
+
+        diffScriptWriter.flush();
+
+        verify(mockOutputStream).flush();
+    }
+
+    private void writeStringAsBytesToWriter(String string, SingleStreamDiffScriptWriter writer)
+            throws IOException {
+        byte[] bytes = string.getBytes("UTF-8");
+        for (int i = 0; i < bytes.length; i++) {
+            writer.writeByte(bytes[i]);
+        }
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunkerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunkerTest.java
new file mode 100644
index 0000000..77b7347
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/ContentDefinedChunkerTest.java
@@ -0,0 +1,232 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking.cdc;
+
+import static com.android.server.backup.testing.CryptoTestUtils.generateAesKey;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Random;
+
+import javax.crypto.SecretKey;
+
+/** Tests for {@link ContentDefinedChunker}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class ContentDefinedChunkerTest {
+    private static final int WINDOW_SIZE_BYTES = 31;
+    private static final int MIN_SIZE_BYTES = 40;
+    private static final int MAX_SIZE_BYTES = 300;
+    private static final String CHUNK_BOUNDARY = "<----------BOUNDARY----------->";
+    private static final byte[] CHUNK_BOUNDARY_BYTES = CHUNK_BOUNDARY.getBytes(UTF_8);
+    private static final String CHUNK_1 = "This is the first chunk";
+    private static final String CHUNK_2 = "And this is the second chunk";
+    private static final String CHUNK_3 = "And finally here is the third chunk";
+    private static final String SMALL_CHUNK = "12345678";
+
+    private FingerprintMixer mFingerprintMixer;
+    private RabinFingerprint64 mRabinFingerprint64;
+    private ContentDefinedChunker mChunker;
+
+    /** Set up a {@link ContentDefinedChunker} and dependencies for use in the tests. */
+    @Before
+    public void setUp() throws Exception {
+        SecretKey secretKey = generateAesKey();
+        byte[] salt = new byte[FingerprintMixer.SALT_LENGTH_BYTES];
+        Random random = new Random();
+        random.nextBytes(salt);
+        mFingerprintMixer = new FingerprintMixer(secretKey, salt);
+
+        mRabinFingerprint64 = new RabinFingerprint64();
+        long chunkBoundaryFingerprint = calculateFingerprint(CHUNK_BOUNDARY_BYTES);
+        mChunker =
+                new ContentDefinedChunker(
+                        MIN_SIZE_BYTES,
+                        MAX_SIZE_BYTES,
+                        mRabinFingerprint64,
+                        mFingerprintMixer,
+                        (fingerprint) -> fingerprint == chunkBoundaryFingerprint);
+    }
+
+    /**
+     * Creating a {@link ContentDefinedChunker} with a minimum chunk size that is smaller than the
+     * window size should throw an {@link IllegalArgumentException}.
+     */
+    @Test
+    public void create_withMinChunkSizeSmallerThanWindowSize_throwsIllegalArgumentException() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new ContentDefinedChunker(
+                                WINDOW_SIZE_BYTES - 1,
+                                MAX_SIZE_BYTES,
+                                mRabinFingerprint64,
+                                mFingerprintMixer,
+                                null));
+    }
+
+    /**
+     * Creating a {@link ContentDefinedChunker} with a maximum chunk size that is smaller than the
+     * minimum chunk size should throw an {@link IllegalArgumentException}.
+     */
+    @Test
+    public void create_withMaxChunkSizeSmallerThanMinChunkSize_throwsIllegalArgumentException() {
+        assertThrows(
+                IllegalArgumentException.class,
+                () ->
+                        new ContentDefinedChunker(
+                                MIN_SIZE_BYTES,
+                                MIN_SIZE_BYTES - 1,
+                                mRabinFingerprint64,
+                                mFingerprintMixer,
+                                null));
+    }
+
+    /**
+     * {@link ContentDefinedChunker#chunkify(InputStream, Chunker.ChunkConsumer)} should split the
+     * input stream across chunk boundaries by default.
+     */
+    @Test
+    public void chunkify_withLargeChunks_splitsIntoChunksAcrossBoundaries() throws Exception {
+        byte[] input =
+                (CHUNK_1 + CHUNK_BOUNDARY + CHUNK_2 + CHUNK_BOUNDARY + CHUNK_3).getBytes(UTF_8);
+        ByteArrayInputStream inputStream = new ByteArrayInputStream(input);
+        ArrayList<String> result = new ArrayList<>();
+
+        mChunker.chunkify(inputStream, (chunk) -> result.add(new String(chunk, UTF_8)));
+
+        assertThat(result)
+                .containsExactly(CHUNK_1 + CHUNK_BOUNDARY, CHUNK_2 + CHUNK_BOUNDARY, CHUNK_3)
+                .inOrder();
+    }
+
+    /** Chunks should be combined across boundaries until they reach the minimum chunk size. */
+    @Test
+    public void chunkify_withSmallChunks_combinesChunksUntilMinSize() throws Exception {
+        byte[] input =
+                (SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2 + CHUNK_BOUNDARY + CHUNK_3).getBytes(UTF_8);
+        ByteArrayInputStream inputStream = new ByteArrayInputStream(input);
+        ArrayList<String> result = new ArrayList<>();
+
+        mChunker.chunkify(inputStream, (chunk) -> result.add(new String(chunk, UTF_8)));
+
+        assertThat(result)
+                .containsExactly(SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2 + CHUNK_BOUNDARY, CHUNK_3)
+                .inOrder();
+        assertThat(result.get(0).length()).isAtLeast(MIN_SIZE_BYTES);
+    }
+
+    /** Chunks can not be larger than the maximum chunk size. */
+    @Test
+    public void chunkify_doesNotProduceChunksLargerThanMaxSize() throws Exception {
+        byte[] largeInput = new byte[MAX_SIZE_BYTES * 10];
+        Arrays.fill(largeInput, "a".getBytes(UTF_8)[0]);
+        ByteArrayInputStream inputStream = new ByteArrayInputStream(largeInput);
+        ArrayList<String> result = new ArrayList<>();
+
+        mChunker.chunkify(inputStream, (chunk) -> result.add(new String(chunk, UTF_8)));
+
+        byte[] expectedChunkBytes = new byte[MAX_SIZE_BYTES];
+        Arrays.fill(expectedChunkBytes, "a".getBytes(UTF_8)[0]);
+        String expectedChunk = new String(expectedChunkBytes, UTF_8);
+        assertThat(result)
+                .containsExactly(
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk,
+                        expectedChunk)
+                .inOrder();
+    }
+
+    /**
+     * If the input stream signals zero availablility, {@link
+     * ContentDefinedChunker#chunkify(InputStream, Chunker.ChunkConsumer)} should still work.
+     */
+    @Test
+    public void chunkify_withInputStreamReturningZeroAvailability_returnsChunks() throws Exception {
+        byte[] input = (SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2).getBytes(UTF_8);
+        ZeroAvailabilityInputStream zeroAvailabilityInputStream =
+                new ZeroAvailabilityInputStream(input);
+        ArrayList<String> result = new ArrayList<>();
+
+        mChunker.chunkify(
+                zeroAvailabilityInputStream, (chunk) -> result.add(new String(chunk, UTF_8)));
+
+        assertThat(result).containsExactly(SMALL_CHUNK + CHUNK_BOUNDARY + CHUNK_2).inOrder();
+    }
+
+    /**
+     * {@link ContentDefinedChunker#chunkify(InputStream, Chunker.ChunkConsumer)} should rethrow any
+     * exception thrown by its consumer.
+     */
+    @Test
+    public void chunkify_whenConsumerThrowsException_rethrowsException() throws Exception {
+        ByteArrayInputStream inputStream = new ByteArrayInputStream(new byte[] {1});
+
+        assertThrows(
+                GeneralSecurityException.class,
+                () ->
+                        mChunker.chunkify(
+                                inputStream,
+                                (chunk) -> {
+                                    throw new GeneralSecurityException();
+                                }));
+    }
+
+    private long calculateFingerprint(byte[] bytes) {
+        long fingerprint = 0;
+        for (byte inByte : bytes) {
+            fingerprint =
+                    mRabinFingerprint64.computeFingerprint64(
+                            /*inChar=*/ inByte, /*outChar=*/ (byte) 0, fingerprint);
+        }
+        return mFingerprintMixer.mix(fingerprint);
+    }
+
+    private static class ZeroAvailabilityInputStream extends ByteArrayInputStream {
+        ZeroAvailabilityInputStream(byte[] wrapped) {
+            super(wrapped);
+        }
+
+        @Override
+        public synchronized int available() {
+            return 0;
+        }
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixerTest.java
new file mode 100644
index 0000000..936b5dc
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/FingerprintMixerTest.java
@@ -0,0 +1,205 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking.cdc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.security.InvalidKeyException;
+import java.security.Key;
+import java.util.HashSet;
+import java.util.Random;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+/** Tests for {@link FingerprintMixer}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class FingerprintMixerTest {
+    private static final String KEY_ALGORITHM = "AES";
+    private static final int SEED = 42;
+    private static final int SALT_LENGTH_BYTES = 256 / 8;
+    private static final int KEY_SIZE_BITS = 256;
+
+    private Random mSeededRandom;
+    private FingerprintMixer mFingerprintMixer;
+
+    /** Set up a {@link FingerprintMixer} with deterministic key and salt generation. */
+    @Before
+    public void setUp() throws Exception {
+        // Seed so that the tests are deterministic.
+        mSeededRandom = new Random(SEED);
+        mFingerprintMixer = new FingerprintMixer(randomKey(), randomSalt());
+    }
+
+    /**
+     * Construcing a {@link FingerprintMixer} with a salt that is too small should throw an {@link
+     * IllegalArgumentException}.
+     */
+    @Test
+    public void create_withIncorrectSaltSize_throwsIllegalArgumentException() {
+        byte[] tooSmallSalt = new byte[SALT_LENGTH_BYTES - 1];
+
+        assertThrows(
+                IllegalArgumentException.class,
+                () -> new FingerprintMixer(randomKey(), tooSmallSalt));
+    }
+
+    /**
+     * Constructing a {@link FingerprintMixer} with a secret key that can't be encoded should throw
+     * an {@link InvalidKeyException}.
+     */
+    @Test
+    public void create_withUnencodableSecretKey_throwsInvalidKeyException() {
+        byte[] keyBytes = new byte[KEY_SIZE_BITS / 8];
+        UnencodableSecretKeySpec keySpec =
+                new UnencodableSecretKeySpec(keyBytes, 0, keyBytes.length, KEY_ALGORITHM);
+
+        assertThrows(InvalidKeyException.class, () -> new FingerprintMixer(keySpec, randomSalt()));
+    }
+
+    /**
+     * {@link FingerprintMixer#getAddend()} should not return the same addend for two different
+     * keys.
+     */
+    @Test
+    public void getAddend_withDifferentKey_returnsDifferentResult() throws Exception {
+        int iterations = 100_000;
+        HashSet<Long> returnedAddends = new HashSet<>();
+        byte[] salt = randomSalt();
+
+        for (int i = 0; i < iterations; i++) {
+            FingerprintMixer fingerprintMixer = new FingerprintMixer(randomKey(), salt);
+            long addend = fingerprintMixer.getAddend();
+            returnedAddends.add(addend);
+        }
+
+        assertThat(returnedAddends).containsNoDuplicates();
+    }
+
+    /**
+     * {@link FingerprintMixer#getMultiplicand()} should not return the same multiplicand for two
+     * different keys.
+     */
+    @Test
+    public void getMultiplicand_withDifferentKey_returnsDifferentResult() throws Exception {
+        int iterations = 100_000;
+        HashSet<Long> returnedMultiplicands = new HashSet<>();
+        byte[] salt = randomSalt();
+
+        for (int i = 0; i < iterations; i++) {
+            FingerprintMixer fingerprintMixer = new FingerprintMixer(randomKey(), salt);
+            long multiplicand = fingerprintMixer.getMultiplicand();
+            returnedMultiplicands.add(multiplicand);
+        }
+
+        assertThat(returnedMultiplicands).containsNoDuplicates();
+    }
+
+    /** The multiplicant returned by {@link FingerprintMixer} should always be odd. */
+    @Test
+    public void getMultiplicand_isOdd() throws Exception {
+        int iterations = 100_000;
+
+        for (int i = 0; i < iterations; i++) {
+            FingerprintMixer fingerprintMixer = new FingerprintMixer(randomKey(), randomSalt());
+
+            long multiplicand = fingerprintMixer.getMultiplicand();
+
+            assertThat(isOdd(multiplicand)).isTrue();
+        }
+    }
+
+    /** {@link FingerprintMixer#mix(long)} should have a random distribution. */
+    @Test
+    public void mix_randomlyDistributesBits() throws Exception {
+        int iterations = 100_000;
+        float tolerance = 0.1f;
+        int[] totals = new int[64];
+
+        for (int i = 0; i < iterations; i++) {
+            long n = mFingerprintMixer.mix(mSeededRandom.nextLong());
+            for (int j = 0; j < 64; j++) {
+                int bit = (int) (n >> j & 1);
+                totals[j] += bit;
+            }
+        }
+
+        for (int i = 0; i < 64; i++) {
+            float mean = ((float) totals[i]) / iterations;
+            float diff = Math.abs(mean - 0.5f);
+            assertThat(diff).isLessThan(tolerance);
+        }
+    }
+
+    /**
+     * {@link FingerprintMixer#mix(long)} should always produce a number that's different from the
+     * input.
+     */
+    @Test
+    public void mix_doesNotProduceSameNumberAsInput() {
+        int iterations = 100_000;
+
+        for (int i = 0; i < iterations; i++) {
+            assertThat(mFingerprintMixer.mix(i)).isNotEqualTo(i);
+        }
+    }
+
+    private byte[] randomSalt() {
+        byte[] salt = new byte[SALT_LENGTH_BYTES];
+        mSeededRandom.nextBytes(salt);
+        return salt;
+    }
+
+    /**
+     * Not a secure way of generating keys. We want to deterministically generate the same keys for
+     * each test run, though, to ensure the test is deterministic.
+     */
+    private SecretKey randomKey() {
+        byte[] keyBytes = new byte[KEY_SIZE_BITS / 8];
+        mSeededRandom.nextBytes(keyBytes);
+        return new SecretKeySpec(keyBytes, 0, keyBytes.length, KEY_ALGORITHM);
+    }
+
+    private static boolean isOdd(long n) {
+        return Math.abs(n % 2) == 1;
+    }
+
+    /**
+     * Subclass of {@link SecretKeySpec} that does not provide an encoded version. As per its
+     * contract in {@link Key}, that means {@code getEncoded()} always returns null.
+     */
+    private class UnencodableSecretKeySpec extends SecretKeySpec {
+        UnencodableSecretKeySpec(byte[] key, int offset, int len, String algorithm) {
+            super(key, offset, len, algorithm);
+        }
+
+        @Override
+        public byte[] getEncoded() {
+            return null;
+        }
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/HkdfTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/HkdfTest.java
new file mode 100644
index 0000000..5494374
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/HkdfTest.java
@@ -0,0 +1,95 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking.cdc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link Hkdf}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class HkdfTest {
+    /** HKDF Test Case 1 IKM from RFC 5869 */
+    private static final byte[] HKDF_CASE1_IKM = {
+        0x0b, 0x0b, 0x0b, 0x0b, 0x0b,
+        0x0b, 0x0b, 0x0b, 0x0b, 0x0b,
+        0x0b, 0x0b, 0x0b, 0x0b, 0x0b,
+        0x0b, 0x0b, 0x0b, 0x0b, 0x0b,
+        0x0b, 0x0b
+    };
+
+    /** HKDF Test Case 1 salt from RFC 5869 */
+    private static final byte[] HKDF_CASE1_SALT = {
+        0x00, 0x01, 0x02, 0x03, 0x04,
+        0x05, 0x06, 0x07, 0x08, 0x09,
+        0x0a, 0x0b, 0x0c
+    };
+
+    /** HKDF Test Case 1 info from RFC 5869 */
+    private static final byte[] HKDF_CASE1_INFO = {
+        (byte) 0xf0, (byte) 0xf1, (byte) 0xf2, (byte) 0xf3, (byte) 0xf4,
+        (byte) 0xf5, (byte) 0xf6, (byte) 0xf7, (byte) 0xf8, (byte) 0xf9
+    };
+
+    /** First 32 bytes of HKDF Test Case 1 OKM (output) from RFC 5869 */
+    private static final byte[] HKDF_CASE1_OKM = {
+        (byte) 0x3c, (byte) 0xb2, (byte) 0x5f, (byte) 0x25, (byte) 0xfa,
+        (byte) 0xac, (byte) 0xd5, (byte) 0x7a, (byte) 0x90, (byte) 0x43,
+        (byte) 0x4f, (byte) 0x64, (byte) 0xd0, (byte) 0x36, (byte) 0x2f,
+        (byte) 0x2a, (byte) 0x2d, (byte) 0x2d, (byte) 0x0a, (byte) 0x90,
+        (byte) 0xcf, (byte) 0x1a, (byte) 0x5a, (byte) 0x4c, (byte) 0x5d,
+        (byte) 0xb0, (byte) 0x2d, (byte) 0x56, (byte) 0xec, (byte) 0xc4,
+        (byte) 0xc5, (byte) 0xbf
+    };
+
+    /** Test the example from RFC 5869. */
+    @Test
+    public void hkdf_derivesKeyMaterial() throws Exception {
+        byte[] result = Hkdf.hkdf(HKDF_CASE1_IKM, HKDF_CASE1_SALT, HKDF_CASE1_INFO);
+
+        assertThat(result).isEqualTo(HKDF_CASE1_OKM);
+    }
+
+    /** Providing a key that is null should throw a {@link java.lang.NullPointerException}. */
+    @Test
+    public void hkdf_withNullKey_throwsNullPointerException() throws Exception {
+        assertThrows(
+                NullPointerException.class,
+                () -> Hkdf.hkdf(null, HKDF_CASE1_SALT, HKDF_CASE1_INFO));
+    }
+
+    /** Providing a salt that is null should throw a {@link java.lang.NullPointerException}. */
+    @Test
+    public void hkdf_withNullSalt_throwsNullPointerException() throws Exception {
+        assertThrows(
+                NullPointerException.class, () -> Hkdf.hkdf(HKDF_CASE1_IKM, null, HKDF_CASE1_INFO));
+    }
+
+    /** Providing data that is null should throw a {@link java.lang.NullPointerException}. */
+    @Test
+    public void hkdf_withNullData_throwsNullPointerException() throws Exception {
+        assertThrows(
+                NullPointerException.class, () -> Hkdf.hkdf(HKDF_CASE1_IKM, HKDF_CASE1_SALT, null));
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpointTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpointTest.java
new file mode 100644
index 0000000..277dc37
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/IsChunkBreakpointTest.java
@@ -0,0 +1,122 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking.cdc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.util.Random;
+
+/** Tests for {@link IsChunkBreakpoint}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class IsChunkBreakpointTest {
+    private static final int RANDOM_SEED = 42;
+    private static final double TOLERANCE = 0.01;
+    private static final int NUMBER_OF_TESTS = 10000;
+    private static final int BITS_PER_LONG = 64;
+
+    private Random mRandom;
+
+    /** Make sure that tests are deterministic. */
+    @Before
+    public void setUp() {
+        mRandom = new Random(RANDOM_SEED);
+    }
+
+    /**
+     * Providing a negative average number of trials should throw an {@link
+     * IllegalArgumentException}.
+     */
+    @Test
+    public void create_withNegativeAverageNumberOfTrials_throwsIllegalArgumentException() {
+        assertThrows(IllegalArgumentException.class, () -> new IsChunkBreakpoint(-1));
+    }
+
+    // Note: the following three tests are compute-intensive, so be cautious adding more.
+
+    /**
+     * If the provided average number of trials is zero, a breakpoint should be expected after one
+     * trial on average.
+     */
+    @Test
+    public void
+            isBreakpoint_withZeroAverageNumberOfTrials_isTrueOnAverageAfterOneTrial() {
+        assertExpectedTrials(new IsChunkBreakpoint(0), /*expectedTrials=*/ 1);
+    }
+
+    /**
+     * If the provided average number of trials is 512, a breakpoint should be expected after 512
+     * trials on average.
+     */
+    @Test
+    public void
+            isBreakpoint_with512AverageNumberOfTrials_isTrueOnAverageAfter512Trials() {
+        assertExpectedTrials(new IsChunkBreakpoint(512), /*expectedTrials=*/ 512);
+    }
+
+    /**
+     * If the provided average number of trials is 1024, a breakpoint should be expected after 1024
+     * trials on average.
+     */
+    @Test
+    public void
+            isBreakpoint_with1024AverageNumberOfTrials_isTrueOnAverageAfter1024Trials() {
+        assertExpectedTrials(new IsChunkBreakpoint(1024), /*expectedTrials=*/ 1024);
+    }
+
+    /** The number of leading zeros should be the logarithm of the average number of trials. */
+    @Test
+    public void getLeadingZeros_squaredIsAverageNumberOfTrials() {
+        for (int i = 0; i < BITS_PER_LONG; i++) {
+            long averageNumberOfTrials = (long) Math.pow(2, i);
+
+            int leadingZeros = new IsChunkBreakpoint(averageNumberOfTrials).getLeadingZeros();
+
+            assertThat(leadingZeros).isEqualTo(i);
+        }
+    }
+
+    private void assertExpectedTrials(IsChunkBreakpoint isChunkBreakpoint, long expectedTrials) {
+        long sum = 0;
+        for (int i = 0; i < NUMBER_OF_TESTS; i++) {
+            sum += numberOfTrialsTillBreakpoint(isChunkBreakpoint);
+        }
+        long averageTrials = sum / NUMBER_OF_TESTS;
+        assertThat((double) Math.abs(averageTrials - expectedTrials))
+                .isLessThan(TOLERANCE * expectedTrials);
+    }
+
+    private int numberOfTrialsTillBreakpoint(IsChunkBreakpoint isChunkBreakpoint) {
+        int trials = 0;
+
+        while (true) {
+            trials++;
+            if (isChunkBreakpoint.isBreakpoint(mRandom.nextLong())) {
+                return trials;
+            }
+        }
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64Test.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64Test.java
new file mode 100644
index 0000000..729580c
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/chunking/cdc/RabinFingerprint64Test.java
@@ -0,0 +1,132 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.chunking.cdc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+/** Tests for {@link RabinFingerprint64}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class RabinFingerprint64Test {
+    private static final int WINDOW_SIZE = 31;
+    private static final ImmutableList<String> TEST_STRINGS =
+            ImmutableList.of(
+                    "ervHTtChYXO6eXivYqThlyyzqkbRaOR",
+                    "IxaVunH9ZC3qneWfhj1GkBH4ys9CYqz",
+                    "wZRVjlE1p976icCFPX9pibk4PEBvjSH",
+                    "pHIVaT8x8If9D6s9croksgNmJpmGYWI");
+
+    private final RabinFingerprint64 mRabinFingerprint64 = new RabinFingerprint64();
+
+    /**
+     * No matter where in the input buffer a string occurs, {@link
+     * RabinFingerprint64#computeFingerprint64(byte, byte, long)} should return the same
+     * fingerprint.
+     */
+    @Test
+    public void computeFingerprint64_forSameWindow_returnsSameFingerprint() {
+        long fingerprint1 =
+                computeFingerprintAtPosition(getBytes(TEST_STRINGS.get(0)), WINDOW_SIZE - 1);
+        long fingerprint2 =
+                computeFingerprintAtPosition(
+                        getBytes(TEST_STRINGS.get(1), TEST_STRINGS.get(0)), WINDOW_SIZE * 2 - 1);
+        long fingerprint3 =
+                computeFingerprintAtPosition(
+                        getBytes(TEST_STRINGS.get(2), TEST_STRINGS.get(3), TEST_STRINGS.get(0)),
+                        WINDOW_SIZE * 3 - 1);
+        String stub = "abc";
+        long fingerprint4 =
+                computeFingerprintAtPosition(
+                        getBytes(stub, TEST_STRINGS.get(0)), WINDOW_SIZE + stub.length() - 1);
+
+        // Assert that all fingerprints are exactly the same
+        assertThat(ImmutableSet.of(fingerprint1, fingerprint2, fingerprint3, fingerprint4))
+                .hasSize(1);
+    }
+
+    /** The computed fingerprint should be different for different inputs. */
+    @Test
+    public void computeFingerprint64_withDifferentInput_returnsDifferentFingerprint() {
+        long fingerprint1 = computeFingerprintOf(TEST_STRINGS.get(0));
+        long fingerprint2 = computeFingerprintOf(TEST_STRINGS.get(1));
+        long fingerprint3 = computeFingerprintOf(TEST_STRINGS.get(2));
+        long fingerprint4 = computeFingerprintOf(TEST_STRINGS.get(3));
+
+        assertThat(ImmutableList.of(fingerprint1, fingerprint2, fingerprint3, fingerprint4))
+                .containsNoDuplicates();
+    }
+
+    /**
+     * An input with the same characters in a different order should return a different fingerprint.
+     */
+    @Test
+    public void computeFingerprint64_withSameInputInDifferentOrder_returnsDifferentFingerprint() {
+        long fingerprint1 = computeFingerprintOf("abcdefghijklmnopqrstuvwxyz12345");
+        long fingerprint2 = computeFingerprintOf("54321zyxwvutsrqponmlkjihgfedcba");
+        long fingerprint3 = computeFingerprintOf("4bcdefghijklmnopqrstuvwxyz123a5");
+        long fingerprint4 = computeFingerprintOf("bacdefghijklmnopqrstuvwxyz12345");
+
+        assertThat(ImmutableList.of(fingerprint1, fingerprint2, fingerprint3, fingerprint4))
+                .containsNoDuplicates();
+    }
+
+    /** UTF-8 bytes of all the given strings in order. */
+    private byte[] getBytes(String... strings) {
+        StringBuilder sb = new StringBuilder();
+        for (String s : strings) {
+            sb.append(s);
+        }
+        return sb.toString().getBytes(UTF_8);
+    }
+
+    /**
+     * The Rabin fingerprint of a window of bytes ending at {@code position} in the {@code bytes}
+     * array.
+     */
+    private long computeFingerprintAtPosition(byte[] bytes, int position) {
+        assertThat(position).isAtMost(bytes.length - 1);
+        long fingerprint = 0;
+        for (int i = 0; i <= position; i++) {
+            byte outChar;
+            if (i >= WINDOW_SIZE) {
+                outChar = bytes[i - WINDOW_SIZE];
+            } else {
+                outChar = (byte) 0;
+            }
+            fingerprint =
+                    mRabinFingerprint64.computeFingerprint64(
+                            /*inChar=*/ bytes[i], outChar, fingerprint);
+        }
+        return fingerprint;
+    }
+
+    private long computeFingerprintOf(String s) {
+        assertThat(s.length()).isEqualTo(WINDOW_SIZE);
+        return computeFingerprintAtPosition(s.getBytes(UTF_8), WINDOW_SIZE - 1);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManagerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManagerTest.java
new file mode 100644
index 0000000..5342efa
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyManagerTest.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.keys;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.content.Context;
+import android.platform.test.annotations.Presubmit;
+import android.security.keystore.recovery.InternalRecoveryServiceException;
+import android.security.keystore.recovery.RecoveryController;
+
+import com.android.server.testing.shadows.ShadowInternalRecoveryServiceException;
+import com.android.server.testing.shadows.ShadowRecoveryController;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+import java.security.SecureRandom;
+import java.util.Optional;
+
+/** Tests for {@link RecoverableKeyStoreSecondaryKeyManager}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+@Config(shadows = {ShadowRecoveryController.class, ShadowInternalRecoveryServiceException.class})
+public class RecoverableKeyStoreSecondaryKeyManagerTest {
+    private static final String BACKUP_KEY_ALIAS_PREFIX =
+            "com.android.server.backup/recoverablekeystore/";
+    private static final int BITS_PER_BYTE = 8;
+    private static final int BACKUP_KEY_SUFFIX_LENGTH_BYTES = 128 / BITS_PER_BYTE;
+    private static final int HEX_PER_BYTE = 2;
+    private static final int BACKUP_KEY_ALIAS_LENGTH =
+            BACKUP_KEY_ALIAS_PREFIX.length() + BACKUP_KEY_SUFFIX_LENGTH_BYTES * HEX_PER_BYTE;
+    private static final String NONEXISTENT_KEY_ALIAS = "NONEXISTENT_KEY_ALIAS";
+
+    private RecoverableKeyStoreSecondaryKeyManager mRecoverableKeyStoreSecondaryKeyManager;
+    private Context mContext;
+
+    /** Create a new {@link RecoverableKeyStoreSecondaryKeyManager} to use in tests. */
+    @Before
+    public void setUp() throws Exception {
+        mContext = RuntimeEnvironment.application;
+
+        mRecoverableKeyStoreSecondaryKeyManager =
+                new RecoverableKeyStoreSecondaryKeyManager(
+                        RecoveryController.getInstance(mContext), new SecureRandom());
+    }
+
+    /** Reset the {@link ShadowRecoveryController}. */
+    @After
+    public void tearDown() throws Exception {
+        ShadowRecoveryController.reset();
+    }
+
+    /** The generated key should always have the prefix {@code BACKUP_KEY_ALIAS_PREFIX}. */
+    @Test
+    public void generate_generatesKeyWithExpectedPrefix() throws Exception {
+        RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate();
+
+        assertThat(key.getAlias()).startsWith(BACKUP_KEY_ALIAS_PREFIX);
+    }
+
+    /** The generated key should always have length {@code BACKUP_KEY_ALIAS_LENGTH}. */
+    @Test
+    public void generate_generatesKeyWithExpectedLength() throws Exception {
+        RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate();
+
+        assertThat(key.getAlias()).hasLength(BACKUP_KEY_ALIAS_LENGTH);
+    }
+
+    /** Ensure that hidden API exceptions are rethrown when generating keys. */
+    @Test
+    public void generate_encounteringHiddenApiException_rethrowsException() {
+        ShadowRecoveryController.setThrowsInternalError(true);
+
+        assertThrows(
+                InternalRecoveryServiceException.class,
+                mRecoverableKeyStoreSecondaryKeyManager::generate);
+    }
+
+    /** Ensure that retrieved keys correspond to those generated earlier. */
+    @Test
+    public void get_getsKeyGeneratedByController() throws Exception {
+        RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate();
+
+        Optional<RecoverableKeyStoreSecondaryKey> retrievedKey =
+                mRecoverableKeyStoreSecondaryKeyManager.get(key.getAlias());
+
+        assertThat(retrievedKey.isPresent()).isTrue();
+        assertThat(retrievedKey.get().getAlias()).isEqualTo(key.getAlias());
+        assertThat(retrievedKey.get().getSecretKey()).isEqualTo(key.getSecretKey());
+    }
+
+    /**
+     * Ensure that a call to {@link RecoverableKeyStoreSecondaryKeyManager#get(java.lang.String)}
+     * for nonexistent aliases returns an emtpy {@link Optional}.
+     */
+    @Test
+    public void get_forNonExistentKey_returnsEmptyOptional() throws Exception {
+        Optional<RecoverableKeyStoreSecondaryKey> retrievedKey =
+                mRecoverableKeyStoreSecondaryKeyManager.get(NONEXISTENT_KEY_ALIAS);
+
+        assertThat(retrievedKey.isPresent()).isFalse();
+    }
+
+    /**
+     * Ensure that exceptions occurring during {@link
+     * RecoverableKeyStoreSecondaryKeyManager#get(java.lang.String)} are not rethrown.
+     */
+    @Test
+    public void get_encounteringInternalException_doesNotPropagateException() throws Exception {
+        ShadowRecoveryController.setThrowsInternalError(true);
+
+        // Should not throw exception
+        mRecoverableKeyStoreSecondaryKeyManager.get(NONEXISTENT_KEY_ALIAS);
+    }
+
+    /** Ensure that keys are correctly removed from the store. */
+    @Test
+    public void remove_removesKeyFromRecoverableStore() throws Exception {
+        RecoverableKeyStoreSecondaryKey key = mRecoverableKeyStoreSecondaryKeyManager.generate();
+
+        mRecoverableKeyStoreSecondaryKeyManager.remove(key.getAlias());
+
+        assertThat(RecoveryController.getInstance(mContext).getAliases())
+                .doesNotContain(key.getAlias());
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyTest.java
new file mode 100644
index 0000000..89977f8
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/RecoverableKeyStoreSecondaryKeyTest.java
@@ -0,0 +1,163 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.keys;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.testng.Assert.assertThrows;
+
+import android.content.Context;
+import android.platform.test.annotations.Presubmit;
+import android.security.keystore.recovery.RecoveryController;
+
+import com.android.server.backup.encryption.keys.RecoverableKeyStoreSecondaryKey.Status;
+import com.android.server.backup.testing.CryptoTestUtils;
+import com.android.server.testing.shadows.ShadowInternalRecoveryServiceException;
+import com.android.server.testing.shadows.ShadowRecoveryController;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+import javax.crypto.SecretKey;
+
+/** Tests for {@link RecoverableKeyStoreSecondaryKey}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+@Config(shadows = {ShadowRecoveryController.class, ShadowInternalRecoveryServiceException.class})
+public class RecoverableKeyStoreSecondaryKeyTest {
+    private static final String TEST_ALIAS = "test";
+    private static final int NONEXISTENT_STATUS_CODE = 42;
+
+    private RecoverableKeyStoreSecondaryKey mSecondaryKey;
+    private SecretKey mGeneratedSecretKey;
+    private Context mContext;
+
+    /** Instantiate a {@link RecoverableKeyStoreSecondaryKey} to use in tests. */
+    @Before
+    public void setUp() throws Exception {
+        mContext = RuntimeEnvironment.application;
+        mGeneratedSecretKey = CryptoTestUtils.generateAesKey();
+        mSecondaryKey = new RecoverableKeyStoreSecondaryKey(TEST_ALIAS, mGeneratedSecretKey);
+    }
+
+    /** Reset the {@link ShadowRecoveryController}. */
+    @After
+    public void tearDown() throws Exception {
+        ShadowRecoveryController.reset();
+    }
+
+    /**
+     * Checks that {@link RecoverableKeyStoreSecondaryKey#getAlias()} returns the value supplied in
+     * the constructor.
+     */
+    @Test
+    public void getAlias() {
+        String alias = mSecondaryKey.getAlias();
+
+        assertThat(alias).isEqualTo(TEST_ALIAS);
+    }
+
+    /**
+     * Checks that {@link RecoverableKeyStoreSecondaryKey#getSecretKey()} returns the value supplied
+     * in the constructor.
+     */
+    @Test
+    public void getSecretKey() {
+        SecretKey secretKey = mSecondaryKey.getSecretKey();
+
+        assertThat(secretKey).isEqualTo(mGeneratedSecretKey);
+    }
+
+    /**
+     * Checks that passing a secret key that is null to the constructor throws an exception.
+     */
+    @Test
+    public void constructor_withNullSecretKey_throwsNullPointerException() {
+        assertThrows(
+                NullPointerException.class,
+                () -> new RecoverableKeyStoreSecondaryKey(TEST_ALIAS, null));
+    }
+
+    /**
+     * Checks that passing an alias that is null to the constructor throws an exception.
+     */
+    @Test
+    public void constructor_withNullAlias_throwsNullPointerException() {
+        assertThrows(
+                NullPointerException.class,
+                () -> new RecoverableKeyStoreSecondaryKey(null, mGeneratedSecretKey));
+    }
+
+    /** Checks that the synced status is returned correctly. */
+    @Test
+    public void getStatus_whenSynced_returnsSynced() throws Exception {
+        setStatus(RecoveryController.RECOVERY_STATUS_SYNCED);
+
+        int status = mSecondaryKey.getStatus(mContext);
+
+        assertThat(status).isEqualTo(Status.SYNCED);
+    }
+
+    /** Checks that the in progress sync status is returned correctly. */
+    @Test
+    public void getStatus_whenNotSynced_returnsNotSynced() throws Exception {
+        setStatus(RecoveryController.RECOVERY_STATUS_SYNC_IN_PROGRESS);
+
+        int status = mSecondaryKey.getStatus(mContext);
+
+        assertThat(status).isEqualTo(Status.NOT_SYNCED);
+    }
+
+    /** Checks that the failure status is returned correctly. */
+    @Test
+    public void getStatus_onPermanentFailure_returnsDestroyed() throws Exception {
+        setStatus(RecoveryController.RECOVERY_STATUS_PERMANENT_FAILURE);
+
+        int status = mSecondaryKey.getStatus(mContext);
+
+        assertThat(status).isEqualTo(Status.DESTROYED);
+    }
+
+    /** Checks that an unknown status results in {@code NOT_SYNCED} being returned. */
+    @Test
+    public void getStatus_forUnknownStatusCode_returnsNotSynced() throws Exception {
+        setStatus(NONEXISTENT_STATUS_CODE);
+
+        int status = mSecondaryKey.getStatus(mContext);
+
+        assertThat(status).isEqualTo(Status.NOT_SYNCED);
+    }
+
+    /** Checks that an internal error results in {@code NOT_SYNCED} being returned. */
+    @Test
+    public void getStatus_onInternalError_returnsNotSynced() throws Exception {
+        ShadowRecoveryController.setThrowsInternalError(true);
+
+        int status = mSecondaryKey.getStatus(mContext);
+
+        assertThat(status).isEqualTo(Status.NOT_SYNCED);
+    }
+
+    private void setStatus(int status) throws Exception {
+        ShadowRecoveryController.setRecoveryStatus(TEST_ALIAS, status);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyGeneratorTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyGeneratorTest.java
new file mode 100644
index 0000000..48216f8
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyGeneratorTest.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.keys;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.security.SecureRandom;
+
+import javax.crypto.SecretKey;
+
+/** Tests for {@link TertiaryKeyGenerator}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class TertiaryKeyGeneratorTest {
+    private static final String KEY_ALGORITHM = "AES";
+    private static final int KEY_SIZE_BITS = 256;
+
+    private TertiaryKeyGenerator mTertiaryKeyGenerator;
+
+    /** Instantiate a new {@link TertiaryKeyGenerator} for use in tests. */
+    @Before
+    public void setUp() {
+        mTertiaryKeyGenerator = new TertiaryKeyGenerator(new SecureRandom());
+    }
+
+    /** Generated keys should be AES keys. */
+    @Test
+    public void generate_generatesAESKeys() {
+        SecretKey secretKey = mTertiaryKeyGenerator.generate();
+
+        assertThat(secretKey.getAlgorithm()).isEqualTo(KEY_ALGORITHM);
+    }
+
+    /** Generated keys should be 256 bits in size. */
+    @Test
+    public void generate_generates256BitKeys() {
+        SecretKey secretKey = mTertiaryKeyGenerator.generate();
+
+        assertThat(secretKey.getEncoded()).hasLength(KEY_SIZE_BITS / 8);
+    }
+
+    /**
+     * Subsequent calls to {@link TertiaryKeyGenerator#generate()} should generate different keys.
+     */
+    @Test
+    public void generate_generatesNewKeys() {
+        SecretKey key1 = mTertiaryKeyGenerator.generate();
+        SecretKey key2 = mTertiaryKeyGenerator.generate();
+
+        assertThat(key1).isNotEqualTo(key2);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTrackerTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTrackerTest.java
new file mode 100644
index 0000000..49bb410
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/keys/TertiaryKeyRotationTrackerTest.java
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.keys;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+/** Tests for {@link TertiaryKeyRotationTracker}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class TertiaryKeyRotationTrackerTest {
+    private static final String PACKAGE_1 = "com.package.one";
+    private static final int NUMBER_OF_BACKUPS_BEFORE_ROTATION = 31;
+
+    private TertiaryKeyRotationTracker mTertiaryKeyRotationTracker;
+
+    /** Instantiate a {@link TertiaryKeyRotationTracker} for use in tests. */
+    @Before
+    public void setUp() {
+        mTertiaryKeyRotationTracker = newInstance();
+    }
+
+    /** New packages should not be due for key rotation. */
+    @Test
+    public void isKeyRotationDue_forNewPackage_isFalse() {
+        // Simulate a new package by not calling simulateBackups(). As a result, PACKAGE_1 hasn't
+        // been seen by mTertiaryKeyRotationTracker before.
+        boolean keyRotationDue = mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1);
+
+        assertThat(keyRotationDue).isFalse();
+    }
+
+    /**
+     * Key rotation should not be due after less than {@code NUMBER_OF_BACKUPS_BEFORE_ROTATION}
+     * backups.
+     */
+    @Test
+    public void isKeyRotationDue_afterLessThanRotationAmountBackups_isFalse() {
+        simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION - 1);
+
+        boolean keyRotationDue = mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1);
+
+        assertThat(keyRotationDue).isFalse();
+    }
+
+    /** Key rotation should be due after {@code NUMBER_OF_BACKUPS_BEFORE_ROTATION} backups. */
+    @Test
+    public void isKeyRotationDue_afterRotationAmountBackups_isTrue() {
+        simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION);
+
+        boolean keyRotationDue = mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1);
+
+        assertThat(keyRotationDue).isTrue();
+    }
+
+    /**
+     * A call to {@link TertiaryKeyRotationTracker#resetCountdown(String)} should make sure no key
+     * rotation is due.
+     */
+    @Test
+    public void resetCountdown_makesKeyRotationNotDue() {
+        simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION);
+
+        mTertiaryKeyRotationTracker.resetCountdown(PACKAGE_1);
+
+        assertThat(mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1)).isFalse();
+    }
+
+    /**
+     * New instances of {@link TertiaryKeyRotationTracker} should read state about the number of
+     * backups from disk.
+     */
+    @Test
+    public void isKeyRotationDue_forNewInstance_readsStateFromDisk() {
+        simulateBackups(PACKAGE_1, NUMBER_OF_BACKUPS_BEFORE_ROTATION);
+
+        boolean keyRotationDueForNewInstance = newInstance().isKeyRotationDue(PACKAGE_1);
+
+        assertThat(keyRotationDueForNewInstance).isTrue();
+    }
+
+    /**
+     * A call to {@link TertiaryKeyRotationTracker#markAllForRotation()} should mark all previously
+     * seen packages for rotation.
+     */
+    @Test
+    public void markAllForRotation_marksSeenPackagesForKeyRotation() {
+        simulateBackups(PACKAGE_1, /*numberOfBackups=*/ 1);
+
+        mTertiaryKeyRotationTracker.markAllForRotation();
+
+        assertThat(mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1)).isTrue();
+    }
+
+    /**
+     * A call to {@link TertiaryKeyRotationTracker#markAllForRotation()} should not mark any new
+     * packages for rotation.
+     */
+    @Test
+    public void markAllForRotation_doesNotMarkUnseenPackages() {
+        mTertiaryKeyRotationTracker.markAllForRotation();
+
+        assertThat(mTertiaryKeyRotationTracker.isKeyRotationDue(PACKAGE_1)).isFalse();
+    }
+
+    private void simulateBackups(String packageName, int numberOfBackups) {
+        while (numberOfBackups > 0) {
+            mTertiaryKeyRotationTracker.recordBackup(packageName);
+            numberOfBackups--;
+        }
+    }
+
+    private static TertiaryKeyRotationTracker newInstance() {
+        return TertiaryKeyRotationTracker.getInstance(RuntimeEnvironment.application);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/BackupEncryptionDbTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/BackupEncryptionDbTest.java
new file mode 100644
index 0000000..87f21bf
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/BackupEncryptionDbTest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+/** Tests for {@link BackupEncryptionDb}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class BackupEncryptionDbTest {
+    private BackupEncryptionDb mBackupEncryptionDb;
+
+    /** Creates an empty {@link BackupEncryptionDb} */
+    @Before
+    public void setUp() {
+        mBackupEncryptionDb = BackupEncryptionDb.newInstance(RuntimeEnvironment.application);
+    }
+
+    /**
+     * Tests that the tertiary keys table gets cleared when calling {@link
+     * BackupEncryptionDb#clear()}.
+     */
+    @Test
+    public void clear_withNonEmptyTertiaryKeysTable_clearsTertiaryKeysTable() throws Exception {
+        String secondaryKeyAlias = "secondaryKeyAlias";
+        TertiaryKeysTable tertiaryKeysTable = mBackupEncryptionDb.getTertiaryKeysTable();
+        tertiaryKeysTable.addKey(new TertiaryKey(secondaryKeyAlias, "packageName", new byte[0]));
+
+        mBackupEncryptionDb.clear();
+
+        assertThat(tertiaryKeysTable.getAllKeys(secondaryKeyAlias)).isEmpty();
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/TertiaryKeysTableTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/TertiaryKeysTableTest.java
new file mode 100644
index 0000000..319ec89
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/storage/TertiaryKeysTableTest.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright (C) 2018 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.backup.encryption.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.testing.CryptoTestUtils;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+
+import java.util.Map;
+import java.util.Optional;
+
+/** Tests for {@link TertiaryKeysTable}. */
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class TertiaryKeysTableTest {
+    private static final int KEY_SIZE_BYTES = 32;
+    private static final String SECONDARY_ALIAS = "phoebe";
+    private static final String PACKAGE_NAME = "generic.package.name";
+
+    private TertiaryKeysTable mTertiaryKeysTable;
+
+    /** Creates an empty {@link BackupEncryptionDb}. */
+    @Before
+    public void setUp() {
+        mTertiaryKeysTable =
+                BackupEncryptionDb.newInstance(RuntimeEnvironment.application)
+                        .getTertiaryKeysTable();
+    }
+
+    /** Tests that new {@link TertiaryKey}s get successfully added to the database. */
+    @Test
+    public void addKey_onEmptyDatabase_putsKeyInDb() throws Exception {
+        byte[] key = generateRandomKey();
+        TertiaryKey keyToInsert = new TertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME, key);
+
+        long result = mTertiaryKeysTable.addKey(keyToInsert);
+
+        assertThat(result).isNotEqualTo(-1);
+        Optional<TertiaryKey> maybeKeyInDb =
+                mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME);
+        assertThat(maybeKeyInDb.isPresent()).isTrue();
+        TertiaryKey keyInDb = maybeKeyInDb.get();
+        assertTertiaryKeysEqual(keyInDb, keyToInsert);
+    }
+
+    /** Tests that keys replace older keys with the same secondary alias and package name. */
+    @Test
+    public void addKey_havingSameSecondaryAliasAndPackageName_replacesOldKey() throws Exception {
+        mTertiaryKeysTable.addKey(
+                new TertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME, generateRandomKey()));
+        byte[] newKey = generateRandomKey();
+
+        long result =
+                mTertiaryKeysTable.addKey(new TertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME, newKey));
+
+        assertThat(result).isNotEqualTo(-1);
+        TertiaryKey keyInDb = mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME).get();
+        assertThat(keyInDb.getWrappedKeyBytes()).isEqualTo(newKey);
+    }
+
+    /**
+     * Tests that keys do not replace older keys with the same package name but a different alias.
+     */
+    @Test
+    public void addKey_havingSamePackageNameButDifferentAlias_doesNotReplaceOldKey()
+            throws Exception {
+        String alias2 = "karl";
+        TertiaryKey key1 = generateTertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME);
+        TertiaryKey key2 = generateTertiaryKey(alias2, PACKAGE_NAME);
+
+        long primaryKey1 = mTertiaryKeysTable.addKey(key1);
+        long primaryKey2 = mTertiaryKeysTable.addKey(key2);
+
+        assertThat(primaryKey1).isNotEqualTo(primaryKey2);
+        assertThat(mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME).isPresent()).isTrue();
+        assertTertiaryKeysEqual(
+                mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME).get(), key1);
+        assertThat(mTertiaryKeysTable.getKey(alias2, PACKAGE_NAME).isPresent()).isTrue();
+        assertTertiaryKeysEqual(mTertiaryKeysTable.getKey(alias2, PACKAGE_NAME).get(), key2);
+    }
+
+    /**
+     * Tests that {@link TertiaryKeysTable#getKey(String, String)} returns an empty {@link Optional}
+     * for a missing key.
+     */
+    @Test
+    public void getKey_forMissingKey_returnsEmptyOptional() throws Exception {
+        Optional<TertiaryKey> key = mTertiaryKeysTable.getKey(SECONDARY_ALIAS, PACKAGE_NAME);
+
+        assertThat(key.isPresent()).isFalse();
+    }
+
+    /**
+     * Tests that {@link TertiaryKeysTable#getAllKeys(String)} returns an empty map when no keys
+     * with the secondary alias exist.
+     */
+    @Test
+    public void getAllKeys_withNoKeysForAlias_returnsEmptyMap() throws Exception {
+        assertThat(mTertiaryKeysTable.getAllKeys(SECONDARY_ALIAS)).isEmpty();
+    }
+
+    /**
+     * Tests that {@link TertiaryKeysTable#getAllKeys(String)} returns all keys corresponding to the
+     * provided secondary alias.
+     */
+    @Test
+    public void getAllKeys_withMatchingKeys_returnsAllKeysWrappedWithSecondary() throws Exception {
+        TertiaryKey key1 = generateTertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME);
+        mTertiaryKeysTable.addKey(key1);
+        String package2 = "generic.package.two";
+        TertiaryKey key2 = generateTertiaryKey(SECONDARY_ALIAS, package2);
+        mTertiaryKeysTable.addKey(key2);
+        String package3 = "generic.package.three";
+        TertiaryKey key3 = generateTertiaryKey(SECONDARY_ALIAS, package3);
+        mTertiaryKeysTable.addKey(key3);
+
+        Map<String, TertiaryKey> keysByPackageName = mTertiaryKeysTable.getAllKeys(SECONDARY_ALIAS);
+
+        assertThat(keysByPackageName).hasSize(3);
+        assertThat(keysByPackageName).containsKey(PACKAGE_NAME);
+        assertTertiaryKeysEqual(keysByPackageName.get(PACKAGE_NAME), key1);
+        assertThat(keysByPackageName).containsKey(package2);
+        assertTertiaryKeysEqual(keysByPackageName.get(package2), key2);
+        assertThat(keysByPackageName).containsKey(package3);
+        assertTertiaryKeysEqual(keysByPackageName.get(package3), key3);
+    }
+
+    /**
+     * Tests that {@link TertiaryKeysTable#getAllKeys(String)} does not return any keys wrapped with
+     * another alias.
+     */
+    @Test
+    public void getAllKeys_withMatchingKeys_doesNotReturnKeysWrappedWithOtherAlias()
+            throws Exception {
+        mTertiaryKeysTable.addKey(generateTertiaryKey(SECONDARY_ALIAS, PACKAGE_NAME));
+        mTertiaryKeysTable.addKey(generateTertiaryKey("somekey", "generic.package.two"));
+
+        Map<String, TertiaryKey> keysByPackageName = mTertiaryKeysTable.getAllKeys(SECONDARY_ALIAS);
+
+        assertThat(keysByPackageName).hasSize(1);
+        assertThat(keysByPackageName).containsKey(PACKAGE_NAME);
+    }
+
+    private void assertTertiaryKeysEqual(TertiaryKey a, TertiaryKey b) {
+        assertThat(a.getSecondaryKeyAlias()).isEqualTo(b.getSecondaryKeyAlias());
+        assertThat(a.getPackageName()).isEqualTo(b.getPackageName());
+        assertThat(a.getWrappedKeyBytes()).isEqualTo(b.getWrappedKeyBytes());
+    }
+
+    private TertiaryKey generateTertiaryKey(String alias, String packageName) {
+        return new TertiaryKey(alias, packageName, generateRandomKey());
+    }
+
+    private byte[] generateRandomKey() {
+        return CryptoTestUtils.generateRandomBytes(KEY_SIZE_BYTES);
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypterTest.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypterTest.java
new file mode 100644
index 0000000..21c4e07
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/encryption/tasks/BackupStreamEncrypterTest.java
@@ -0,0 +1,262 @@
+/*
+ * Copyright (C) 2019 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.backup.encryption.tasks;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.platform.test.annotations.Presubmit;
+
+import com.android.server.backup.encryption.chunk.ChunkHash;
+import com.android.server.backup.encryption.chunking.EncryptedChunk;
+import com.android.server.backup.testing.CryptoTestUtils;
+import com.android.server.backup.testing.RandomInputStream;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+
+import java.io.ByteArrayInputStream;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Random;
+
+import javax.crypto.SecretKey;
+
+@RunWith(RobolectricTestRunner.class)
+@Presubmit
+public class BackupStreamEncrypterTest {
+    private static final int SALT_LENGTH = 32;
+    private static final int BITS_PER_BYTE = 8;
+    private static final int BYTES_PER_KILOBYTE = 1024;
+    private static final int BYTES_PER_MEGABYTE = 1024 * 1024;
+    private static final int MIN_CHUNK_SIZE = 2 * BYTES_PER_KILOBYTE;
+    private static final int AVERAGE_CHUNK_SIZE = 4 * BYTES_PER_KILOBYTE;
+    private static final int MAX_CHUNK_SIZE = 64 * BYTES_PER_KILOBYTE;
+    private static final int BACKUP_SIZE = 2 * BYTES_PER_MEGABYTE;
+    private static final int SMALL_BACKUP_SIZE = BYTES_PER_KILOBYTE;
+    // 16 bytes for the mac. iv is encoded in a separate field.
+    private static final int BYTES_OVERHEAD_PER_CHUNK = 16;
+    private static final int MESSAGE_DIGEST_SIZE_IN_BYTES = 256 / BITS_PER_BYTE;
+    private static final int RANDOM_SEED = 42;
+    private static final double TOLERANCE = 0.1;
+
+    private Random mRandom;
+    private SecretKey mSecretKey;
+    private byte[] mSalt;
+
+    @Before
+    public void setUp() throws Exception {
+        mSecretKey = CryptoTestUtils.generateAesKey();
+
+        mSalt = new byte[SALT_LENGTH];
+        // Make these tests deterministic
+        mRandom = new Random(RANDOM_SEED);
+        mRandom.nextBytes(mSalt);
+    }
+
+    @Test
+    public void testBackup_producesChunksOfTheGivenAverageSize() throws Exception {
+        BackupEncrypter.Result result = runBackup(BACKUP_SIZE);
+
+        long totalSize = 0;
+        for (EncryptedChunk chunk : result.getNewChunks()) {
+            totalSize += chunk.encryptedBytes().length;
+        }
+
+        double meanSize = totalSize / result.getNewChunks().size();
+        double expectedChunkSize = AVERAGE_CHUNK_SIZE + BYTES_OVERHEAD_PER_CHUNK;
+        assertThat(Math.abs(meanSize - expectedChunkSize) / expectedChunkSize)
+                .isLessThan(TOLERANCE);
+    }
+
+    @Test
+    public void testBackup_producesNoChunksSmallerThanMinSize() throws Exception {
+        BackupEncrypter.Result result = runBackup(BACKUP_SIZE);
+        List<EncryptedChunk> chunks = result.getNewChunks();
+
+        // Last chunk could be smaller, depending on the file size and how it is chunked
+        for (EncryptedChunk chunk : chunks.subList(0, chunks.size() - 2)) {
+            assertThat(chunk.encryptedBytes().length)
+                    .isAtLeast(MIN_CHUNK_SIZE + BYTES_OVERHEAD_PER_CHUNK);
+        }
+    }
+
+    @Test
+    public void testBackup_producesNoChunksLargerThanMaxSize() throws Exception {
+        BackupEncrypter.Result result = runBackup(BACKUP_SIZE);
+        List<EncryptedChunk> chunks = result.getNewChunks();
+
+        for (EncryptedChunk chunk : chunks) {
+            assertThat(chunk.encryptedBytes().length)
+                    .isAtMost(MAX_CHUNK_SIZE + BYTES_OVERHEAD_PER_CHUNK);
+        }
+    }
+
+    @Test
+    public void testBackup_producesAFileOfTheExpectedSize() throws Exception {
+        BackupEncrypter.Result result = runBackup(BACKUP_SIZE);
+        HashMap<ChunkHash, EncryptedChunk> chunksBySha256 =
+                chunksIndexedByKey(result.getNewChunks());
+
+        int expectedSize = BACKUP_SIZE + result.getAllChunks().size() * BYTES_OVERHEAD_PER_CHUNK;
+        int size = 0;
+        for (ChunkHash byteString : result.getAllChunks()) {
+            size += chunksBySha256.get(byteString).encryptedBytes().length;
+        }
+        assertThat(size).isEqualTo(expectedSize);
+    }
+
+    @Test
+    public void testBackup_forSameFile_producesNoNewChunks() throws Exception {
+        byte[] backupData = getRandomData(BACKUP_SIZE);
+        BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of());
+
+        BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks());
+
+        assertThat(incrementalResult.getNewChunks()).isEmpty();
+    }
+
+    @Test
+    public void testBackup_onlyUpdatesChangedChunks() throws Exception {
+        byte[] backupData = getRandomData(BACKUP_SIZE);
+        BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of());
+
+        // Let's update the 2nd and 5th chunk
+        backupData[positionOfChunk(result, 1)]++;
+        backupData[positionOfChunk(result, 4)]++;
+        BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks());
+
+        assertThat(incrementalResult.getNewChunks()).hasSize(2);
+    }
+
+    @Test
+    public void testBackup_doesNotIncludeUpdatedChunksInNewListing() throws Exception {
+        byte[] backupData = getRandomData(BACKUP_SIZE);
+        BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of());
+
+        // Let's update the 2nd and 5th chunk
+        backupData[positionOfChunk(result, 1)]++;
+        backupData[positionOfChunk(result, 4)]++;
+        BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks());
+
+        List<EncryptedChunk> newChunks = incrementalResult.getNewChunks();
+        List<ChunkHash> chunkListing = result.getAllChunks();
+        assertThat(newChunks).doesNotContain(chunkListing.get(1));
+        assertThat(newChunks).doesNotContain(chunkListing.get(4));
+    }
+
+    @Test
+    public void testBackup_includesUnchangedChunksInNewListing() throws Exception {
+        byte[] backupData = getRandomData(BACKUP_SIZE);
+        BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of());
+
+        // Let's update the 2nd and 5th chunk
+        backupData[positionOfChunk(result, 1)]++;
+        backupData[positionOfChunk(result, 4)]++;
+        BackupEncrypter.Result incrementalResult = runBackup(backupData, result.getAllChunks());
+
+        HashSet<ChunkHash> chunksPresentInIncremental =
+                new HashSet<>(incrementalResult.getAllChunks());
+        chunksPresentInIncremental.removeAll(result.getAllChunks());
+
+        assertThat(chunksPresentInIncremental).hasSize(2);
+    }
+
+    @Test
+    public void testBackup_forSameData_createsSameDigest() throws Exception {
+        byte[] backupData = getRandomData(SMALL_BACKUP_SIZE);
+
+        BackupEncrypter.Result result = runBackup(backupData, ImmutableList.of());
+        BackupEncrypter.Result result2 = runBackup(backupData, ImmutableList.of());
+        assertThat(result.getDigest()).isEqualTo(result2.getDigest());
+    }
+
+    @Test
+    public void testBackup_forDifferentData_createsDifferentDigest() throws Exception {
+        byte[] backup1Data = getRandomData(SMALL_BACKUP_SIZE);
+        byte[] backup2Data = getRandomData(SMALL_BACKUP_SIZE);
+
+        BackupEncrypter.Result result = runBackup(backup1Data, ImmutableList.of());
+        BackupEncrypter.Result result2 = runBackup(backup2Data, ImmutableList.of());
+        assertThat(result.getDigest()).isNotEqualTo(result2.getDigest());
+    }
+
+    @Test
+    public void testBackup_createsDigestOf32Bytes() throws Exception {
+        assertThat(runBackup(getRandomData(SMALL_BACKUP_SIZE), ImmutableList.of()).getDigest())
+                .hasLength(MESSAGE_DIGEST_SIZE_IN_BYTES);
+    }
+
+    private byte[] getRandomData(int size) throws Exception {
+        RandomInputStream randomInputStream = new RandomInputStream(mRandom, size);
+        byte[] backupData = new byte[size];
+        randomInputStream.read(backupData);
+        return backupData;
+    }
+
+    private BackupEncrypter.Result runBackup(int backupSize) throws Exception {
+        RandomInputStream dataStream = new RandomInputStream(mRandom, backupSize);
+        BackupStreamEncrypter task =
+                new BackupStreamEncrypter(
+                        dataStream, MIN_CHUNK_SIZE, MAX_CHUNK_SIZE, AVERAGE_CHUNK_SIZE);
+        return task.backup(mSecretKey, mSalt, ImmutableSet.of());
+    }
+
+    private BackupEncrypter.Result runBackup(byte[] data, List<ChunkHash> existingChunks)
+            throws Exception {
+        ByteArrayInputStream dataStream = new ByteArrayInputStream(data);
+        BackupStreamEncrypter task =
+                new BackupStreamEncrypter(
+                        dataStream, MIN_CHUNK_SIZE, MAX_CHUNK_SIZE, AVERAGE_CHUNK_SIZE);
+        return task.backup(mSecretKey, mSalt, ImmutableSet.copyOf(existingChunks));
+    }
+
+    /** Returns a {@link HashMap} of the chunks, indexed by the SHA-256 Mac key. */
+    private static HashMap<ChunkHash, EncryptedChunk> chunksIndexedByKey(
+            List<EncryptedChunk> chunks) {
+        HashMap<ChunkHash, EncryptedChunk> chunksByKey = new HashMap<>();
+        for (EncryptedChunk chunk : chunks) {
+            chunksByKey.put(chunk.key(), chunk);
+        }
+        return chunksByKey;
+    }
+
+    /**
+     * Returns the start position of the chunk in the plaintext backup data.
+     *
+     * @param result The result from a backup.
+     * @param index The index of the chunk in question.
+     * @return the start position.
+     */
+    private static int positionOfChunk(BackupEncrypter.Result result, int index) {
+        HashMap<ChunkHash, EncryptedChunk> byKey = chunksIndexedByKey(result.getNewChunks());
+        List<ChunkHash> listing = result.getAllChunks();
+
+        int position = 0;
+        for (int i = 0; i < index - 1; i++) {
+            EncryptedChunk chunk = byKey.get(listing.get(i));
+            position += chunk.encryptedBytes().length - BYTES_OVERHEAD_PER_CHUNK;
+        }
+
+        return position;
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/testing/RandomInputStream.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/testing/RandomInputStream.java
new file mode 100644
index 0000000..998da0b
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/backup/testing/RandomInputStream.java
@@ -0,0 +1,78 @@
+/*
+ * Copyright (C) 2019 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.backup.testing;
+
+import static com.android.internal.util.Preconditions.checkArgument;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Random;
+
+/** {@link InputStream} that generates random bytes up to a given length. For testing purposes. */
+public class RandomInputStream extends InputStream {
+    private static final int BYTE_MAX_VALUE = 255;
+
+    private final Random mRandom;
+    private final int mSizeBytes;
+    private int mBytesRead;
+
+    /**
+     * A new instance, generating {@code sizeBytes} from {@code random} as a source.
+     *
+     * @param random Source of random bytes.
+     * @param sizeBytes The number of bytes to generate before closing the stream.
+     */
+    public RandomInputStream(Random random, int sizeBytes) {
+        mRandom = random;
+        mSizeBytes = sizeBytes;
+        mBytesRead = 0;
+    }
+
+    @Override
+    public int read() throws IOException {
+        if (isFinished()) {
+            return -1;
+        }
+        mBytesRead++;
+        return mRandom.nextInt(BYTE_MAX_VALUE);
+    }
+
+    @Override
+    public int read(byte[] b, int off, int len) throws IOException {
+        checkArgument(off + len <= b.length);
+        if (isFinished()) {
+            return -1;
+        }
+        int length = Math.min(len, mSizeBytes - mBytesRead);
+        int end = off + length;
+
+        for (int i = off; i < end; ) {
+            for (int rnd = mRandom.nextInt(), n = Math.min(end - i, Integer.SIZE / Byte.SIZE);
+                    n-- > 0;
+                    rnd >>= Byte.SIZE) {
+                b[i++] = (byte) rnd;
+            }
+        }
+
+        mBytesRead += length;
+        return length;
+    }
+
+    private boolean isFinished() {
+        return mBytesRead >= mSizeBytes;
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java
new file mode 100644
index 0000000..3f3494d
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/CryptoTestUtils.java
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2018 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.backup.testing;
+
+import java.security.NoSuchAlgorithmException;
+import java.util.Random;
+
+import javax.crypto.KeyGenerator;
+import javax.crypto.SecretKey;
+
+/** Helpers for crypto code tests. */
+public class CryptoTestUtils {
+    private static final String KEY_ALGORITHM = "AES";
+    private static final int KEY_SIZE_BITS = 256;
+
+    private CryptoTestUtils() {}
+
+    public static SecretKey generateAesKey() throws NoSuchAlgorithmException {
+        KeyGenerator keyGenerator = KeyGenerator.getInstance(KEY_ALGORITHM);
+        keyGenerator.init(KEY_SIZE_BITS);
+        return keyGenerator.generateKey();
+    }
+
+    /** Generates a byte array of size {@code n} containing random bytes. */
+    public static byte[] generateRandomBytes(int n) {
+        byte[] bytes = new byte[n];
+        Random random = new Random();
+        random.nextBytes(bytes);
+        return bytes;
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowInternalRecoveryServiceException.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowInternalRecoveryServiceException.java
new file mode 100644
index 0000000..9c06d81
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowInternalRecoveryServiceException.java
@@ -0,0 +1,43 @@
+/*
+ * Copyright (C) 2018 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.testing.shadows;
+
+import android.security.keystore.recovery.InternalRecoveryServiceException;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+
+/** Shadow {@link InternalRecoveryServiceException}. */
+@Implements(InternalRecoveryServiceException.class)
+public class ShadowInternalRecoveryServiceException {
+    private String mMessage;
+
+    @Implementation
+    public void __constructor__(String message) {
+        mMessage = message;
+    }
+
+    @Implementation
+    public void __constructor__(String message, Throwable cause) {
+        mMessage = message;
+    }
+
+    @Implementation
+    public String getMessage() {
+        return mMessage;
+    }
+}
diff --git a/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowRecoveryController.java b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowRecoveryController.java
new file mode 100644
index 0000000..7dad8a4
--- /dev/null
+++ b/packages/BackupEncryption/test/robolectric/src/com/android/server/testing/shadows/ShadowRecoveryController.java
@@ -0,0 +1,152 @@
+/*
+ * Copyright (C) 2018 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.testing.shadows;
+
+import android.content.Context;
+import android.security.keystore.recovery.InternalRecoveryServiceException;
+import android.security.keystore.recovery.LockScreenRequiredException;
+import android.security.keystore.recovery.RecoveryController;
+
+import com.google.common.collect.ImmutableList;
+
+import org.robolectric.annotation.Implementation;
+import org.robolectric.annotation.Implements;
+import org.robolectric.annotation.Resetter;
+
+import java.lang.reflect.Constructor;
+import java.security.Key;
+import java.security.NoSuchAlgorithmException;
+import java.security.UnrecoverableKeyException;
+import java.util.HashMap;
+import java.util.List;
+
+import javax.crypto.KeyGenerator;
+
+/**
+ * Shadow of {@link RecoveryController}.
+ *
+ * <p>Instead of generating keys via the {@link RecoveryController}, this shadow generates them in
+ * memory.
+ */
+@Implements(RecoveryController.class)
+public class ShadowRecoveryController {
+    private static final String KEY_GENERATOR_ALGORITHM = "AES";
+    private static final int KEY_SIZE_BITS = 256;
+
+    private static boolean sIsSupported = true;
+    private static boolean sThrowsInternalError = false;
+    private static HashMap<String, Key> sKeysByAlias = new HashMap<>();
+    private static HashMap<String, Integer> sKeyStatusesByAlias = new HashMap<>();
+
+    @Implementation
+    public void __constructor__() {
+        // do not throw
+    }
+
+    @Implementation
+    public static RecoveryController getInstance(Context context) {
+        // Call non-public constructor.
+        try {
+            Constructor<RecoveryController> constructor = RecoveryController.class.getConstructor();
+            return constructor.newInstance();
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Implementation
+    public static boolean isRecoverableKeyStoreEnabled(Context context) {
+        return sIsSupported;
+    }
+
+    @Implementation
+    public Key generateKey(String alias)
+            throws InternalRecoveryServiceException, LockScreenRequiredException {
+        maybeThrowError();
+        KeyGenerator keyGenerator;
+        try {
+            keyGenerator = KeyGenerator.getInstance(KEY_GENERATOR_ALGORITHM);
+        } catch (NoSuchAlgorithmException e) {
+            // Should never happen
+            throw new RuntimeException(e);
+        }
+
+        keyGenerator.init(KEY_SIZE_BITS);
+        Key key = keyGenerator.generateKey();
+        sKeysByAlias.put(alias, key);
+        sKeyStatusesByAlias.put(alias, RecoveryController.RECOVERY_STATUS_SYNC_IN_PROGRESS);
+        return key;
+    }
+
+    @Implementation
+    public Key getKey(String alias)
+            throws InternalRecoveryServiceException, UnrecoverableKeyException {
+        return sKeysByAlias.get(alias);
+    }
+
+    @Implementation
+    public void removeKey(String alias) throws InternalRecoveryServiceException {
+        sKeyStatusesByAlias.remove(alias);
+        sKeysByAlias.remove(alias);
+    }
+
+    @Implementation
+    public int getRecoveryStatus(String alias) throws InternalRecoveryServiceException {
+        maybeThrowError();
+        return sKeyStatusesByAlias.getOrDefault(
+                alias, RecoveryController.RECOVERY_STATUS_PERMANENT_FAILURE);
+    }
+
+    @Implementation
+    public List<String> getAliases() throws InternalRecoveryServiceException {
+        return ImmutableList.copyOf(sKeyStatusesByAlias.keySet());
+    }
+
+    private static void maybeThrowError() throws InternalRecoveryServiceException {
+        if (sThrowsInternalError) {
+            throw new InternalRecoveryServiceException("test error");
+        }
+    }
+
+    /** Sets the recovery status of the key with {@code alias} to {@code status}. */
+    public static void setRecoveryStatus(String alias, int status) {
+        sKeyStatusesByAlias.put(alias, status);
+    }
+
+    /** Sets all existing keys to being synced. */
+    public static void syncAllKeys() {
+        for (String alias : sKeysByAlias.keySet()) {
+            sKeyStatusesByAlias.put(alias, RecoveryController.RECOVERY_STATUS_SYNCED);
+        }
+    }
+
+    public static void setThrowsInternalError(boolean throwsInternalError) {
+        ShadowRecoveryController.sThrowsInternalError = throwsInternalError;
+    }
+
+    public static void setIsSupported(boolean isSupported) {
+        ShadowRecoveryController.sIsSupported = isSupported;
+    }
+
+    @Resetter
+    public static void reset() {
+        sIsSupported = true;
+        sThrowsInternalError = false;
+        sKeysByAlias.clear();
+        sKeyStatusesByAlias.clear();
+    }
+}