sample_updater: add non-streaming demo

SampleUpdater app was tested manually on a device.
There are unit tests for utility classes.

SampleUpdater app demonstrates how to use Android Update Engine to
apply A/B (seamless) update.
This CL contains demo of non-stream update using async update_engine,
which is accessed directly from an activity.
This app also shows logs from update_engine on the UI.
Instructions can be found in `README.md`.

- Create a UI with list of configs, current version, control buttons and a progress bar
- Add PayloadSpec and PayloadSpecs for working with update zip file
- Add UpdateConfig for working with json config files
- Add applying non-streaming update

Test: tested manually and unit tests for utilities
Change-Id: I05d4a46ad9cf8b334c9c60c7dd4da486dac0400a
Signed-off-by: Zhomart Mukhamejanov <zhomart@google.com>
diff --git a/sample_updater/.gitignore b/sample_updater/.gitignore
new file mode 100644
index 0000000..487263f
--- /dev/null
+++ b/sample_updater/.gitignore
@@ -0,0 +1,9 @@
+*~
+*.bak
+*.pyc
+*.pyc-2.4
+Thumbs.db
+*.iml
+.idea/
+gen/
+.vscode
diff --git a/sample_updater/Android.mk b/sample_updater/Android.mk
index 2b0fcbe..2786de4 100644
--- a/sample_updater/Android.mk
+++ b/sample_updater/Android.mk
@@ -15,13 +15,18 @@
 #
 
 LOCAL_PATH := $(call my-dir)
-
 include $(CLEAR_VARS)
 
-LOCAL_PACKAGE_NAME := SystemUpdateApp
+LOCAL_PACKAGE_NAME := SystemUpdaterSample
 LOCAL_SDK_VERSION := system_current
-LOCAL_MODULE_TAGS := optional
+LOCAL_MODULE_TAGS := samples
+
+# TODO: enable proguard and use proguard.flags file
+LOCAL_PROGUARD_ENABLED := disabled
 
 LOCAL_SRC_FILES := $(call all-java-files-under, src)
 
 include $(BUILD_PACKAGE)
+
+# Use the following include to make our test apk.
+include $(call all-makefiles-under,$(LOCAL_PATH))
diff --git a/sample_updater/AndroidManifest.xml b/sample_updater/AndroidManifest.xml
index 66414b5..5bbb21c 100644
--- a/sample_updater/AndroidManifest.xml
+++ b/sample_updater/AndroidManifest.xml
@@ -15,17 +15,22 @@
   -->
 
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
-          package="com.android.update">
+          package="com.example.android.systemupdatersample">
 
-  <application android:label="Sample Updater">
-    <activity android:name=".ui.SystemUpdateActivity"
-              android:label="Sample Updater">
-      <intent-filter>
-        <action android:name="android.intent.action.MAIN" />
-        <category android:name="android.intent.category.LAUNCHER" />
-      </intent-filter>
-    </activity>
-  </application>
+    <uses-sdk android:minSdkVersion="27" android:targetSdkVersion="27" />
+
+    <application
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round">
+        <activity
+            android:name=".ui.MainActivity"
+            android:label="@string/app_name" >
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
 
 </manifest>
