diff options
Diffstat (limited to '')
22 files changed, 544 insertions, 181 deletions
diff --git a/updater_sample/Android.mk b/updater_sample/Android.mk index 2786de44f..056ad66be 100644 --- a/updater_sample/Android.mk +++ b/updater_sample/Android.mk @@ -26,6 +26,10 @@ LOCAL_PROGUARD_ENABLED := disabled LOCAL_SRC_FILES := $(call all-java-files-under, src) +LOCAL_STATIC_JAVA_LIBRARIES += guava + +LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res + include $(BUILD_PACKAGE) # Use the following include to make our test apk. diff --git a/updater_sample/README.md b/updater_sample/README.md index ee1faaf85..12f803ff6 100644 --- a/updater_sample/README.md +++ b/updater_sample/README.md @@ -30,13 +30,19 @@ to the app, but in this sample, the config files are stored on the device. The directory can be found in logs or on the UI. In most cases it should be located at `/data/user/0/com.example.android.systemupdatersample/files/configs/`. -SystemUpdaterSample app downloads OTA package from `url`. If `ab_install_type` -is `NON_STREAMING` then app downloads the whole package and -passes it to the `update_engine`. If `ab_install_type` is `STREAMING` -then app downloads only some files to prepare the streaming update and -`update_engine` will stream only `payload.bin`. -To support streaming A/B (seamless) update, OTA package file must be -an uncompressed (ZIP_STORED) zip file. +SystemUpdaterSample app downloads OTA package from `url`. In this sample app +`url` is expected to point to file system, e.g. `file:///data/sample-builds/ota-002.zip`. + +If `ab_install_type` is `NON_STREAMING` then app checks if `url` starts +with `file://` and passes `url` to the `update_engine`. + +If `ab_install_type` is `STREAMING`, app downloads only the entries in need, as +opposed to the entire package, to initiate a streaming update. The `payload.bin` +entry, which takes up the majority of the space in an OTA package, will be +streamed by `update_engine` directly. The ZIP entries in such a package need to be +saved uncompressed (`ZIP_STORED`), so that their data can be downloaded directly +with the offset and length. As `payload.bin` itself is already in compressed +format, the size penalty is marginal. Config files can be generated using `tools/gen_update_config.py`. Running `./tools/gen_update_config.py --help` shows usage of the script. @@ -44,11 +50,15 @@ Running `./tools/gen_update_config.py --help` shows usage of the script. ## Running on a device -The commands expected to be run from `$ANDROID_BUILD_TOP`. +The commands expected to be run from `$ANDROID_BUILD_TOP` and for demo +purpose only. 1. Compile the app `$ mmma bootable/recovery/updater_sample`. 2. Install the app to the device using `$ adb install <APK_PATH>`. -3. Add update config files. +3. Change permissions on `/data/ota_package/` to `0777` on the device. +4. Set SELinux mode to permissive. See instructions below. +5. Add update config files. +6. Push OTA packages to the device. ## Development @@ -86,13 +96,33 @@ The commands expected to be run from `$ANDROID_BUILD_TOP`. ``` -## Getting access to `update_engine` API and read/write access to `/data` +## Accessing `android.os.UpdateEngine` API + +`android.os.UpdateEngine`` APIs are marked as `@SystemApi`, meaning only system apps can access them. + -Run adb shell as a root, and set SELinux mode to permissive (0): +## Getting read/write access to `/data/ota_package/` + +Following must be included in `AndroidManifest.xml`: + +```xml + <uses-permission android:name="android.permission.ACCESS_CACHE_FILESYSTEM" /> +``` + +Note: access to cache filesystem is granted only to system apps. + + +## Setting SELinux mode to permissive (0) ```txt -$ adb root -$ adb shell -# setenforce 0 -# getenforce +local$ adb root +local$ adb shell +android# setenforce 0 +android# getenforce ``` + + +## License + +SystemUpdaterSample app is released under +[Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). diff --git a/updater_sample/res/layout/activity_main.xml b/updater_sample/res/layout/activity_main.xml index 3cd772107..7a12d3474 100644 --- a/updater_sample/res/layout/activity_main.xml +++ b/updater_sample/res/layout/activity_main.xml @@ -114,7 +114,7 @@ android:id="@+id/textView" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="Running update status:" /> + android:text="Update status:" /> <TextView android:id="@+id/textViewStatus" @@ -124,6 +124,28 @@ android:text="@string/unknown" /> </LinearLayout> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:orientation="horizontal"> + + <TextView + android:id="@+id/textView2" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Update completion:" /> + + <TextView + android:id="@+id/textViewCompletion" + 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" diff --git a/updater_sample/res/raw/sample.json b/updater_sample/res/raw/sample.json index 03335cc97..b6f4cdce6 100644 --- a/updater_sample/res/raw/sample.json +++ b/updater_sample/res/raw/sample.json @@ -1,18 +1,18 @@ { "__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)", + "__url": "https:// or file:// uri to update package (zip, xz, ...)", + "__type": "NON_STREAMING (from a 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": { + "url": "http://foo.bar/builds/ota-001.zip", + "ab_install_type": "NON_STREAMING", + "ab_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": "name of the file in package", + "__offset": "defines beginning of the file in package", + "__size": "size of the file in package", "filename": "payload.bin", "offset": 531, "size": 5012323 diff --git a/updater_sample/src/com/example/android/systemupdatersample/PayloadSpec.java b/updater_sample/src/com/example/android/systemupdatersample/PayloadSpec.java index 90c5637ea..ce8833883 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/PayloadSpec.java +++ b/updater_sample/src/com/example/android/systemupdatersample/PayloadSpec.java @@ -18,12 +18,15 @@ package com.example.android.systemupdatersample; import android.os.UpdateEngine; +import java.io.Serializable; import java.util.List; /** * Payload that will be given to {@link UpdateEngine#applyPayload)}. */ -public class PayloadSpec { +public class PayloadSpec implements Serializable { + + private static final long serialVersionUID = 41043L; /** * Creates a payload spec {@link Builder} diff --git a/updater_sample/src/com/example/android/systemupdatersample/UpdateConfig.java b/updater_sample/src/com/example/android/systemupdatersample/UpdateConfig.java index cbee18fcb..23510e426 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/UpdateConfig.java +++ b/updater_sample/src/com/example/android/systemupdatersample/UpdateConfig.java @@ -19,6 +19,7 @@ package com.example.android.systemupdatersample; import android.os.Parcel; import android.os.Parcelable; +import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -26,13 +27,13 @@ import java.io.File; import java.io.Serializable; /** - * UpdateConfig describes an update. It will be parsed from JSON, which is intended to + * An update description. 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 int AB_INSTALL_TYPE_NON_STREAMING = 0; + public static final int AB_INSTALL_TYPE_STREAMING = 1; public static final Parcelable.Creator<UpdateConfig> CREATOR = new Parcelable.Creator<UpdateConfig>() { @@ -54,18 +55,30 @@ public class UpdateConfig implements Parcelable { 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")); + switch (o.getString("ab_install_type")) { + case AB_INSTALL_TYPE_NON_STREAMING_JSON: + c.mAbInstallType = AB_INSTALL_TYPE_NON_STREAMING; + break; + case AB_INSTALL_TYPE_STREAMING_JSON: + c.mAbInstallType = AB_INSTALL_TYPE_STREAMING; + break; + default: + throw new JSONException("Invalid type, expected either " + + "NON_STREAMING or STREAMING, got " + o.getString("ab_install_type")); } - if (o.has("metadata")) { - c.mMetadata = new Metadata( - o.getJSONObject("metadata").getInt("offset"), - o.getJSONObject("metadata").getInt("size")); + if (c.mAbInstallType == AB_INSTALL_TYPE_STREAMING) { + JSONObject meta = o.getJSONObject("ab_streaming_metadata"); + JSONArray propertyFilesJson = meta.getJSONArray("property_files"); + InnerFile[] propertyFiles = + new InnerFile[propertyFilesJson.length()]; + for (int i = 0; i < propertyFilesJson.length(); i++) { + JSONObject p = propertyFilesJson.getJSONObject(i); + propertyFiles[i] = new InnerFile( + p.getString("filename"), + p.getLong("offset"), + p.getLong("size")); + } + c.mAbStreamingMetadata = new StreamingMetadata(propertyFiles); } c.mRawJson = json; return c; @@ -74,8 +87,8 @@ public class UpdateConfig implements Parcelable { /** * 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"; + private static final String AB_INSTALL_TYPE_NON_STREAMING_JSON = "NON_STREAMING"; + private static final String AB_INSTALL_TYPE_STREAMING_JSON = "STREAMING"; /** name will be visible on UI */ private String mName; @@ -84,10 +97,10 @@ public class UpdateConfig implements Parcelable { private String mUrl; /** non-streaming (first saves locally) OR streaming (on the fly) */ - private int mInstallType; + private int mAbInstallType; /** metadata is required only for streaming update */ - private Metadata mMetadata; + private StreamingMetadata mAbStreamingMetadata; private String mRawJson; @@ -97,15 +110,15 @@ public class UpdateConfig implements Parcelable { protected UpdateConfig(Parcel in) { this.mName = in.readString(); this.mUrl = in.readString(); - this.mInstallType = in.readInt(); - this.mMetadata = (Metadata) in.readSerializable(); + this.mAbInstallType = in.readInt(); + this.mAbStreamingMetadata = (StreamingMetadata) in.readSerializable(); this.mRawJson = in.readString(); } public UpdateConfig(String name, String url, int installType) { this.mName = name; this.mUrl = url; - this.mInstallType = installType; + this.mAbInstallType = installType; } public String getName() { @@ -121,16 +134,18 @@ public class UpdateConfig implements Parcelable { } public int getInstallType() { - return mInstallType; + return mAbInstallType; + } + + public StreamingMetadata getStreamingMetadata() { + return mAbStreamingMetadata; } /** - * "url" must be the file located on the device. - * * @return File object for given url */ public File getUpdatePackageFile() { - if (mInstallType != TYPE_NON_STREAMING) { + if (mAbInstallType != AB_INSTALL_TYPE_NON_STREAMING) { throw new RuntimeException("Expected non-streaming install type"); } if (!mUrl.startsWith("file://")) { @@ -148,29 +163,60 @@ public class UpdateConfig implements Parcelable { public void writeToParcel(Parcel dest, int flags) { dest.writeString(mName); dest.writeString(mUrl); - dest.writeInt(mInstallType); - dest.writeSerializable(mMetadata); + dest.writeInt(mAbInstallType); + dest.writeSerializable(mAbStreamingMetadata); dest.writeString(mRawJson); } /** - * Metadata for STREAMING update + * Metadata for streaming A/B update. */ - public static class Metadata implements Serializable { + public static class StreamingMetadata implements Serializable { private static final long serialVersionUID = 31042L; /** defines beginning of update data in archive */ + private InnerFile[] mPropertyFiles; + + public StreamingMetadata() { + mPropertyFiles = new InnerFile[0]; + } + + public StreamingMetadata(InnerFile[] propertyFiles) { + this.mPropertyFiles = propertyFiles; + } + + public InnerFile[] getPropertyFiles() { + return mPropertyFiles; + } + } + + /** + * Description of a file in an OTA package zip file. + */ + public static class InnerFile implements Serializable { + + private static final long serialVersionUID = 31043L; + + /** filename in an archive */ + private String mFilename; + + /** 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) { + public InnerFile(String filename, long offset, long size) { + this.mFilename = filename; this.mOffset = offset; this.mSize = size; } + public String getFilename() { + return mFilename; + } + public long getOffset() { return mOffset; } @@ -178,6 +224,7 @@ public class UpdateConfig implements Parcelable { public long getSize() { return mSize; } + } } diff --git a/updater_sample/src/com/example/android/systemupdatersample/ui/MainActivity.java b/updater_sample/src/com/example/android/systemupdatersample/ui/MainActivity.java index 72e1b2469..d6a6ce3f5 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/ui/MainActivity.java +++ b/updater_sample/src/com/example/android/systemupdatersample/ui/MainActivity.java @@ -31,13 +31,15 @@ import android.widget.Spinner; import android.widget.TextView; import android.widget.Toast; +import com.example.android.systemupdatersample.PayloadSpec; 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.PayloadSpecs; import com.example.android.systemupdatersample.util.UpdateConfigs; import com.example.android.systemupdatersample.util.UpdateEngineErrorCodes; import com.example.android.systemupdatersample.util.UpdateEngineStatuses; +import java.io.IOException; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -46,6 +48,8 @@ import java.util.concurrent.atomic.AtomicInteger; */ public class MainActivity extends Activity { + private static final String TAG = "MainActivity"; + private TextView mTextViewBuild; private Spinner mSpinnerConfigs; private TextView mTextViewConfigsDirHint; @@ -55,17 +59,19 @@ public class MainActivity extends Activity { private Button mButtonReset; private ProgressBar mProgressBar; private TextView mTextViewStatus; + private TextView mTextViewCompletion; 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(); + private final UpdateEngine mUpdateEngine = new UpdateEngine(); + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -80,14 +86,14 @@ public class MainActivity extends Activity { this.mButtonReset = findViewById(R.id.buttonReset); this.mProgressBar = findViewById(R.id.progressBar); this.mTextViewStatus = findViewById(R.id.textViewStatus); - - this.mUpdateEngine.bind(mUpdateEngineCallback); + this.mTextViewCompletion = findViewById(R.id.textViewCompletion); this.mTextViewConfigsDirHint.setText(UpdateConfigs.getConfigsRoot(this)); uiReset(); - loadUpdateConfigs(); + + this.mUpdateEngine.bind(mUpdateEngineCallback); } @Override @@ -140,7 +146,6 @@ public class MainActivity extends Activity { .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(); @@ -156,7 +161,6 @@ public class MainActivity extends Activity { + " 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(); @@ -178,6 +182,13 @@ public class MainActivity extends Activity { setUiStatus(status); Toast.makeText(this, "Update Status changed", Toast.LENGTH_LONG) .show(); + if (status != UpdateEngine.UpdateStatusConstants.IDLE) { + Log.d(TAG, "status changed, setting ui to updating mode"); + uiSetUpdating(); + } else { + Log.d(TAG, "status changed, resetting ui"); + uiReset(); + } }); } } @@ -188,15 +199,16 @@ public class MainActivity extends Activity { * values from {@link UpdateEngine.ErrorCodeConstants}. */ private void onPayloadApplicationComplete(int errorCode) { + final String state = UpdateEngineErrorCodes.isUpdateSucceeded(errorCode) + ? "SUCCESS" + : "FAILURE"; 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(); + setUiCompletion(errorCode); }); } @@ -212,6 +224,7 @@ public class MainActivity extends Activity { mProgressBar.setEnabled(false); mProgressBar.setVisibility(ProgressBar.INVISIBLE); mTextViewStatus.setText(R.string.unknown); + mTextViewCompletion.setText(R.string.unknown); } /** sets ui updating mode */ @@ -239,7 +252,18 @@ public class MainActivity extends Activity { */ private void setUiStatus(int status) { String statusText = UpdateEngineStatuses.getStatusText(status); - mTextViewStatus.setText(statusText); + mTextViewStatus.setText(statusText + "/" + status); + } + + /** + * @param errorCode update engine error code + */ + private void setUiCompletion(int errorCode) { + final String state = UpdateEngineErrorCodes.isUpdateSucceeded(errorCode) + ? "SUCCESS" + : "FAILURE"; + String errorText = UpdateEngineErrorCodes.getCodeName(errorCode); + mTextViewCompletion.setText(state + " " + errorText + "/" + errorCode); } private void loadConfigsToSpinner(List<UpdateConfig> configs) { @@ -259,19 +283,42 @@ public class MainActivity extends Activity { /** * Applies the given update */ - private void applyUpdate(UpdateConfig config) { - if (config.getInstallType() == UpdateConfig.TYPE_NON_STREAMING) { - AbNonStreamingUpdate update = new AbNonStreamingUpdate(mUpdateEngine, config); + private void applyUpdate(final UpdateConfig config) { + if (config.getInstallType() == UpdateConfig.AB_INSTALL_TYPE_NON_STREAMING) { + PayloadSpec payload; try { - update.execute(); - } catch (Exception e) { - Log.e("MainActivity", "Error applying the update", e); - Toast.makeText(this, "Error applying the update", Toast.LENGTH_SHORT) + payload = PayloadSpecs.forNonStreaming(config.getUpdatePackageFile()); + } catch (IOException e) { + Log.e(TAG, "Error creating payload spec", e); + Toast.makeText(this, "Error creating payload spec", Toast.LENGTH_LONG) .show(); + return; } + updateEngineApplyPayload(payload); } else { - Toast.makeText(this, "Streaming is not implemented", Toast.LENGTH_SHORT) - .show(); + Log.d(TAG, "Starting PrepareStreamingService"); + } + } + + /** + * Applies given payload. + * + * UpdateEngine works asynchronously. This method doesn't wait until + * end of the update. + */ + private void updateEngineApplyPayload(PayloadSpec payloadSpec) { + try { + mUpdateEngine.applyPayload( + payloadSpec.getUrl(), + payloadSpec.getOffset(), + payloadSpec.getSize(), + payloadSpec.getProperties().toArray(new String[0])); + } catch (Exception e) { + Log.e(TAG, "UpdateEngine failed to apply the update", e); + Toast.makeText( + this, + "UpdateEngine failed to apply the update", + Toast.LENGTH_LONG).show(); } } @@ -280,10 +327,11 @@ public class MainActivity extends Activity { * leave it as is. */ private void stopRunningUpdate() { - Toast.makeText(this, - "stopRunningUpdate is not implemented", - Toast.LENGTH_SHORT).show(); - + try { + mUpdateEngine.cancel(); + } catch (Exception e) { + Log.w(TAG, "UpdateEngine failed to stop the ongoing update", e); + } } /** @@ -291,13 +339,15 @@ public class MainActivity extends Activity { * update has been applied. */ private void resetUpdate() { - Toast.makeText(this, - "resetUpdate is not implemented", - Toast.LENGTH_SHORT).show(); + try { + mUpdateEngine.resetStatus(); + } catch (Exception e) { + Log.w(TAG, "UpdateEngine failed to reset the update", e); + } } /** - * Helper class to delegate UpdateEngine callbacks to MainActivity + * Helper class to delegate {@code update_engine} callbacks to MainActivity */ class UpdateEngineCallbackImpl extends UpdateEngineCallback { @Override diff --git a/updater_sample/src/com/example/android/systemupdatersample/updates/AbNonStreamingUpdate.java b/updater_sample/src/com/example/android/systemupdatersample/updates/AbNonStreamingUpdate.java deleted file mode 100644 index 1b91a1ac3..000000000 --- a/updater_sample/src/com/example/android/systemupdatersample/updates/AbNonStreamingUpdate.java +++ /dev/null @@ -1,52 +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.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/updater_sample/src/com/example/android/systemupdatersample/util/FileDownloader.java b/updater_sample/src/com/example/android/systemupdatersample/util/FileDownloader.java new file mode 100644 index 000000000..5c1d71117 --- /dev/null +++ b/updater_sample/src/com/example/android/systemupdatersample/util/FileDownloader.java @@ -0,0 +1,93 @@ +/* + * 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.Log; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URL; +import java.net.URLConnection; + +/** + * Downloads chunk of a file from given url using {@code offset} and {@code size}, + * and saves to a given location. + * + * In real-life application this helper class should download from HTTP Server, + * but in this sample app it will only download from a local file. + */ +public final class FileDownloader { + + private String mUrl; + private long mOffset; + private long mSize; + private File mOut; + + public FileDownloader(String url, long offset, long size, File out) { + this.mUrl = url; + this.mOffset = offset; + this.mSize = size; + this.mOut = out; + } + + /** + * Downloads the file with given offset and size. + */ + public void download() throws IOException { + Log.d("FileDownloader", "downloading " + mOut.getName() + + " from " + mUrl + + " to " + mOut.getAbsolutePath()); + + URL url = new URL(mUrl); + URLConnection connection = url.openConnection(); + connection.connect(); + + // download the file + try (InputStream input = connection.getInputStream()) { + try (OutputStream output = new FileOutputStream(mOut)) { + long skipped = input.skip(mOffset); + if (skipped != mOffset) { + throw new IOException("Can't download file " + + mUrl + + " with given offset " + + mOffset); + } + byte[] data = new byte[4096]; + long total = 0; + while (total < mSize) { + int needToRead = (int) Math.min(4096, mSize - total); + int count = input.read(data, 0, needToRead); + if (count <= 0) { + break; + } + output.write(data, 0, count); + total += count; + } + if (total != mSize) { + throw new IOException("Can't download file " + + mUrl + + " with given size " + + mSize); + } + } + } + } + +} diff --git a/updater_sample/src/com/example/android/systemupdatersample/util/PackagePropertyFiles.java b/updater_sample/src/com/example/android/systemupdatersample/util/PackageFiles.java index 3988b5928..b485234ea 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/util/PackagePropertyFiles.java +++ b/updater_sample/src/com/example/android/systemupdatersample/util/PackageFiles.java @@ -16,13 +16,30 @@ package com.example.android.systemupdatersample.util; -/** Utility class for property files in a package. */ -public final class PackagePropertyFiles { +/** Utility class for an OTA package. */ +public final class PackageFiles { - public static final String PAYLOAD_BINARY_FILE_NAME = "payload.bin"; + /** + * Directory used to perform updates. + */ + public static final String OTA_PACKAGE_DIR = "/data/ota_package"; - public static final String PAYLOAD_HEADER_FILE_NAME = "payload_header.bin"; + /** + * update payload, it will be passed to {@code UpdateEngine#applyPayload}. + */ + public static final String PAYLOAD_BINARY_FILE_NAME = "payload.bin"; + /** + * Currently, when calling {@code UpdateEngine#applyPayload} to perform actions + * that don't require network access (e.g. change slot), update_engine still + * talks to the server to download/verify file. + * {@code update_engine} might throw error when rebooting if {@code UpdateEngine#applyPayload} + * is not supplied right headers and tokens. + * This behavior might change in future android versions. + * + * To avoid extra network request in {@code update_engine}, this file has to be + * downloaded and put in {@code OTA_PACKAGE_DIR}. + */ public static final String PAYLOAD_METADATA_FILE_NAME = "payload_metadata.bin"; public static final String PAYLOAD_PROPERTIES_FILE_NAME = "payload_properties.txt"; @@ -38,5 +55,5 @@ public final class PackagePropertyFiles { */ public static final String COMPATIBILITY_ZIP_FILE_NAME = "compatibility.zip"; - private PackagePropertyFiles() {} + private PackageFiles() {} } diff --git a/updater_sample/src/com/example/android/systemupdatersample/util/PayloadSpecs.java b/updater_sample/src/com/example/android/systemupdatersample/util/PayloadSpecs.java index 43c8d75e2..4db448a31 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/util/PayloadSpecs.java +++ b/updater_sample/src/com/example/android/systemupdatersample/util/PayloadSpecs.java @@ -16,9 +16,6 @@ package com.example.android.systemupdatersample.util; -import android.annotation.TargetApi; -import android.os.Build; - import com.example.android.systemupdatersample.PayloadSpec; import java.io.BufferedReader; @@ -26,6 +23,7 @@ import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Files; import java.util.ArrayList; import java.util.Arrays; import java.util.Enumeration; @@ -34,7 +32,6 @@ 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 { /** @@ -68,14 +65,14 @@ public final class PayloadSpecs { } long length = entry.getCompressedSize(); - if (PackagePropertyFiles.PAYLOAD_BINARY_FILE_NAME.equals(name)) { + if (PackageFiles.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)) { + } else if (PackageFiles.PAYLOAD_PROPERTIES_FILE_NAME.equals(name)) { InputStream inputStream = zip.getInputStream(entry); if (inputStream != null) { BufferedReader br = new BufferedReader(new InputStreamReader(inputStream)); @@ -101,6 +98,21 @@ public final class PayloadSpecs { } /** + * Creates a {@link PayloadSpec} for streaming update. + */ + public static PayloadSpec forStreaming(String updateUrl, + long offset, + long size, + File propertiesFile) throws IOException { + return PayloadSpec.newBuilder() + .url(updateUrl) + .offset(offset) + .size(size) + .properties(Files.readAllLines(propertiesFile.toPath())) + .build(); + } + + /** * Converts an {@link PayloadSpec} to a string. */ public static String toString(PayloadSpec payloadSpec) { diff --git a/updater_sample/src/com/example/android/systemupdatersample/util/UpdateConfigs.java b/updater_sample/src/com/example/android/systemupdatersample/util/UpdateConfigs.java index 089f8b2f2..71d4df8ab 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/util/UpdateConfigs.java +++ b/updater_sample/src/com/example/android/systemupdatersample/util/UpdateConfigs.java @@ -17,6 +17,7 @@ package com.example.android.systemupdatersample.util; import android.content.Context; +import android.util.Log; import com.example.android.systemupdatersample.UpdateConfig; @@ -70,6 +71,7 @@ public final class UpdateConfigs { StandardCharsets.UTF_8); configs.add(UpdateConfig.fromJson(json)); } catch (Exception e) { + Log.e("UpdateConfigs", "Can't read/parse config file " + f.getName(), e); throw new RuntimeException( "Can't read/parse config file " + f.getName(), e); } diff --git a/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineErrorCodes.java b/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineErrorCodes.java index e63da6298..6d319c5af 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineErrorCodes.java +++ b/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineErrorCodes.java @@ -50,6 +50,7 @@ public final class UpdateEngineErrorCodes { 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(15, "NEW_ROOTFS_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"); diff --git a/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineStatuses.java b/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineStatuses.java index 6203b201a..a96f19d84 100644 --- a/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineStatuses.java +++ b/updater_sample/src/com/example/android/systemupdatersample/util/UpdateEngineStatuses.java @@ -20,7 +20,7 @@ import android.util.SparseArray; /** * Helper class to work with update_engine's error codes. - * Many error codes are defined in {@link UpdateEngine.UpdateStatusConstants}, + * Many error codes are defined in {@code UpdateEngine.UpdateStatusConstants}, * but you can find more in system/update_engine/common/error_code.h. */ public final class UpdateEngineStatuses { diff --git a/updater_sample/tests/Android.mk b/updater_sample/tests/Android.mk index 83082cda6..a1a4664dc 100644 --- a/updater_sample/tests/Android.mk +++ b/updater_sample/tests/Android.mk @@ -22,11 +22,15 @@ LOCAL_SDK_VERSION := system_current LOCAL_MODULE_TAGS := tests LOCAL_JAVA_LIBRARIES := \ android.test.base.stubs \ - android.test.runner.stubs + android.test.runner.stubs \ + guava \ + mockito-target-minus-junit4 LOCAL_STATIC_JAVA_LIBRARIES := android-support-test LOCAL_INSTRUMENTATION_FOR := SystemUpdaterSample LOCAL_PROGUARD_ENABLED := disabled -LOCAL_SRC_FILES := $(call all-subdir-java-files) +LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res + +LOCAL_SRC_FILES := $(call all-java-files-under, src) include $(BUILD_PACKAGE) diff --git a/updater_sample/tests/AndroidManifest.xml b/updater_sample/tests/AndroidManifest.xml index 2392bb3af..76af5f1a9 100644 --- a/updater_sample/tests/AndroidManifest.xml +++ b/updater_sample/tests/AndroidManifest.xml @@ -17,6 +17,8 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.android.systemupdatersample.tests"> + <uses-sdk android:minSdkVersion="27" android:targetSdkVersion="27" /> + <!-- 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. --> diff --git a/updater_sample/tests/res/raw/update_config_stream_001.json b/updater_sample/tests/res/raw/update_config_stream_001.json index 965f737d7..15127cf2c 100644 --- a/updater_sample/tests/res/raw/update_config_stream_001.json +++ b/updater_sample/tests/res/raw/update_config_stream_001.json @@ -1,13 +1,13 @@ { "name": "streaming-001", "url": "http://foo.bar/update.zip", - "type": "STREAMING", - "streaming_metadata": { + "ab_install_type": "STREAMING", + "ab_streaming_metadata": { "property_files": [ { "filename": "payload.bin", - "offset": 531, - "size": 5012323 + "offset": 195, + "size": 8 } ] } diff --git a/updater_sample/tests/src/com/example/android/systemupdatersample/UpdateConfigTest.java b/updater_sample/tests/src/com/example/android/systemupdatersample/UpdateConfigTest.java index 87153715e..0975e76be 100644 --- a/updater_sample/tests/src/com/example/android/systemupdatersample/UpdateConfigTest.java +++ b/updater_sample/tests/src/com/example/android/systemupdatersample/UpdateConfigTest.java @@ -19,14 +19,23 @@ package com.example.android.systemupdatersample; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; +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.tests.R; +import com.google.common.io.CharStreams; + +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.IOException; +import java.io.InputStreamReader; + /** * Tests for {@link UpdateConfig} */ @@ -36,27 +45,48 @@ 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\"}"; + + " \"ab_install_type\": \"NON_STREAMING\"}"; @Rule public final ExpectedException thrown = ExpectedException.none(); + private Context mContext; + private Context mTargetContext; + private String mJsonStreaming001; + + @Before + public void setUp() throws Exception { + mContext = InstrumentationRegistry.getContext(); + mTargetContext = InstrumentationRegistry.getTargetContext(); + mJsonStreaming001 = readResource(R.raw.update_config_stream_001); + } + @Test - public void fromJson_parsesJsonConfigWithoutMetadata() throws Exception { + public void fromJson_parsesNonStreaming() 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()); + assertSame("type is parsed", + UpdateConfig.AB_INSTALL_TYPE_NON_STREAMING, + config.getInstallType()); assertEquals("url is parsed", "file:///builds/a.zip", config.getUrl()); } @Test + public void fromJson_parsesStreaming() throws Exception { + UpdateConfig config = UpdateConfig.fromJson(mJsonStreaming001); + assertEquals("streaming-001", config.getName()); + assertEquals("http://foo.bar/update.zip", config.getUrl()); + assertSame(UpdateConfig.AB_INSTALL_TYPE_STREAMING, config.getInstallType()); + assertEquals("payload.bin", + config.getStreamingMetadata().getPropertyFiles()[0].getFilename()); + assertEquals(195, config.getStreamingMetadata().getPropertyFiles()[0].getOffset()); + assertEquals(8, config.getStreamingMetadata().getPropertyFiles()[0].getSize()); + } + + @Test public void getUpdatePackageFile_throwsErrorIfStreaming() throws Exception { - UpdateConfig config = UpdateConfig.fromJson(JSON_STREAMING); + UpdateConfig config = UpdateConfig.fromJson(mJsonStreaming001); thrown.expect(RuntimeException.class); config.getUpdatePackageFile(); } @@ -64,7 +94,7 @@ public class UpdateConfigTest { @Test public void getUpdatePackageFile_throwsErrorIfNotAFile() throws Exception { String json = "{\"name\": \"upd\", \"url\": \"http://foo.bar\"," - + " \"type\": \"NON_STREAMING\"}"; + + " \"ab_install_type\": \"NON_STREAMING\"}"; UpdateConfig config = UpdateConfig.fromJson(json); thrown.expect(RuntimeException.class); config.getUpdatePackageFile(); @@ -73,7 +103,11 @@ public class UpdateConfigTest { @Test public void getUpdatePackageFile_works() throws Exception { UpdateConfig c = UpdateConfig.fromJson(JSON_NON_STREAMING); - assertEquals("correct path", "/builds/a.zip", c.getUpdatePackageFile().getAbsolutePath()); + assertEquals("/builds/a.zip", c.getUpdatePackageFile().getAbsolutePath()); } + private String readResource(int id) throws IOException { + return CharStreams.toString(new InputStreamReader( + mContext.getResources().openRawResource(id))); + } } diff --git a/updater_sample/tests/src/com/example/android/systemupdatersample/util/FileDownloaderTest.java b/updater_sample/tests/src/com/example/android/systemupdatersample/util/FileDownloaderTest.java new file mode 100644 index 000000000..80506ee6d --- /dev/null +++ b/updater_sample/tests/src/com/example/android/systemupdatersample/util/FileDownloaderTest.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.example.android.systemupdatersample.util; + +import static junit.framework.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.tests.R; + +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.nio.file.Files; +import java.nio.file.Paths; + +/** + * Tests for {@link FileDownloader} + */ +@RunWith(AndroidJUnit4.class) +@SmallTest +public class FileDownloaderTest { + + @Rule + public final ExpectedException thrown = ExpectedException.none(); + + private Context mTestContext; + private Context mTargetContext; + + @Before + public void setUp() { + mTestContext = InstrumentationRegistry.getContext(); + mTargetContext = InstrumentationRegistry.getTargetContext(); + } + + @Test + public void download_downloadsChunkOfZip() throws Exception { + // Prepare the target file + File packageFile = Paths + .get(mTargetContext.getCacheDir().getAbsolutePath(), "ota.zip") + .toFile(); + Files.deleteIfExists(packageFile.toPath()); + Files.copy(mTestContext.getResources().openRawResource(R.raw.ota_002_package), + packageFile.toPath()); + String url = "file://" + packageFile.getAbsolutePath(); + // prepare where to download + File outFile = Paths + .get(mTargetContext.getCacheDir().getAbsolutePath(), "care_map.txt") + .toFile(); + Files.deleteIfExists(outFile.toPath()); + // download a chunk of ota.zip + FileDownloader downloader = new FileDownloader(url, 160, 8, outFile); + downloader.download(); + String downloadedContent = String.join("\n", Files.readAllLines(outFile.toPath())); + // archive contains text files with uppercase filenames + assertEquals("CARE_MAP", downloadedContent); + } + +} diff --git a/updater_sample/tests/src/com/example/android/systemupdatersample/util/PayloadSpecsTest.java b/updater_sample/tests/src/com/example/android/systemupdatersample/util/PayloadSpecsTest.java index 6f06ca3e1..2912e209e 100644 --- a/updater_sample/tests/src/com/example/android/systemupdatersample/util/PayloadSpecsTest.java +++ b/updater_sample/tests/src/com/example/android/systemupdatersample/util/PayloadSpecsTest.java @@ -16,8 +16,9 @@ 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 com.example.android.systemupdatersample.util.PackageFiles.PAYLOAD_BINARY_FILE_NAME; +import static com.example.android.systemupdatersample.util.PackageFiles + .PAYLOAD_PROPERTIES_FILE_NAME; import static org.junit.Assert.assertArrayEquals; import static org.junit.Assert.assertEquals; @@ -28,6 +29,8 @@ import android.support.test.filters.SmallTest; import android.support.test.runner.AndroidJUnit4; import com.example.android.systemupdatersample.PayloadSpec; +import com.google.common.base.Charsets; +import com.google.common.io.Files; import org.junit.Before; import org.junit.Rule; @@ -56,16 +59,16 @@ public class PayloadSpecsTest { private File mTestDir; - private Context mContext; + private Context mTargetContext; @Rule public final ExpectedException thrown = ExpectedException.none(); @Before public void setUp() { - mContext = InstrumentationRegistry.getTargetContext(); + mTargetContext = InstrumentationRegistry.getTargetContext(); - mTestDir = mContext.getFilesDir(); + mTestDir = mTargetContext.getFilesDir(); } @Test @@ -87,6 +90,21 @@ public class PayloadSpecsTest { PayloadSpecs.forNonStreaming(new File("/fake/news.zip")); } + @Test + public void forStreaming_works() throws Exception { + String url = "http://a.com/b.zip"; + long offset = 45; + long size = 200; + File propertiesFile = createMockPropertiesFile(); + + PayloadSpec spec = PayloadSpecs.forStreaming(url, offset, size, propertiesFile); + assertEquals("same url", url, spec.getUrl()); + assertEquals("same offset", offset, spec.getOffset()); + assertEquals("same size", size, spec.getSize()); + assertArrayEquals("correct properties", + new String[]{"k1=val1", "key2=val2"}, spec.getProperties().toArray(new String[0])); + } + /** * Creates package zip file that contains payload.bin and payload_properties.txt */ @@ -114,4 +132,10 @@ public class PayloadSpecsTest { return testFile; } + private File createMockPropertiesFile() throws IOException { + File propertiesFile = new File(mTestDir, PackageFiles.PAYLOAD_PROPERTIES_FILE_NAME); + Files.asCharSink(propertiesFile, Charsets.UTF_8).write(PROPERTIES_CONTENTS); + return propertiesFile; + } + } diff --git a/updater_sample/tests/src/com/example/android/systemupdatersample/util/UpdateConfigsTest.java b/updater_sample/tests/src/com/example/android/systemupdatersample/util/UpdateConfigsTest.java index 4aa8c6453..4ccae9380 100644 --- a/updater_sample/tests/src/com/example/android/systemupdatersample/util/UpdateConfigsTest.java +++ b/updater_sample/tests/src/com/example/android/systemupdatersample/util/UpdateConfigsTest.java @@ -18,14 +18,11 @@ 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; @@ -41,21 +38,14 @@ import java.util.List; @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) + new UpdateConfig("blah", "http://", UpdateConfig.AB_INSTALL_TYPE_NON_STREAMING), + new UpdateConfig("blah 2", "http://", UpdateConfig.AB_INSTALL_TYPE_STREAMING) ); String[] names = UpdateConfigs.configsToNames(configs); assertArrayEquals(new String[] {"blah", "blah 2"}, names); diff --git a/updater_sample/tools/gen_update_config.py b/updater_sample/tools/gen_update_config.py index cb9bd0119..057812479 100755 --- a/updater_sample/tools/gen_update_config.py +++ b/updater_sample/tools/gen_update_config.py @@ -17,7 +17,7 @@ """ Given a OTA package file, produces update config JSON file. -Example: tools/gen_update.config.py \\ +Example: tools/gen_update_config.py \\ --ab_install_type=STREAMING \\ ota-build-001.zip \\ my-config-001.json \\ |