diff --git a/AndroidManifest.xml b/AndroidManifest.xml index a405032fc26..f9156cc8771 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -97,6 +97,7 @@ + diff --git a/res/values/strings.xml b/res/values/strings.xml index 7118886e2d8..def5890603a 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -11325,7 +11325,23 @@ GPU + + Boot with 16K page size + + Boot device using 16K page size supported kernel + + Reboot with 16KB pages compatible kernel? + + WARNING: Some applications may not be compatible with this mode. Device will reboot after confirmation. + + Reboot with 4KB pages compatible kernel? + + Device will reboot after confirmation. + + Failed to update kernel to 16KB pages compatible kernel. + Applying change + DSU Loader Load a Dynamic System Update Image diff --git a/res/xml/development_settings.xml b/res/xml/development_settings.xml index 32acac66ddb..c11829c0354 100644 --- a/res/xml/development_settings.xml +++ b/res/xml/development_settings.xml @@ -107,6 +107,12 @@ android:summary="@string/oem_unlock_enable_summary" settings:useAdditionalSummary="true" /> + + installUpdate()); + Futures.addCallback( + future, + new FutureCallback<>() { + + @Override + public void onSuccess(@NonNull Object result) { + // This means UpdateEngineStable is working on applying update in + // background. + // Result of that operation will be provided by separate callback. + Log.i(TAG, "applyPayload call to UpdateEngineStable succeeded."); + } + + @Override + public void onFailure(Throwable t) { + hideProgressDialog(); + Log.e(TAG, "Failed to call applyPayload of UpdateEngineStable!"); + displayToast(mContext.getString(R.string.toast_16k_update_failed_text)); + } + }, + ContextCompat.getMainExecutor(mContext)); + } + + /** Called when user dismisses to reboot dialog */ + @Override + public void on16kPagesDialogDismissed() {} + + private void installUpdate() { + SystemUpdateManager manager = mContext.getSystemService(SystemUpdateManager.class); + Bundle data = manager.retrieveSystemUpdateInfo(); + int status = data.getInt(SystemUpdateManager.KEY_STATUS); + if (status != SystemUpdateManager.STATUS_UNKNOWN + && status != SystemUpdateManager.STATUS_IDLE) { + throw new RuntimeException("System has pending update!"); + } + + String updateFilePath = mEnable16k ? OTA_16K_PATH : OTA_4K_PATH; + try { + File updateFile = new File(updateFilePath); + applyUpdateFile(updateFile); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @VisibleForTesting + void applyUpdateFile(@NonNull File updateFile) throws IOException, FileNotFoundException { + boolean payloadFound = false; + boolean propertiesFound = false; + long payloadOffset = 0; + long payloadSize = 0; + + List properties = new ArrayList<>(); + try (ZipFile zip = new ZipFile(updateFile)) { + Enumeration entries = zip.entries(); + long offset = 0; + while (entries.hasMoreElements()) { + ZipEntry zipEntry = entries.nextElement(); + String fileName = zipEntry.getName(); + long extraSize = zipEntry.getExtra() == null ? 0 : zipEntry.getExtra().length; + offset += OFFSET_TO_FILE_NAME + fileName.length() + extraSize; + + if (zipEntry.isDirectory()) { + continue; + } + + long length = zipEntry.getCompressedSize(); + if (PAYLOAD_BINARY_FILE_NAME.equals(fileName)) { + if (zipEntry.getMethod() != ZipEntry.STORED) { + throw new IOException("Unknown compression method."); + } + payloadFound = true; + payloadOffset = offset; + payloadSize = length; + } else if (PAYLOAD_PROPERTIES_FILE_NAME.equals(fileName)) { + propertiesFound = true; + InputStream inputStream = zip.getInputStream(zipEntry); + 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 FileNotFoundException( + "Failed to find payload in zip: " + updateFile.getAbsolutePath()); + } + + if (!propertiesFound) { + throw new FileNotFoundException( + "Failed to find payload properties in zip: " + updateFile.getAbsolutePath()); + } + + if (payloadSize == 0) { + throw new IOException("Found empty payload in zip: " + updateFile.getAbsolutePath()); + } + + applyPayload(updateFile, payloadOffset, payloadSize, properties); + } + + private void hideProgressDialog() { + // Hide progress bar + if (mProgressDialog != null && mProgressDialog.isShowing()) { + mProgressDialog.hide(); + } + } + + @VisibleForTesting + void applyPayload( + @NonNull File updateFile, + long payloadOffset, + long payloadSize, + @NonNull List properties) + throws FileNotFoundException { + String[] header = properties.stream().toArray(String[]::new); + UpdateEngineStable updateEngineStable = new UpdateEngineStable(); + try { + ParcelFileDescriptor pfd = + ParcelFileDescriptor.open(updateFile, ParcelFileDescriptor.MODE_READ_ONLY); + updateEngineStable.bind( + new OtaUpdateCallback(updateEngineStable), + new Handler(mContext.getMainLooper())); + updateEngineStable.applyPayloadFd(pfd, payloadOffset, payloadSize, header); + } finally { + Log.e(TAG, "Failure while applying an update using update engine"); + } + } + + private void displayToast(String message) { + Toast.makeText(mContext, message, Toast.LENGTH_SHORT).show(); + } + + private class OtaUpdateCallback extends UpdateEngineStableCallback { + UpdateEngineStable mUpdateEngineStable; + + OtaUpdateCallback(@NonNull UpdateEngineStable engine) { + mUpdateEngineStable = engine; + } + + @Override + public void onStatusUpdate(int status, float percent) {} + + @Override + public void onPayloadApplicationComplete(int errorCode) { + Log.i(TAG, "Callback from update engine stable received. unbinding.."); + // unbind the callback from update engine + mUpdateEngineStable.unbind(); + + // Hide progress bar + hideProgressDialog(); + + if (errorCode == UpdateEngine.ErrorCodeConstants.SUCCESS) { + Log.i(TAG, "applyPayload successful"); + + // Save changed preference + Settings.Global.putInt( + mContext.getContentResolver(), + Settings.Global.ENABLE_16K_PAGES, + mEnable16k ? ENABLE_16K_PAGE_SIZE : ENABLE_4K_PAGE_SIZE); + + // Publish system update info + SystemUpdateManager manager = + (SystemUpdateManager) + mContext.getSystemService(Context.SYSTEM_UPDATE_SERVICE); + manager.updateSystemUpdateInfo(getUpdateInfo()); + + // Restart device to complete update + PowerManager pm = mContext.getSystemService(PowerManager.class); + pm.reboot(REBOOT_REASON); + } else { + Log.e(TAG, "applyPayload failed, error code: " + errorCode); + displayToast(mContext.getString(R.string.toast_16k_update_failed_text)); + } + } + } + + private AlertDialog makeProgressDialog() { + AlertDialog.Builder builder = new AlertDialog.Builder(mFragment.getActivity()); + builder.setTitle(R.string.progress_16k_ota_title); + + final ProgressBar progressBar = new ProgressBar(mFragment.getActivity()); + LinearLayout.LayoutParams params = + new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + progressBar.setLayoutParams(params); + builder.setView(progressBar); + builder.setCancelable(false); + return builder.create(); + } + + private PersistableBundle getUpdateInfo() { + PersistableBundle infoBundle = new PersistableBundle(); + infoBundle.putInt( + SystemUpdateManager.KEY_STATUS, SystemUpdateManager.STATUS_WAITING_REBOOT); + infoBundle.putBoolean(SystemUpdateManager.KEY_IS_SECURITY_UPDATE, false); + infoBundle.putString(SystemUpdateManager.KEY_TITLE, EXPERIMENTAL_UPDATE_TITLE); + return infoBundle; + } +} diff --git a/src/com/android/settings/development/Enable16kPagesWarningDialog.java b/src/com/android/settings/development/Enable16kPagesWarningDialog.java new file mode 100644 index 00000000000..5c27b3f53c0 --- /dev/null +++ b/src/com/android/settings/development/Enable16kPagesWarningDialog.java @@ -0,0 +1,105 @@ +/* + * Copyright (C) 2024 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.settings.development; + +import android.app.Dialog; +import android.app.settings.SettingsEnums; +import android.content.DialogInterface; +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; + +import com.android.settings.R; +import com.android.settings.core.instrumentation.InstrumentedDialogFragment; + +/** Dialog when user interacts 16K pages developer option */ +public class Enable16kPagesWarningDialog extends InstrumentedDialogFragment + implements DialogInterface.OnClickListener, DialogInterface.OnDismissListener { + + public static final String TAG = "Enable16KDialog"; + private static final String DIALOG_BUNDLE_KEY = "SHOW_16K_DIALOG"; + + private Enable16kbPagesDialogHost mHost; + + private void setHost(@NonNull Enable16kbPagesDialogHost host) { + mHost = host; + } + + /** This method is used to show warning dialog to apply 16K update and reboot */ + public static void show( + @NonNull Fragment hostFragment, + @NonNull Enable16kbPagesDialogHost dialogHost, + boolean enable16k) { + final FragmentManager manager = hostFragment.getActivity().getSupportFragmentManager(); + Fragment existingFragment = manager.findFragmentByTag(TAG); + if (existingFragment == null) { + existingFragment = new Enable16kPagesWarningDialog(); + } + + if (existingFragment instanceof Enable16kPagesWarningDialog) { + Bundle bundle = new Bundle(); + bundle.putBoolean(DIALOG_BUNDLE_KEY, enable16k); + existingFragment.setArguments(bundle); + existingFragment.setTargetFragment(hostFragment, 0 /* requestCode */); + ((Enable16kPagesWarningDialog) existingFragment).setHost(dialogHost); + ((Enable16kPagesWarningDialog) existingFragment).show(manager, TAG); + } + } + + @Override + public int getMetricsCategory() { + return SettingsEnums.DIALOG_ENABLE_16K_PAGES; + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + final Bundle bundle = getArguments(); + boolean is16kDialog = bundle.getBoolean(DIALOG_BUNDLE_KEY); + return new AlertDialog.Builder(getActivity()) + .setTitle( + is16kDialog + ? R.string.confirm_enable_16k_pages_title + : R.string.confirm_enable_4k_pages_title) + .setMessage( + is16kDialog + ? R.string.confirm_enable_16k_pages_text + : R.string.confirm_enable_4k_pages_text) + .setPositiveButton(android.R.string.ok, this /* onClickListener */) + .setNegativeButton(android.R.string.cancel, this /* onClickListener */) + .create(); + } + + @Override + public void onClick(@NonNull DialogInterface dialog, int buttonId) { + if (buttonId == DialogInterface.BUTTON_POSITIVE) { + mHost.on16kPagesDialogConfirmed(); + } else { + mHost.on16kPagesDialogDismissed(); + } + } + + @Override + public void onDismiss(@NonNull DialogInterface dialog) { + super.onDismiss(dialog); + mHost.on16kPagesDialogDismissed(); + } +} diff --git a/src/com/android/settings/development/Enable16kbPagesDialogHost.java b/src/com/android/settings/development/Enable16kbPagesDialogHost.java new file mode 100644 index 00000000000..fc54bd457fb --- /dev/null +++ b/src/com/android/settings/development/Enable16kbPagesDialogHost.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024 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.settings.development; + +/** Interface for Enable16kbPagesDialogHost callbacks. */ +public interface Enable16kbPagesDialogHost { + /** Callback when the user presses ok the warning dialog. */ + void on16kPagesDialogConfirmed(); + + /** Callback when the user cancels or dismisses the warning dialog. */ + void on16kPagesDialogDismissed(); +} diff --git a/tests/robotests/assets/empty_payload_ota.zip b/tests/robotests/assets/empty_payload_ota.zip new file mode 100644 index 00000000000..c28b5dfd543 Binary files /dev/null and b/tests/robotests/assets/empty_payload_ota.zip differ diff --git a/tests/robotests/assets/no_payload_ota.zip b/tests/robotests/assets/no_payload_ota.zip new file mode 100644 index 00000000000..00add65c77e Binary files /dev/null and b/tests/robotests/assets/no_payload_ota.zip differ diff --git a/tests/robotests/assets/no_properties_ota.zip b/tests/robotests/assets/no_properties_ota.zip new file mode 100644 index 00000000000..4ca60e02e3b Binary files /dev/null and b/tests/robotests/assets/no_properties_ota.zip differ diff --git a/tests/robotests/assets/valid_ota.zip b/tests/robotests/assets/valid_ota.zip new file mode 100644 index 00000000000..20fa2dadf5e Binary files /dev/null and b/tests/robotests/assets/valid_ota.zip differ diff --git a/tests/robotests/src/com/android/settings/development/Enable16kPagesPreferenceControllerTest.java b/tests/robotests/src/com/android/settings/development/Enable16kPagesPreferenceControllerTest.java new file mode 100644 index 00000000000..0c9906cdf72 --- /dev/null +++ b/tests/robotests/src/com/android/settings/development/Enable16kPagesPreferenceControllerTest.java @@ -0,0 +1,124 @@ +/* + * Copyright (C) 2024 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.settings.development; + +import static com.google.common.truth.Truth.assertThat; + +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.spy; + +import android.content.Context; +import android.os.FileUtils; +import android.os.SystemProperties; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +@RunWith(RobolectricTestRunner.class) +public class Enable16kPagesPreferenceControllerTest { + + @Mock private DevelopmentSettingsDashboardFragment mFragment; + private Context mContext; + private Enable16kPagesPreferenceController mController; + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + mContext = spy(ApplicationProvider.getApplicationContext()); + mController = spy(new Enable16kPagesPreferenceController(mContext, mFragment)); + doNothing().when(mController).applyPayload(any(), anyLong(), anyLong(), anyList()); + } + + @Test + public void onSystemPropertyDisabled_shouldDisablePreference() { + SystemProperties.set(Enable16kPagesPreferenceController.DEV_OPTION_PROPERTY, "false"); + assertThat(mController.isAvailable()).isEqualTo(false); + } + + @Test + public void onSystemPropertyEnabled_shouldEnablePreference() { + SystemProperties.set(Enable16kPagesPreferenceController.DEV_OPTION_PROPERTY, "true"); + assertThat(mController.isAvailable()).isEqualTo(true); + } + + // TODO(b/303280163) : add tests to based on page size and whether preference is turned on/off + + @Test + public void validateUpdateParsing_validFile() throws IOException { + // TODO(b/295573133) : Add tests to verify applyPayload arguments + String filename = "valid_ota.zip"; + File updateFile = copyFromAssetToDataDir(filename); + mController.applyUpdateFile(updateFile); + } + + @Test + public void validateUpdateParsing_emptyPayloadFile() { + String filename = "empty_payload_ota.zip"; + File updateFile = copyFromAssetToDataDir(filename); + assertThrows(IOException.class, () -> mController.applyUpdateFile(updateFile)); + } + + @Test + public void validateUpdateParsing_noPayloadFile() { + String filename = "no_payload_ota.zip"; + File updateFile = copyFromAssetToDataDir(filename); + assertThrows(FileNotFoundException.class, () -> mController.applyUpdateFile(updateFile)); + } + + @Test + public void validateUpdateParsing_noPropertiesFile() { + String filename = "no_properties_ota.zip"; + File updateFile = copyFromAssetToDataDir(filename); + assertThrows(FileNotFoundException.class, () -> mController.applyUpdateFile(updateFile)); + } + + private File copyFromAssetToDataDir(String filename) { + try { + InputStream in = mContext.getAssets().open(filename); + File destination = + File.createTempFile( + "test_update", ".zip", new File(mContext.getDataDir().getPath())); + FileUtils.setPermissions( + /* path= */ destination, + /* mode= */ FileUtils.S_IRWXU | FileUtils.S_IRGRP | FileUtils.S_IROTH, + /* uid= */ -1, + /* gid= */ -1); + OutputStream out = new FileOutputStream(destination); + FileUtils.copy(in, out); + return destination; + } catch (IOException e) { + throw new RuntimeException(e); + } + } +}