-
diff --git a/sample_updater/README.md b/sample_updater/README.md
index a06c52d..467ef5d 100644
--- a/sample_updater/README.md
+++ b/sample_updater/README.md
@@ -1 +1,72 @@
-# System update sample app.
+# SystemUpdaterSample
+
+This app demonstrates how to use Android system updates APIs to install
+[OTA updates](https://source.android.com/devices/tech/ota/). It contains a sample
+client for `update_engine` to install A/B (seamless) updates and a sample of
+applying non-A/B updates using `recovery`.
+
+A/B (seamless) update is available since Android Nougat (API 24), but this sample
+targets the latest android.
+
+
+## Running on a device
+
+The commands expected to be run from `$ANDROID_BUILD_TOP`.
+
+1. Compile the app `$ mmma bootable/recovery/sample_updater`.
+2. Install the app to the device using `$ adb install <APK_PATH>`.
+3. Add update config files.
+
+
+## Update Config file
+
+Directory can be found in logs or on UI. Usually json config files are located in
+`/data/user/0/com.example.android.systemupdatersample/files/configs/`. Example file
+is located at `res/raw/sample.json`.
+
+
+## Development
+
+- [x] Create a UI with list of configs, current version,
+      control buttons, progress bar and log viewer
+- [x] Add `PayloadSpec` and `PayloadSpecs` for working with
+      update zip file
+- [x] Add `UpdateConfig` for working with json config files
+- [x] Add applying non-streaming update
+- [ ] Add applying streaming update
+- [ ] Prepare streaming update (partially downloading package)
+- [ ] Add tests for `MainActivity`
+- [ ] Add stop/reset the update
+- [ ] Verify system partition checksum for package
+- [ ] HAL compatibility check
+- [ ] Change partition demo
+- [ ] Add non-A/B updates demo
+
+
+## Running tests
+
+1. Build `$ mmma bootable/recovery/sample_updater/`
+2. Install app
+   `$ adb install $OUT/system/app/SystemUpdaterSample/SystemUpdaterSample.apk`
+3. Install tests
+   `$ adb install $OUT/testcases/SystemUpdaterSampleTests/SystemUpdaterSampleTests.apk`
+4. Run tests
+   `$ adb shell am instrument -w com.example.android.systemupdatersample.tests/android.support.test.runner.AndroidJUnitRunner`
+5. Run a test file
+   ```
+   $ adb shell am instrument \
+     -w com.example.android.systemupdatersample.tests/android.support.test.runner.AndroidJUnitRunner \
+     -c com.example.android.systemupdatersample.util.PayloadSpecsTest
+   ```
+
+
+## Getting access to `update_engine` API and read/write access to `/data`
+
+Run adb shell as a root, and set SELinux mode to permissive (0):
+
+```txt
+$ adb root
+$ adb shell
+# setenforce 0
+# getenforce
+```
diff --git a/sample_updater/res/layout/activity_main.xml b/sample_updater/res/layout/activity_main.xml
index bd7d686..3cd7721 100644
--- a/sample_updater/res/layout/activity_main.xml
+++ b/sample_updater/res/layout/activity_main.xml
@@ -1,20 +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
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2018 The Android Open Source Project
 
-      http://www.apache.org/licenses/LICENSE-2.0
+     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
 
-  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.
-  -->
+          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.
+-->
 
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
-    android:layout_height="match_parent">
+    android:orientation="vertical"
+    android:padding="4dip"
+    android:gravity="center_horizontal"
+    android:layout_width="fill_parent"
+    android:layout_height="fill_parent">
+
+    <ScrollView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_marginBottom="8dp"
+        android:layout_marginEnd="8dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        >
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="0dp"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/textViewBuildtitle"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Current Build:" />
+
+            <TextView
+                android:id="@+id/textViewBuild"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="@string/unknown" />
+
+            <Space
+                android:layout_width="match_parent"
+                android:layout_height="40dp" />
+
+            <TextView
+                android:id="@+id/textView4"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Apply an update" />
+
+            <TextView
+                android:id="@+id/textViewConfigsDirHint"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="4dp"
+                android:text="Config files located in NULL"
+                android:textColor="#777"
+                android:textSize="10sp"
+                android:textStyle="italic" />
+
+            <Spinner
+                android:id="@+id/spinnerConfigs"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="8dp" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="8dp"
+                android:orientation="horizontal">
+
+                <Button
+                    android:id="@+id/buttonReload"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:onClick="onReloadClick"
+                    android:text="Reload" />
+
+                <Button
+                    android:id="@+id/buttonViewConfig"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:onClick="onViewConfigClick"
+                    android:text="View config" />
+
+                <Button
+                    android:id="@+id/buttonApplyConfig"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:onClick="onApplyConfigClick"
+                    android:text="Apply" />
+            </LinearLayout>
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="24dp"
+                android:orientation="horizontal">
+
+                <TextView
+                    android:id="@+id/textView"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:text="Running update status:" />
+
+                <TextView
+                    android:id="@+id/textViewStatus"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginLeft="8dp"
+                    android:text="@string/unknown" />
+            </LinearLayout>
+
+            <ProgressBar
+                android:id="@+id/progressBar"
+                style="?android:attr/progressBarStyleHorizontal"
+                android:layout_marginTop="8dp"
+                android:min="0"
+                android:max="100"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="12dp"
+                android:orientation="horizontal">
+
+                <Button
+                    android:id="@+id/buttonStop"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:onClick="onStopClick"
+                    android:text="Stop" />
+
+                <Button
+                    android:id="@+id/buttonReset"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:onClick="onResetClick"
+                    android:text="Reset" />
+            </LinearLayout>
+
+        </LinearLayout>
+
+    </ScrollView>
 
 </LinearLayout>
diff --git a/sample_updater/res/mipmap-hdpi/ic_launcher.png b/sample_updater/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..a2f5908
--- /dev/null
+++ b/sample_updater/res/mipmap-hdpi/ic_launcher.png
Binary files differ
diff --git a/sample_updater/res/mipmap-hdpi/ic_launcher_round.png b/sample_updater/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..1b52399
--- /dev/null
+++ b/sample_updater/res/mipmap-hdpi/ic_launcher_round.png
Binary files differ
diff --git a/sample_updater/res/raw/sample.json b/sample_updater/res/raw/sample.json
new file mode 100644
index 0000000..03335cc
--- /dev/null
+++ b/sample_updater/res/raw/sample.json
@@ -0,0 +1,22 @@
+{
+    "__name": "name will be visible on UI",
+    "__url": "https:// or file:// uri to update file (zip, xz, ...)",
+    "__type": "NON_STREAMING (from local file) OR STREAMING (on the fly)",
+    "name": "SAMPLE-cake-release BUILD-12345",
+    "url": "file:///data/builds/android-update.zip",
+    "type": "NON_STREAMING",
+    "streaming_metadata": {
+        "__": "streaming_metadata is required only for streaming update",
+        "__property_files": "name, offset and size of files",
+        "property_files": [
+            {
+                "__filename": "payload.bin and payload_properties.txt are required",
+                "__offset": "defines beginning of update data in archive",
+                "__size": "size of the update data in archive",
+                "filename": "payload.bin",
+                "offset": 531,
+                "size": 5012323
+            }
+        ]
+    }
+}
diff --git a/sample_updater/res/values/strings.xml b/sample_updater/res/values/strings.xml
new file mode 100644
index 0000000..2b671ee
--- /dev/null
+++ b/sample_updater/res/values/strings.xml
@@ -0,0 +1,21 @@
+<!-- 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.
+-->
+
+<resources>
+    <string name="app_name">SystemUpdaterSample</string>
+    <string name="action_reload">Reload</string>
+    <string name="unknown">Unknown</string>
+    <string name="close">CLOSE</string>
+</resources>
diff --git a/sample_updater/src/com/android/update/ui/SystemUpdateActivity.java b/sample_updater/src/com/android/update/ui/SystemUpdateActivity.java
deleted file mode 100644
index e57b167..0000000
--- a/sample_updater/src/com/android/update/ui/SystemUpdateActivity.java
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * 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.update.ui;
-
-import android.app.Activity;
-import android.os.UpdateEngine;
-import android.os.UpdateEngineCallback;
-
-/** Main update activity. */
-public class SystemUpdateActivity extends Activity {
-
-  private UpdateEngine updateEngine;
-  private UpdateEngineCallbackImpl updateEngineCallbackImpl = new UpdateEngineCallbackImpl(this);
-
-  @Override
-  public void onResume() {
-    super.onResume();
-    updateEngine = new UpdateEngine();
-    updateEngine.bind(updateEngineCallbackImpl);
-  }
-
-  @Override
-  public void onPause() {
-    updateEngine.unbind();
-    super.onPause();
-  }
-
-  void onStatusUpdate(int i, float v) {
-    // Handle update engine status update
-  }
-
-  void onPayloadApplicationComplete(int i) {
-    // Handle apply payload completion
-  }
-
-  private static class UpdateEngineCallbackImpl extends UpdateEngineCallback {
-
-    private final SystemUpdateActivity activity;
-
-    public UpdateEngineCallbackImpl(SystemUpdateActivity activity) {
-      this.activity = activity;
-    }
-
-    @Override
-    public void onStatusUpdate(int i, float v) {
-      activity.onStatusUpdate(i, v);
-    }
-
-    @Override
-    public void onPayloadApplicationComplete(int i) {
-      activity.onPayloadApplicationComplete(i);
-    }
-  }
-}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/PayloadSpec.java b/sample_updater/src/com/example/android/systemupdatersample/PayloadSpec.java
new file mode 100644
index 0000000..90c5637
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/PayloadSpec.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.example.android.systemupdatersample;
+
+import android.os.UpdateEngine;
+
+import java.util.List;
+
+/**
+ * Payload that will be given to {@link UpdateEngine#applyPayload)}.
+ */
+public class PayloadSpec {
+
+    /**
+     * Creates a payload spec {@link Builder}
+     */
+    public static Builder newBuilder() {
+        return new Builder();
+    }
+
+    private String mUrl;
+    private long mOffset;
+    private long mSize;
+    private List<String> mProperties;
+
+    public PayloadSpec(Builder b) {
+        this.mUrl = b.mUrl;
+        this.mOffset = b.mOffset;
+        this.mSize = b.mSize;
+        this.mProperties = b.mProperties;
+    }
+
+    public String getUrl() {
+        return mUrl;
+    }
+
+    public long getOffset() {
+        return mOffset;
+    }
+
+    public long getSize() {
+        return mSize;
+    }
+
+    public List<String> getProperties() {
+        return mProperties;
+    }
+
+    /**
+     * payload spec builder.
+     *
+     * <p>Usage:</p>
+     *
+     * {@code
+     *   PayloadSpec spec = PayloadSpec.newBuilder()
+     *     .url("url")
+     *     .build();
+     * }
+     */
+    public static class Builder {
+        private String mUrl;
+        private long mOffset;
+        private long mSize;
+        private List<String> mProperties;
+
+        public Builder() {
+        }
+
+        /**
+         * set url
+         */
+        public Builder url(String url) {
+            this.mUrl = url;
+            return this;
+        }
+
+        /**
+         * set offset
+         */
+        public Builder offset(long offset) {
+            this.mOffset = offset;
+            return this;
+        }
+
+        /**
+         * set size
+         */
+        public Builder size(long size) {
+            this.mSize = size;
+            return this;
+        }
+
+        /**
+         * set properties
+         */
+        public Builder properties(List<String> properties) {
+            this.mProperties = properties;
+            return this;
+        }
+
+        /**
+         * build {@link PayloadSpec}
+         */
+        public PayloadSpec build() {
+            return new PayloadSpec(this);
+        }
+    }
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/UpdateConfig.java b/sample_updater/src/com/example/android/systemupdatersample/UpdateConfig.java
new file mode 100644
index 0000000..cbee18f
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/UpdateConfig.java
@@ -0,0 +1,183 @@
+/*
+ * 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.example.android.systemupdatersample;
+
+import android.os.Parcel;
+import android.os.Parcelable;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.Serializable;
+
+/**
+ * UpdateConfig describes an update. It will be parsed from JSON, which is intended to
+ * be sent from server to the update app, but in this sample app it will be stored on the device.
+ */
+public class UpdateConfig implements Parcelable {
+
+    public static final int TYPE_NON_STREAMING = 0;
+    public static final int TYPE_STREAMING     = 1;
+
+    public static final Parcelable.Creator<UpdateConfig> CREATOR =
+            new Parcelable.Creator<UpdateConfig>() {
+                @Override
+                public UpdateConfig createFromParcel(Parcel source) {
+                    return new UpdateConfig(source);
+                }
+
+                @Override
+                public UpdateConfig[] newArray(int size) {
+                    return new UpdateConfig[size];
+                }
+            };
+
+    /** parse update config from json */
+    public static UpdateConfig fromJson(String json) throws JSONException {
+        UpdateConfig c = new UpdateConfig();
+
+        JSONObject o = new JSONObject(json);
+        c.mName = o.getString("name");
+        c.mUrl = o.getString("url");
+        if (TYPE_NON_STREAMING_JSON.equals(o.getString("type"))) {
+            c.mInstallType = TYPE_NON_STREAMING;
+        } else if (TYPE_STREAMING_JSON.equals(o.getString("type"))) {
+            c.mInstallType = TYPE_STREAMING;
+        } else {
+            throw new JSONException("Invalid type, expected either "
+                    + "NON_STREAMING or STREAMING, got " + o.getString("type"));
+        }
+        if (o.has("metadata")) {
+            c.mMetadata = new Metadata(
+                    o.getJSONObject("metadata").getInt("offset"),
+                    o.getJSONObject("metadata").getInt("size"));
+        }
+        c.mRawJson = json;
+        return c;
+    }
+
+    /**
+     * these strings are represent types in JSON config files
+     */
+    private static final String TYPE_NON_STREAMING_JSON  = "NON_STREAMING";
+    private static final String TYPE_STREAMING_JSON      = "STREAMING";
+
+    /** name will be visible on UI */
+    private String mName;
+
+    /** update zip file URI, can be https:// or file:// */
+    private String mUrl;
+
+    /** non-streaming (first saves locally) OR streaming (on the fly) */
+    private int mInstallType;
+
+    /** metadata is required only for streaming update */
+    private Metadata mMetadata;
+
+    private String mRawJson;
+
+    protected UpdateConfig() {
+    }
+
+    protected UpdateConfig(Parcel in) {
+        this.mName = in.readString();
+        this.mUrl = in.readString();
+        this.mInstallType = in.readInt();
+        this.mMetadata = (Metadata) in.readSerializable();
+        this.mRawJson = in.readString();
+    }
+
+    public UpdateConfig(String name, String url, int installType) {
+        this.mName = name;
+        this.mUrl = url;
+        this.mInstallType = installType;
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    public String getUrl() {
+        return mUrl;
+    }
+
+    public String getRawJson() {
+        return mRawJson;
+    }
+
+    public int getInstallType() {
+        return mInstallType;
+    }
+
+    /**
+     * "url" must be the file located on the device.
+     *
+     * @return File object for given url
+     */
+    public File getUpdatePackageFile() {
+        if (mInstallType != TYPE_NON_STREAMING) {
+            throw new RuntimeException("Expected non-streaming install type");
+        }
+        if (!mUrl.startsWith("file://")) {
+            throw new RuntimeException("url is expected to start with file://");
+        }
+        return new File(mUrl.substring(7, mUrl.length()));
+    }
+
+    @Override
+    public int describeContents() {
+        return 0;
+    }
+
+    @Override
+    public void writeToParcel(Parcel dest, int flags) {
+        dest.writeString(mName);
+        dest.writeString(mUrl);
+        dest.writeInt(mInstallType);
+        dest.writeSerializable(mMetadata);
+        dest.writeString(mRawJson);
+    }
+
+    /**
+     * Metadata for STREAMING update
+     */
+    public static class Metadata implements Serializable {
+
+        private static final long serialVersionUID = 31042L;
+
+        /** defines beginning of update data in archive */
+        private long mOffset;
+
+        /** size of the update data in archive */
+        private long mSize;
+
+        public Metadata(long offset, long size) {
+            this.mOffset = offset;
+            this.mSize = size;
+        }
+
+        public long getOffset() {
+            return mOffset;
+        }
+
+        public long getSize() {
+            return mSize;
+        }
+    }
+
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/ui/MainActivity.java b/sample_updater/src/com/example/android/systemupdatersample/ui/MainActivity.java
new file mode 100644
index 0000000..72e1b24
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/ui/MainActivity.java
@@ -0,0 +1,314 @@
+/*
+ * 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.example.android.systemupdatersample.ui;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.UpdateEngine;
+import android.os.UpdateEngineCallback;
+import android.util.Log;
+import android.view.View;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.ProgressBar;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.example.android.systemupdatersample.R;
+import com.example.android.systemupdatersample.UpdateConfig;
+import com.example.android.systemupdatersample.updates.AbNonStreamingUpdate;
+import com.example.android.systemupdatersample.util.UpdateConfigs;
+import com.example.android.systemupdatersample.util.UpdateEngineErrorCodes;
+import com.example.android.systemupdatersample.util.UpdateEngineStatuses;
+
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * UI for SystemUpdaterSample app.
+ */
+public class MainActivity extends Activity {
+
+    private TextView mTextViewBuild;
+    private Spinner mSpinnerConfigs;
+    private TextView mTextViewConfigsDirHint;
+    private Button mButtonReload;
+    private Button mButtonApplyConfig;
+    private Button mButtonStop;
+    private Button mButtonReset;
+    private ProgressBar mProgressBar;
+    private TextView mTextViewStatus;
+
+    private List<UpdateConfig> mConfigs;
+    private AtomicInteger mUpdateEngineStatus =
+            new AtomicInteger(UpdateEngine.UpdateStatusConstants.IDLE);
+    private UpdateEngine mUpdateEngine = new UpdateEngine();
+
+    /**
+     * Listen to {@code update_engine} events.
+     */
+    private UpdateEngineCallbackImpl mUpdateEngineCallback = new UpdateEngineCallbackImpl();
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+
+        this.mTextViewBuild = findViewById(R.id.textViewBuild);
+        this.mSpinnerConfigs = findViewById(R.id.spinnerConfigs);
+        this.mTextViewConfigsDirHint = findViewById(R.id.textViewConfigsDirHint);
+        this.mButtonReload = findViewById(R.id.buttonReload);
+        this.mButtonApplyConfig = findViewById(R.id.buttonApplyConfig);
+        this.mButtonStop = findViewById(R.id.buttonStop);
+        this.mButtonReset = findViewById(R.id.buttonReset);
+        this.mProgressBar = findViewById(R.id.progressBar);
+        this.mTextViewStatus = findViewById(R.id.textViewStatus);
+
+        this.mUpdateEngine.bind(mUpdateEngineCallback);
+
+        this.mTextViewConfigsDirHint.setText(UpdateConfigs.getConfigsRoot(this));
+
+        uiReset();
+
+        loadUpdateConfigs();
+    }
+
+    @Override
+    protected void onDestroy() {
+        this.mUpdateEngine.unbind();
+        super.onDestroy();
+    }
+
+    /**
+     * reload button is clicked
+     */
+    public void onReloadClick(View view) {
+        loadUpdateConfigs();
+    }
+
+    /**
+     * view config button is clicked
+     */
+    public void onViewConfigClick(View view) {
+        UpdateConfig config = mConfigs.get(mSpinnerConfigs.getSelectedItemPosition());
+        new AlertDialog.Builder(this)
+                .setTitle(config.getName())
+                .setMessage(config.getRawJson())
+                .setPositiveButton(R.string.close, (dialog, id) -> dialog.dismiss())
+                .show();
+    }
+
+    /**
+     * apply config button is clicked
+     */
+    public void onApplyConfigClick(View view) {
+        new AlertDialog.Builder(this)
+                .setTitle("Apply Update")
+                .setMessage("Do you really want to apply this update?")
+                .setIcon(android.R.drawable.ic_dialog_alert)
+                .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+                    uiSetUpdating();
+                    applyUpdate(getSelectedConfig());
+                })
+                .setNegativeButton(android.R.string.cancel, null)
+                .show();
+    }
+
+    /**
+     * stop button clicked
+     */
+    public void onStopClick(View view) {
+        new AlertDialog.Builder(this)
+                .setTitle("Stop Update")
+                .setMessage("Do you really want to cancel running update?")
+                .setIcon(android.R.drawable.ic_dialog_alert)
+                .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+                    uiReset();
+                    stopRunningUpdate();
+                })
+                .setNegativeButton(android.R.string.cancel, null).show();
+    }
+
+    /**
+     * reset button clicked
+     */
+    public void onResetClick(View view) {
+        new AlertDialog.Builder(this)
+                .setTitle("Reset Update")
+                .setMessage("Do you really want to cancel running update"
+                        + " and restore old version?")
+                .setIcon(android.R.drawable.ic_dialog_alert)
+                .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> {
+                    uiReset();
+                    resetUpdate();
+                })
+                .setNegativeButton(android.R.string.cancel, null).show();
+    }
+
+    /**
+     * Invoked when anything changes. The value of {@code status} will
+     * be one of the values from {@link UpdateEngine.UpdateStatusConstants},
+     * and {@code percent} will be from {@code 0.0} to {@code 1.0}.
+     */
+    private void onStatusUpdate(int status, float percent) {
+        mProgressBar.setProgress((int) (100 * percent));
+        if (mUpdateEngineStatus.get() != status) {
+            mUpdateEngineStatus.set(status);
+            runOnUiThread(() -> {
+                Log.e("UpdateEngine", "StatusUpdate - status="
+                        + UpdateEngineStatuses.getStatusText(status)
+                        + "/" + status);
+                setUiStatus(status);
+                Toast.makeText(this, "Update Status changed", Toast.LENGTH_LONG)
+                        .show();
+            });
+        }
+    }
+
+    /**
+     * Invoked when the payload has been applied, whether successfully or
+     * unsuccessfully. The value of {@code errorCode} will be one of the
+     * values from {@link UpdateEngine.ErrorCodeConstants}.
+     */
+    private void onPayloadApplicationComplete(int errorCode) {
+        runOnUiThread(() -> {
+            final String state = UpdateEngineErrorCodes.isUpdateSucceeded(errorCode)
+                    ? "SUCCESS"
+                    : "FAILURE";
+            Log.i("UpdateEngine",
+                    "Completed - errorCode="
+                    + UpdateEngineErrorCodes.getCodeName(errorCode) + "/" + errorCode
+                    + " " + state);
+            Toast.makeText(this, "Update completed", Toast.LENGTH_LONG).show();
+        });
+    }
+
+    /** resets ui */
+    private void uiReset() {
+        mTextViewBuild.setText(Build.DISPLAY);
+        mSpinnerConfigs.setEnabled(true);
+        mButtonReload.setEnabled(true);
+        mButtonApplyConfig.setEnabled(true);
+        mButtonStop.setEnabled(false);
+        mButtonReset.setEnabled(false);
+        mProgressBar.setProgress(0);
+        mProgressBar.setEnabled(false);
+        mProgressBar.setVisibility(ProgressBar.INVISIBLE);
+        mTextViewStatus.setText(R.string.unknown);
+    }
+
+    /** sets ui updating mode */
+    private void uiSetUpdating() {
+        mTextViewBuild.setText(Build.DISPLAY);
+        mSpinnerConfigs.setEnabled(false);
+        mButtonReload.setEnabled(false);
+        mButtonApplyConfig.setEnabled(false);
+        mButtonStop.setEnabled(true);
+        mProgressBar.setEnabled(true);
+        mButtonReset.setEnabled(true);
+        mProgressBar.setVisibility(ProgressBar.VISIBLE);
+    }
+
+    /**
+     * loads json configurations from configs dir that is defined in {@link UpdateConfigs}.
+     */
+    private void loadUpdateConfigs() {
+        mConfigs = UpdateConfigs.getUpdateConfigs(this);
+        loadConfigsToSpinner(mConfigs);
+    }
+
+    /**
+     * @param status update engine status code
+     */
+    private void setUiStatus(int status) {
+        String statusText = UpdateEngineStatuses.getStatusText(status);
+        mTextViewStatus.setText(statusText);
+    }
+
+    private void loadConfigsToSpinner(List<UpdateConfig> configs) {
+        String[] spinnerArray = UpdateConfigs.configsToNames(configs);
+        ArrayAdapter<String> spinnerArrayAdapter = new ArrayAdapter<>(this,
+                android.R.layout.simple_spinner_item,
+                spinnerArray);
+        spinnerArrayAdapter.setDropDownViewResource(android.R.layout
+                .simple_spinner_dropdown_item);
+        mSpinnerConfigs.setAdapter(spinnerArrayAdapter);
+    }
+
+    private UpdateConfig getSelectedConfig() {
+        return mConfigs.get(mSpinnerConfigs.getSelectedItemPosition());
+    }
+
+    /**
+     * Applies the given update
+     */
+    private void applyUpdate(UpdateConfig config) {
+        if (config.getInstallType() == UpdateConfig.TYPE_NON_STREAMING) {
+            AbNonStreamingUpdate update = new AbNonStreamingUpdate(mUpdateEngine, config);
+            try {
+                update.execute();
+            } catch (Exception e) {
+                Log.e("MainActivity", "Error applying the update", e);
+                Toast.makeText(this, "Error applying the update", Toast.LENGTH_SHORT)
+                        .show();
+            }
+        } else {
+            Toast.makeText(this, "Streaming is not implemented", Toast.LENGTH_SHORT)
+                    .show();
+        }
+    }
+
+    /**
+     * Requests update engine to stop any ongoing update. If an update has been applied,
+     * leave it as is.
+     */
+    private void stopRunningUpdate() {
+        Toast.makeText(this,
+                "stopRunningUpdate is not implemented",
+                Toast.LENGTH_SHORT).show();
+
+    }
+
+    /**
+     * Resets update engine to IDLE state. Requests to cancel any onging update, or to revert if an
+     * update has been applied.
+     */
+    private void resetUpdate() {
+        Toast.makeText(this,
+                "resetUpdate is not implemented",
+                Toast.LENGTH_SHORT).show();
+    }
+
+    /**
+     * Helper class to delegate UpdateEngine callbacks to MainActivity
+     */
+    class UpdateEngineCallbackImpl extends UpdateEngineCallback {
+        @Override
+        public void onStatusUpdate(int status, float percent) {
+            MainActivity.this.onStatusUpdate(status, percent);
+        }
+
+        @Override
+        public void onPayloadApplicationComplete(int errorCode) {
+            MainActivity.this.onPayloadApplicationComplete(errorCode);
+        }
+    }
+
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/updates/AbNonStreamingUpdate.java b/sample_updater/src/com/example/android/systemupdatersample/updates/AbNonStreamingUpdate.java
new file mode 100644
index 0000000..1b91a1a
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/updates/AbNonStreamingUpdate.java
@@ -0,0 +1,52 @@
+/*
+ * 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.example.android.systemupdatersample.updates;
+
+import android.os.UpdateEngine;
+
+import com.example.android.systemupdatersample.PayloadSpec;
+import com.example.android.systemupdatersample.UpdateConfig;
+import com.example.android.systemupdatersample.util.PayloadSpecs;
+
+/**
+ * Applies A/B (seamless) non-streaming update.
+ */
+public class AbNonStreamingUpdate {
+
+    private final UpdateEngine mUpdateEngine;
+    private final UpdateConfig mUpdateConfig;
+
+    public AbNonStreamingUpdate(UpdateEngine updateEngine, UpdateConfig config) {
+        this.mUpdateEngine = updateEngine;
+        this.mUpdateConfig = config;
+    }
+
+    /**
+     * Start applying the update. This method doesn't wait until end of the update.
+     * {@code update_engine} works asynchronously.
+     */
+    public void execute() throws Exception {
+        PayloadSpec payload = PayloadSpecs.forNonStreaming(mUpdateConfig.getUpdatePackageFile());
+
+        mUpdateEngine.applyPayload(
+                payload.getUrl(),
+                payload.getOffset(),
+                payload.getSize(),
+                payload.getProperties().toArray(new String[0]));
+    }
+
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/util/PackagePropertyFiles.java b/sample_updater/src/com/example/android/systemupdatersample/util/PackagePropertyFiles.java
new file mode 100644
index 0000000..3988b59
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/util/PackagePropertyFiles.java
@@ -0,0 +1,42 @@
+/*
+ * 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.example.android.systemupdatersample.util;
+
+/** Utility class for property files in a package. */
+public final class PackagePropertyFiles {
+
+    public static final String PAYLOAD_BINARY_FILE_NAME = "payload.bin";
+
+    public static final String PAYLOAD_HEADER_FILE_NAME = "payload_header.bin";
+
+    public static final String PAYLOAD_METADATA_FILE_NAME = "payload_metadata.bin";
+
+    public static final String PAYLOAD_PROPERTIES_FILE_NAME = "payload_properties.txt";
+
+    /** The zip entry in an A/B OTA package, which will be used by update_verifier. */
+    public static final String CARE_MAP_FILE_NAME = "care_map.txt";
+
+    public static final String METADATA_FILE_NAME = "metadata";
+
+    /**
+     * The zip file that claims the compatibility of the update package to check against the Android
+     * framework to ensure that the package can be installed on the device.
+     */
+    public static final String COMPATIBILITY_ZIP_FILE_NAME = "compatibility.zip";
+
+    private PackagePropertyFiles() {}
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/util/PayloadSpecs.java b/sample_updater/src/com/example/android/systemupdatersample/util/PayloadSpecs.java
new file mode 100644
index 0000000..43c8d75
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/util/PayloadSpecs.java
@@ -0,0 +1,117 @@
+/*
+ * 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.example.android.systemupdatersample.util;
+
+import android.annotation.TargetApi;
+import android.os.Build;
+
+import com.example.android.systemupdatersample.PayloadSpec;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/** The helper class that creates {@link PayloadSpec}. */
+@TargetApi(Build.VERSION_CODES.N)
+public final class PayloadSpecs {
+
+    /**
+     * The payload PAYLOAD_ENTRY is stored in the zip package to comply with the Android OTA package
+     * format. We want to find out the offset of the entry, so that we can pass it over to the A/B
+     * updater without making an extra copy of the payload.
+     *
+     * <p>According to Android docs, the entries are listed in the order in which they appear in the
+     * zip file. So we enumerate the entries to identify the offset of the payload file.
+     * http://developer.android.com/reference/java/util/zip/ZipFile.html#entries()
+     */
+    public static PayloadSpec forNonStreaming(File packageFile) throws IOException {
+        boolean payloadFound = false;
+        long payloadOffset = 0;
+        long payloadSize = 0;
+
+        List<String> properties = new ArrayList<>();
+        try (ZipFile zip = new ZipFile(packageFile)) {
+            Enumeration<? extends ZipEntry> entries = zip.entries();
+            long offset = 0;
+            while (entries.hasMoreElements()) {
+                ZipEntry entry = entries.nextElement();
+                String name = entry.getName();
+                // Zip local file header has 30 bytes + filename + sizeof extra field.
+                // https://en.wikipedia.org/wiki/Zip_(file_format)
+                long extraSize = entry.getExtra() == null ? 0 : entry.getExtra().length;
+                offset += 30 + name.length() + extraSize;
+
+                if (entry.isDirectory()) {
+                    continue;
+                }
+
+                long length = entry.getCompressedSize();
+                if (PackagePropertyFiles.PAYLOAD_BINARY_FILE_NAME.equals(name)) {
+                    if (entry.getMethod() != ZipEntry.STORED) {
+                        throw new IOException("Invalid compression method.");
+                    }
+                    payloadFound = true;
+                    payloadOffset = offset;
+                    payloadSize = length;
+                } else if (PackagePropertyFiles.PAYLOAD_PROPERTIES_FILE_NAME.equals(name)) {
+                    InputStream inputStream = zip.getInputStream(entry);
+                    if (inputStream != null) {
+                        BufferedReader br = new BufferedReader(new InputStreamReader(inputStream));
+                        String line;
+                        while ((line = br.readLine()) != null) {
+                            properties.add(line);
+                        }
+                    }
+                }
+                offset += length;
+            }
+        }
+
+        if (!payloadFound) {
+            throw new IOException("Failed to find payload entry in the given package.");
+        }
+        return PayloadSpec.newBuilder()
+                        .url("file://" + packageFile.getAbsolutePath())
+                        .offset(payloadOffset)
+                        .size(payloadSize)
+                        .properties(properties)
+                        .build();
+    }
+
+    /**
+     * Converts an {@link PayloadSpec} to a string.
+     */
+    public static String toString(PayloadSpec payloadSpec) {
+        return "<PayloadSpec url=" + payloadSpec.getUrl()
+                + ", offset=" + payloadSpec.getOffset()
+                + ", size=" + payloadSpec.getSize()
+                + ", properties=" + Arrays.toString(
+                        payloadSpec.getProperties().toArray(new String[0]))
+                + ">";
+    }
+
+    private PayloadSpecs() {}
+
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/util/UpdateConfigs.java b/sample_updater/src/com/example/android/systemupdatersample/util/UpdateConfigs.java
new file mode 100644
index 0000000..089f8b2
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/util/UpdateConfigs.java
@@ -0,0 +1,82 @@
+/*
+ * 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.example.android.systemupdatersample.util;
+
+import android.content.Context;
+
+import com.example.android.systemupdatersample.UpdateConfig;
+
+import java.io.File;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Utility class for working with json update configurations.
+ */
+public final class UpdateConfigs {
+
+    private static final String UPDATE_CONFIGS_ROOT = "configs/";
+
+    /**
+     * @param configs update configs
+     * @return list of names
+     */
+    public static String[] configsToNames(List<UpdateConfig> configs) {
+        return configs.stream().map(UpdateConfig::getName).toArray(String[]::new);
+    }
+
+    /**
+     * @param context app context
+     * @return configs root directory
+     */
+    public static String getConfigsRoot(Context context) {
+        return Paths.get(context.getFilesDir().toString(),
+                UPDATE_CONFIGS_ROOT).toString();
+    }
+
+    /**
+     * It parses only {@code .json} files.
+     *
+     * @param context application context
+     * @return list of configs from directory {@link UpdateConfigs#getConfigsRoot}
+     */
+    public static List<UpdateConfig> getUpdateConfigs(Context context) {
+        File root = new File(getConfigsRoot(context));
+        ArrayList<UpdateConfig> configs = new ArrayList<>();
+        if (!root.exists()) {
+            return configs;
+        }
+        for (final File f : root.listFiles()) {
+            if (!f.isDirectory() && f.getName().endsWith(".json")) {
+                try {
+                    String json = new String(Files.readAllBytes(f.toPath()),
+                            StandardCharsets.UTF_8);
+                    configs.add(UpdateConfig.fromJson(json));
+                } catch (Exception e) {
+                    throw new RuntimeException(
+                            "Can't read/parse config file " + f.getName(), e);
+                }
+            }
+        }
+        return configs;
+    }
+
+    private UpdateConfigs() {}
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/util/UpdateEngineErrorCodes.java b/sample_updater/src/com/example/android/systemupdatersample/util/UpdateEngineErrorCodes.java
new file mode 100644
index 0000000..e63da62
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/util/UpdateEngineErrorCodes.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.example.android.systemupdatersample.util;
+
+import android.os.UpdateEngine;
+import android.util.SparseArray;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Helper class to work with update_engine's error codes.
+ * Many error codes are defined in  {@link UpdateEngine.ErrorCodeConstants},
+ * but you can find more in system/update_engine/common/error_code.h.
+ */
+public final class UpdateEngineErrorCodes {
+
+    /**
+    * Error code from the update engine. Values must agree with the ones in
+    * system/update_engine/common/error_code.h.
+    */
+    public static final int UPDATED_BUT_NOT_ACTIVE = 52;
+
+    private static final SparseArray<String> CODE_TO_NAME_MAP = new SparseArray<>();
+
+    static {
+        CODE_TO_NAME_MAP.put(0, "SUCCESS");
+        CODE_TO_NAME_MAP.put(1, "ERROR");
+        CODE_TO_NAME_MAP.put(4, "FILESYSTEM_COPIER_ERROR");
+        CODE_TO_NAME_MAP.put(5, "POST_INSTALL_RUNNER_ERROR");
+        CODE_TO_NAME_MAP.put(6, "PAYLOAD_MISMATCHED_TYPE_ERROR");
+        CODE_TO_NAME_MAP.put(7, "INSTALL_DEVICE_OPEN_ERROR");
+        CODE_TO_NAME_MAP.put(8, "KERNEL_DEVICE_OPEN_ERROR");
+        CODE_TO_NAME_MAP.put(9, "DOWNLOAD_TRANSFER_ERROR");
+        CODE_TO_NAME_MAP.put(10, "PAYLOAD_HASH_MISMATCH_ERROR");
+        CODE_TO_NAME_MAP.put(11, "PAYLOAD_SIZE_MISMATCH_ERROR");
+        CODE_TO_NAME_MAP.put(12, "DOWNLOAD_PAYLOAD_VERIFICATION_ERROR");
+        CODE_TO_NAME_MAP.put(20, "DOWNLOAD_STATE_INITIALIZATION_ERROR");
+        CODE_TO_NAME_MAP.put(48, "USER_CANCELLED");
+        CODE_TO_NAME_MAP.put(52, "UPDATED_BUT_NOT_ACTIVE");
+    }
+
+    /**
+     * Completion codes returned by update engine indicating that the update
+     * was successfully applied.
+     */
+    private static final Set<Integer> SUCCEEDED_COMPLETION_CODES = new HashSet<Integer>(
+            Arrays.asList(UpdateEngine.ErrorCodeConstants.SUCCESS,
+                    // UPDATED_BUT_NOT_ACTIVE is returned when the payload is
+                    // successfully applied but the
+                    // device won't switch to the new slot after the next boot.
+                    UPDATED_BUT_NOT_ACTIVE));
+
+    /**
+     * checks if update succeeded using errorCode
+     */
+    public static boolean isUpdateSucceeded(int errorCode) {
+        return SUCCEEDED_COMPLETION_CODES.contains(errorCode);
+    }
+
+    /**
+     * converts error code to error name
+     */
+    public static String getCodeName(int errorCode) {
+        return CODE_TO_NAME_MAP.get(errorCode);
+    }
+
+    private UpdateEngineErrorCodes() {}
+}
diff --git a/sample_updater/src/com/example/android/systemupdatersample/util/UpdateEngineStatuses.java b/sample_updater/src/com/example/android/systemupdatersample/util/UpdateEngineStatuses.java
new file mode 100644
index 0000000..6203b20
--- /dev/null
+++ b/sample_updater/src/com/example/android/systemupdatersample/util/UpdateEngineStatuses.java
@@ -0,0 +1,51 @@
+/*
+ * 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.example.android.systemupdatersample.util;
+
+import android.util.SparseArray;
+
+/**
+ * Helper class to work with update_engine's error codes.
+ * Many error codes are defined in  {@link UpdateEngine.UpdateStatusConstants},
+ * but you can find more in system/update_engine/common/error_code.h.
+ */
+public final class UpdateEngineStatuses {
+
+    private static final SparseArray<String> STATUS_MAP = new SparseArray<>();
+
+    static {
+        STATUS_MAP.put(0, "IDLE");
+        STATUS_MAP.put(1, "CHECKING_FOR_UPDATE");
+        STATUS_MAP.put(2, "UPDATE_AVAILABLE");
+        STATUS_MAP.put(3, "DOWNLOADING");
+        STATUS_MAP.put(4, "VERIFYING");
+        STATUS_MAP.put(5, "FINALIZING");
+        STATUS_MAP.put(6, "UPDATED_NEED_REBOOT");
+        STATUS_MAP.put(7, "REPORTING_ERROR_EVENT");
+        STATUS_MAP.put(8, "ATTEMPTING_ROLLBACK");
+        STATUS_MAP.put(9, "DISABLED");
+    }
+
+    /**
+     * converts status code to status name
+     */
+    public static String getStatusText(int status) {
+        return STATUS_MAP.get(status);
+    }
+
+    private UpdateEngineStatuses() {}
+}
diff --git a/sample_updater/tests/Android.mk b/sample_updater/tests/Android.mk
new file mode 100644
index 0000000..1ec68b9
--- /dev/null
+++ b/sample_updater/tests/Android.mk
@@ -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.
+#
+
+LOCAL_PATH := $(call my-dir)
+include $(CLEAR_VARS)
+
+LOCAL_PACKAGE_NAME := SystemUpdaterSampleTests
+LOCAL_SDK_VERSION := system_current
+LOCAL_MODULE_TAGS := tests
+LOCAL_JAVA_LIBRARIES := \
+    android.test.runner \
+    android.test.base
+LOCAL_STATIC_JAVA_LIBRARIES := android-support-test
+LOCAL_INSTRUMENTATION_FOR := SystemUpdaterSample
+LOCAL_PROGUARD_ENABLED := disabled
+
+LOCAL_SRC_FILES := $(call all-subdir-java-files)
+
+include $(BUILD_PACKAGE)
diff --git a/sample_updater/tests/AndroidManifest.xml b/sample_updater/tests/AndroidManifest.xml
new file mode 100644
index 0000000..145576c
--- /dev/null
+++ b/sample_updater/tests/AndroidManifest.xml
@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+      package="com.example.android.systemupdatersample.tests">
+
+    <!-- We add an application tag here just so that we can indicate that
+         this package needs to link against the android.test library,
+         which is needed when building test cases. -->
+    <application>
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation android:name="android.support.test.runner.AndroidJUnitRunner"
+                     android:targetPackage="com.example.android.systemupdatersample"
+                     android:label="Tests for SampleUpdater."/>
+
+</manifest>
diff --git a/sample_updater/tests/build.properties b/sample_updater/tests/build.properties
new file mode 100644
index 0000000..e0c39de
--- /dev/null
+++ b/sample_updater/tests/build.properties
@@ -0,0 +1 @@
+tested.project.dir=..
diff --git a/sample_updater/tests/src/com/example/android/systemupdatersample/UpdateConfigTest.java b/sample_updater/tests/src/com/example/android/systemupdatersample/UpdateConfigTest.java
new file mode 100644
index 0000000..8715371
--- /dev/null
+++ b/sample_updater/tests/src/com/example/android/systemupdatersample/UpdateConfigTest.java
@@ -0,0 +1,79 @@
+/*
+ * 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.example.android.systemupdatersample;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertSame;
+
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+
+/**
+ * Tests for {@link UpdateConfig}
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class UpdateConfigTest {
+
+    private static final String JSON_NON_STREAMING =
+            "{\"name\": \"vip update\", \"url\": \"file:///builds/a.zip\", "
+            + " \"type\": \"NON_STREAMING\"}";
+
+    private static final String JSON_STREAMING =
+            "{\"name\": \"vip update 2\", \"url\": \"http://foo.bar/a.zip\", "
+            + "\"type\": \"STREAMING\"}";
+
+    @Rule
+    public final ExpectedException thrown = ExpectedException.none();
+
+    @Test
+    public void fromJson_parsesJsonConfigWithoutMetadata() throws Exception {
+        UpdateConfig config = UpdateConfig.fromJson(JSON_NON_STREAMING);
+        assertEquals("name is parsed", "vip update", config.getName());
+        assertEquals("stores raw json", JSON_NON_STREAMING, config.getRawJson());
+        assertSame("type is parsed", UpdateConfig.TYPE_NON_STREAMING, config.getInstallType());
+        assertEquals("url is parsed", "file:///builds/a.zip", config.getUrl());
+    }
+
+    @Test
+    public void getUpdatePackageFile_throwsErrorIfStreaming() throws Exception {
+        UpdateConfig config = UpdateConfig.fromJson(JSON_STREAMING);
+        thrown.expect(RuntimeException.class);
+        config.getUpdatePackageFile();
+    }
+
+    @Test
+    public void getUpdatePackageFile_throwsErrorIfNotAFile() throws Exception {
+        String json = "{\"name\": \"upd\", \"url\": \"http://foo.bar\","
+                + " \"type\": \"NON_STREAMING\"}";
+        UpdateConfig config = UpdateConfig.fromJson(json);
+        thrown.expect(RuntimeException.class);
+        config.getUpdatePackageFile();
+    }
+
+    @Test
+    public void getUpdatePackageFile_works() throws Exception {
+        UpdateConfig c = UpdateConfig.fromJson(JSON_NON_STREAMING);
+        assertEquals("correct path", "/builds/a.zip", c.getUpdatePackageFile().getAbsolutePath());
+    }
+
+}
diff --git a/sample_updater/tests/src/com/example/android/systemupdatersample/ui/MainActivityTest.java b/sample_updater/tests/src/com/example/android/systemupdatersample/ui/MainActivityTest.java
new file mode 100644
index 0000000..0101416
--- /dev/null
+++ b/sample_updater/tests/src/com/example/android/systemupdatersample/ui/MainActivityTest.java
@@ -0,0 +1,48 @@
+/*
+ * 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.example.android.systemupdatersample.ui;
+
+import static org.junit.Assert.assertNotNull;
+
+import android.support.test.filters.MediumTest;
+import android.support.test.rule.ActivityTestRule;
+import android.support.test.runner.AndroidJUnit4;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/**
+ * Make sure that the main launcher activity opens up properly, which will be
+ * verified by {@link #activityLaunches}.
+ */
+@RunWith(AndroidJUnit4.class)
+@MediumTest
+public class MainActivityTest {
+
+    @Rule
+    public final ActivityTestRule<MainActivity> mActivityRule =
+            new ActivityTestRule<>(MainActivity.class);
+
+    /**
+     * Verifies that the activity under test can be launched.
+     */
+    @Test
+    public void activityLaunches() {
+        assertNotNull(mActivityRule.getActivity());
+    }
+}
diff --git a/sample_updater/tests/src/com/example/android/systemupdatersample/util/PayloadSpecsTest.java b/sample_updater/tests/src/com/example/android/systemupdatersample/util/PayloadSpecsTest.java
new file mode 100644
index 0000000..6f06ca3
--- /dev/null
+++ b/sample_updater/tests/src/com/example/android/systemupdatersample/util/PayloadSpecsTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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.example.android.systemupdatersample.util;
+
+import static com.example.android.systemupdatersample.util.PackagePropertyFiles.PAYLOAD_BINARY_FILE_NAME;
+import static com.example.android.systemupdatersample.util.PackagePropertyFiles.PAYLOAD_PROPERTIES_FILE_NAME;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+
+import android.content.Context;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.example.android.systemupdatersample.PayloadSpec;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.zip.CRC32;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+/**
+ * Tests if PayloadSpecs parses update package zip file correctly.
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class PayloadSpecsTest {
+
+    private static final String PROPERTIES_CONTENTS = "k1=val1\nkey2=val2";
+    private static final String PAYLOAD_CONTENTS    = "hello\nworld";
+    private static final int PAYLOAD_SIZE           = PAYLOAD_CONTENTS.length();
+
+    private File mTestDir;
+
+    private Context mContext;
+
+    @Rule
+    public final ExpectedException thrown = ExpectedException.none();
+
+    @Before
+    public void setUp() {
+        mContext = InstrumentationRegistry.getTargetContext();
+
+        mTestDir = mContext.getFilesDir();
+    }
+
+    @Test
+    public void forNonStreaming_works() throws Exception {
+        File packageFile = createMockZipFile();
+        PayloadSpec spec = PayloadSpecs.forNonStreaming(packageFile);
+
+        assertEquals("correct url", "file://" + packageFile.getAbsolutePath(), spec.getUrl());
+        assertEquals("correct payload offset",
+                30 + PAYLOAD_BINARY_FILE_NAME.length(), spec.getOffset());
+        assertEquals("correct payload size", PAYLOAD_SIZE, spec.getSize());
+        assertArrayEquals("correct properties",
+                new String[]{"k1=val1", "key2=val2"}, spec.getProperties().toArray(new String[0]));
+    }
+
+    @Test
+    public void forNonStreaming_IOException() throws Exception {
+        thrown.expect(IOException.class);
+        PayloadSpecs.forNonStreaming(new File("/fake/news.zip"));
+    }
+
+    /**
+     * Creates package zip file that contains payload.bin and payload_properties.txt
+     */
+    private File createMockZipFile() throws IOException {
+        File testFile = new File(mTestDir, "test.zip");
+        try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(testFile))) {
+            // Add payload.bin entry.
+            ZipEntry entry = new ZipEntry(PAYLOAD_BINARY_FILE_NAME);
+            entry.setMethod(ZipEntry.STORED);
+            entry.setCompressedSize(PAYLOAD_SIZE);
+            entry.setSize(PAYLOAD_SIZE);
+            CRC32 crc = new CRC32();
+            crc.update(PAYLOAD_CONTENTS.getBytes(StandardCharsets.UTF_8));
+            entry.setCrc(crc.getValue());
+            zos.putNextEntry(entry);
+            zos.write(PAYLOAD_CONTENTS.getBytes(StandardCharsets.UTF_8));
+            zos.closeEntry();
+
+            // Add payload properties entry.
+            ZipEntry propertiesEntry = new ZipEntry(PAYLOAD_PROPERTIES_FILE_NAME);
+            zos.putNextEntry(propertiesEntry);
+            zos.write(PROPERTIES_CONTENTS.getBytes(StandardCharsets.UTF_8));
+            zos.closeEntry();
+        }
+        return testFile;
+    }
+
+}
diff --git a/sample_updater/tests/src/com/example/android/systemupdatersample/util/UpdateConfigsTest.java b/sample_updater/tests/src/com/example/android/systemupdatersample/util/UpdateConfigsTest.java
new file mode 100644
index 0000000..4aa8c64
--- /dev/null
+++ b/sample_updater/tests/src/com/example/android/systemupdatersample/util/UpdateConfigsTest.java
@@ -0,0 +1,63 @@
+/*
+ * 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.example.android.systemupdatersample.util;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import android.content.Context;
+import android.support.test.InstrumentationRegistry;
+import android.support.test.filters.SmallTest;
+import android.support.test.runner.AndroidJUnit4;
+
+import com.example.android.systemupdatersample.UpdateConfig;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.junit.runner.RunWith;
+
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Tests for {@link UpdateConfigs}
+ */
+@RunWith(AndroidJUnit4.class)
+@SmallTest
+public class UpdateConfigsTest {
+
+    private Context mContext;
+
+    @Rule
+    public final ExpectedException thrown = ExpectedException.none();
+
+    @Before
+    public void setUp() {
+        mContext = InstrumentationRegistry.getTargetContext();
+    }
+
+    @Test
+    public void configsToNames_extractsNames() {
+        List<UpdateConfig> configs = Arrays.asList(
+                new UpdateConfig("blah", "http://", UpdateConfig.TYPE_NON_STREAMING),
+                new UpdateConfig("blah 2", "http://", UpdateConfig.TYPE_STREAMING)
+        );
+        String[] names = UpdateConfigs.configsToNames(configs);
+        assertArrayEquals(new String[] {"blah", "blah 2"}, names);
+    }
+}