diff --git a/controller/app/src/main/java/org/iiab/controller/MainActivity.java b/controller/app/src/main/java/org/iiab/controller/MainActivity.java index 44f720b..1c9a455 100644 --- a/controller/app/src/main/java/org/iiab/controller/MainActivity.java +++ b/controller/app/src/main/java/org/iiab/controller/MainActivity.java @@ -27,6 +27,10 @@ import android.os.Environment; import android.util.Log; import org.iiab.controller.update.data.ApkVerifier; +import org.iiab.controller.update.presentation.UpdateUiState; +import org.iiab.controller.update.presentation.UpdateViewModel; +import org.iiab.controller.update.presentation.UpdateViewModelFactory; +import androidx.lifecycle.ViewModelProvider; import android.view.View; import android.widget.ImageButton; import android.widget.TextView; @@ -65,6 +69,8 @@ public class MainActivity extends AppCompatActivity implements View.OnClickListe private android.widget.ImageView headerIcon; private long updateDownloadId = -1; + private androidx.appcompat.app.AlertDialog updateProgressDialog; + private UpdateViewModel updateViewModel; private long lastUpdateCheckTime = 0; // Tabs UI @@ -1318,7 +1324,10 @@ private void startDownload(String downloadUrl) { android.app.DownloadManager manager = (android.app.DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); if (manager != null) { updateDownloadId = manager.enqueue(request); - android.widget.Toast.makeText(this, R.string.download_started_toast, android.widget.Toast.LENGTH_SHORT).show(); + // PR B: show the in-app modal progress dialog and start polling. + // The DownloadManager system notification is kept as well. + getUpdateViewModel().track(updateDownloadId); + showUpdateProgressDialog(); } } @@ -1332,8 +1341,16 @@ public void onReceive(Context context, Intent intent) { // F15: only install if the download actually SUCCEEDED. DownloadManager // reports completion even when the server returned an error/HTML page. if (isDownloadSuccessful(id)) { - installApk(); + // PR B: verify signature, then move the dialog to READY (user taps Install). + java.io.File apk = verifyDownloadedApk(); + if (apk != null) { + getUpdateViewModel().onReady(); + } else { + getUpdateViewModel().onError(getString(R.string.ota_error_verify_failed)); + Toast.makeText(context, R.string.ota_error_verify_failed, Toast.LENGTH_LONG).show(); + } } else { + getUpdateViewModel().onError(getString(R.string.ota_error_download_failed)); Log.e(TAG, "OTA: download did not complete successfully; not installing."); Toast.makeText(context, R.string.ota_error_download_failed, Toast.LENGTH_LONG).show(); } @@ -1359,7 +1376,8 @@ private boolean isDownloadSuccessful(long id) { return false; } - private void installApk() { + /** Verify the staged APK exists and is signed by this app's certificate. Returns the file, or null. */ + private java.io.File verifyDownloadedApk() { String apkName = getSharedPreferences(getString(R.string.pref_file_internal), Context.MODE_PRIVATE) .getString("ota_apk_name", "iiab_update.apk"); @@ -1370,7 +1388,7 @@ private void installApk() { if (!apkFile.exists()) { Log.e(TAG, "OTA: Downloaded APK file not found at " + apkFile.getAbsolutePath()); - return; + return null; } // F15: verify the APK is signed by the SAME certificate as this app before @@ -1379,6 +1397,16 @@ private void installApk() { if (!ApkVerifier.isSignedBySameCertAsApp(this, apkFile)) { Log.e(TAG, "OTA: APK failed signature verification; deleting and aborting install."); apkFile.delete(); + return null; + } + return apkFile; + } + + /** Launch the system installer for the (re-)verified APK. Invoked from the dialog's Install button. */ + private void launchInstaller() { + java.io.File apkFile = verifyDownloadedApk(); + if (apkFile == null) { + getUpdateViewModel().onError(getString(R.string.ota_error_verify_failed)); Toast.makeText(this, R.string.ota_error_verify_failed, Toast.LENGTH_LONG).show(); return; } @@ -1396,6 +1424,8 @@ private void installApk() { return; } + getUpdateViewModel().onInstalling(); + Intent intent = new Intent(Intent.ACTION_VIEW); android.net.Uri apkUri = androidx.core.content.FileProvider.getUriForFile( this, @@ -1422,6 +1452,97 @@ private void installApk() { } } + // ---- PR B: in-app OTA progress dialog --------------------------------- + + private UpdateViewModel getUpdateViewModel() { + if (updateViewModel == null) { + updateViewModel = new ViewModelProvider(this, new UpdateViewModelFactory(this)) + .get(UpdateViewModel.class); + updateViewModel.state().observe(this, this::renderUpdateState); + } + return updateViewModel; + } + + private void showUpdateProgressDialog() { + View view = getLayoutInflater().inflate(R.layout.dialog_ota_progress, null); + androidx.appcompat.app.AlertDialog d = new androidx.appcompat.app.AlertDialog.Builder(this) + .setTitle(R.string.ota_progress_title) + .setView(view) + .setCancelable(false) + .setPositiveButton(R.string.ota_btn_install, null) + .setNegativeButton(R.string.ota_btn_cancel, null) + .create(); + d.setOnShowListener(dlg -> { + android.widget.Button install = d.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE); + android.widget.Button cancel = d.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE); + if (install != null) install.setOnClickListener(v -> launchInstaller()); + if (cancel != null) cancel.setOnClickListener(v -> { + UpdateUiState st = getUpdateViewModel().state().getValue(); + boolean terminal = st != null && (st.status == UpdateUiState.Status.READY + || st.status == UpdateUiState.Status.ERROR + || st.status == UpdateUiState.Status.INSTALLING); + if (!terminal) { + getUpdateViewModel().cancel(); + } + d.dismiss(); + }); + renderUpdateState(getUpdateViewModel().state().getValue()); + }); + updateProgressDialog = d; + d.show(); + } + + private void renderUpdateState(UpdateUiState s) { + if (updateProgressDialog == null || s == null || !updateProgressDialog.isShowing()) { + return; + } + android.widget.ProgressBar bar = updateProgressDialog.findViewById(R.id.ota_progress); + TextView status = updateProgressDialog.findViewById(R.id.ota_status); + TextView percent = updateProgressDialog.findViewById(R.id.ota_percent); + android.widget.Button install = updateProgressDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE); + android.widget.Button cancel = updateProgressDialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_NEGATIVE); + + boolean determinate = s.status == UpdateUiState.Status.DOWNLOADING && !s.indeterminate && s.percent >= 0; + if (bar != null) { + bar.setIndeterminate(!determinate); + if (determinate) bar.setProgress(s.percent); + } + if (percent != null) { + percent.setVisibility(determinate ? View.VISIBLE : View.GONE); + if (determinate) percent.setText(getString(R.string.ota_progress_percent, s.percent)); + } + + switch (s.status) { + case DOWNLOADING: + if (status != null) status.setText(R.string.ota_status_downloading); + if (install != null) install.setEnabled(false); + if (cancel != null) cancel.setEnabled(true); + break; + case VERIFYING: + if (status != null) status.setText(R.string.ota_status_verifying); + if (install != null) install.setEnabled(false); + if (cancel != null) cancel.setEnabled(true); + break; + case READY: + if (status != null) status.setText(R.string.ota_status_ready); + if (install != null) install.setEnabled(true); + if (cancel != null) cancel.setEnabled(true); + break; + case INSTALLING: + if (status != null) status.setText(R.string.ota_status_installing); + if (install != null) install.setEnabled(false); + if (cancel != null) cancel.setEnabled(false); + break; + case ERROR: + if (status != null) status.setText(s.message != null ? s.message : getString(R.string.ota_status_error)); + if (install != null) install.setEnabled(false); + if (cancel != null) cancel.setEnabled(true); + break; + default: + break; + } + } + private void addNewTerminalSession() { com.termux.terminal.TerminalSessionClient client = new com.termux.terminal.TerminalSessionClient() { @Override diff --git a/controller/app/src/main/java/org/iiab/controller/update/data/DownloadManagerGateway.java b/controller/app/src/main/java/org/iiab/controller/update/data/DownloadManagerGateway.java new file mode 100644 index 0000000..e6db3f1 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/data/DownloadManagerGateway.java @@ -0,0 +1,57 @@ +/* + * ============================================================================ + * Name : DownloadManagerGateway.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : OtaDownloadGateway backed by Android DownloadManager. + * ============================================================================ + */ +package org.iiab.controller.update.data; + +import android.app.DownloadManager; +import android.content.Context; +import android.database.Cursor; + +import org.iiab.controller.update.domain.DownloadProgress; +import org.iiab.controller.update.domain.OtaDownloadGateway; + +/** Queries DownloadManager for live progress and removes the download on cancel. */ +public final class DownloadManagerGateway implements OtaDownloadGateway { + + private final DownloadManager dm; + + public DownloadManagerGateway(Context context) { + this.dm = (DownloadManager) context.getApplicationContext().getSystemService(Context.DOWNLOAD_SERVICE); + } + + @Override + public DownloadProgress query(long downloadId) { + if (dm == null || downloadId < 0) return DownloadProgress.none(); + try (Cursor c = dm.query(new DownloadManager.Query().setFilterById(downloadId))) { + if (c != null && c.moveToFirst()) { + int status = c.getInt(c.getColumnIndexOrThrow(DownloadManager.COLUMN_STATUS)); + long dl = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)); + long total = c.getLong(c.getColumnIndexOrThrow(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)); + return new DownloadProgress(map(status), dl, total); + } + } catch (Exception ignored) { + } + return DownloadProgress.none(); + } + + @Override + public void cancel(long downloadId) { + if (dm != null && downloadId >= 0) dm.remove(downloadId); + } + + private static DownloadProgress.Status map(int s) { + switch (s) { + case DownloadManager.STATUS_RUNNING: return DownloadProgress.Status.RUNNING; + case DownloadManager.STATUS_PENDING: return DownloadProgress.Status.PENDING; + case DownloadManager.STATUS_PAUSED: return DownloadProgress.Status.PAUSED; + case DownloadManager.STATUS_SUCCESSFUL: return DownloadProgress.Status.SUCCESSFUL; + case DownloadManager.STATUS_FAILED: return DownloadProgress.Status.FAILED; + default: return DownloadProgress.Status.NONE; + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/update/domain/DownloadProgress.java b/controller/app/src/main/java/org/iiab/controller/update/domain/DownloadProgress.java new file mode 100644 index 0000000..d249127 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/domain/DownloadProgress.java @@ -0,0 +1,51 @@ +/* + * ============================================================================ + * Name : DownloadProgress.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Pure value object for OTA download progress (PR B presentation core). + * ============================================================================ + */ +package org.iiab.controller.update.domain; + +/** + * Immutable snapshot of an in-flight OTA download. Pure domain (no Android): the + * Data layer maps a DownloadManager query into this; the ViewModel turns it into + * UI state. {@code totalBytes <= 0} means the size is unknown (indeterminate bar). + */ +public final class DownloadProgress { + + public enum Status { NONE, PENDING, RUNNING, PAUSED, SUCCESSFUL, FAILED } + + public final Status status; + public final long downloadedBytes; + public final long totalBytes; + + public DownloadProgress(Status status, long downloadedBytes, long totalBytes) { + this.status = status == null ? Status.NONE : status; + this.downloadedBytes = Math.max(0, downloadedBytes); + this.totalBytes = totalBytes; + } + + public static DownloadProgress none() { + return new DownloadProgress(Status.NONE, 0, -1); + } + + /** True when the total size is unknown -> the UI should show an indeterminate bar. */ + public boolean isIndeterminate() { + return totalBytes <= 0; + } + + /** 0..100, or -1 when indeterminate. */ + public int percent() { + if (totalBytes <= 0) return -1; + long p = downloadedBytes * 100L / totalBytes; + if (p < 0) return 0; + if (p > 100) return 100; + return (int) p; + } + + public boolean isTerminal() { + return status == Status.SUCCESSFUL || status == Status.FAILED; + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/update/domain/OtaDownloadGateway.java b/controller/app/src/main/java/org/iiab/controller/update/domain/OtaDownloadGateway.java new file mode 100644 index 0000000..8e5bd5b --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/domain/OtaDownloadGateway.java @@ -0,0 +1,15 @@ +/* + * ============================================================================ + * Name : OtaDownloadGateway.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Domain port: query progress of / cancel an OTA download. + * ============================================================================ + */ +package org.iiab.controller.update.domain; + +/** Port over the platform download mechanism: query progress and cancel by id. */ +public interface OtaDownloadGateway { + DownloadProgress query(long downloadId); + void cancel(long downloadId); +} diff --git a/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateUiState.java b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateUiState.java new file mode 100644 index 0000000..1b5ed3b --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateUiState.java @@ -0,0 +1,55 @@ +/* + * ============================================================================ + * Name : UpdateUiState.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Immutable UI state for the in-app OTA update dialog. + * ============================================================================ + */ +package org.iiab.controller.update.presentation; + +import org.iiab.controller.update.domain.DownloadProgress; + +/** UI state for the update dialog: download -> verify -> ready/install, or error. */ +public final class UpdateUiState { + + public enum Status { IDLE, DOWNLOADING, VERIFYING, READY, INSTALLING, ERROR } + + public final Status status; + public final int percent; // 0..100, or -1 when indeterminate + public final boolean indeterminate; + public final String message; + + private UpdateUiState(Status status, int percent, boolean indeterminate, String message) { + this.status = status; + this.percent = percent; + this.indeterminate = indeterminate; + this.message = message; + } + + public static UpdateUiState idle() { return new UpdateUiState(Status.IDLE, 0, false, null); } + public static UpdateUiState downloading(int percent, boolean indeterminate) { + return new UpdateUiState(Status.DOWNLOADING, percent, indeterminate, null); + } + public static UpdateUiState verifying() { return new UpdateUiState(Status.VERIFYING, 100, true, null); } + public static UpdateUiState ready() { return new UpdateUiState(Status.READY, 100, false, null); } + public static UpdateUiState installing() { return new UpdateUiState(Status.INSTALLING, 100, true, null); } + public static UpdateUiState error(String message) { return new UpdateUiState(Status.ERROR, 0, false, message); } + + /** Pure mapping from a download snapshot to UI state. */ + public static UpdateUiState fromDownload(DownloadProgress p) { + if (p == null) return idle(); + switch (p.status) { + case RUNNING: + case PENDING: + case PAUSED: + return downloading(p.percent(), p.isIndeterminate()); + case SUCCESSFUL: + return verifying(); + case FAILED: + return error(null); + default: + return idle(); + } + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateViewModel.java b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateViewModel.java new file mode 100644 index 0000000..8de5df3 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateViewModel.java @@ -0,0 +1,83 @@ +/* + * ============================================================================ + * Name : UpdateViewModel.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Polls the OTA download and exposes UpdateUiState; supports cancel. + * ============================================================================ + */ +package org.iiab.controller.update.presentation; + +import android.os.Handler; +import android.os.Looper; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import org.iiab.controller.update.domain.DownloadProgress; +import org.iiab.controller.update.domain.OtaDownloadGateway; + +/** + * Drives the in-app OTA update dialog. While a download is tracked it polls the + * {@link OtaDownloadGateway} (~400ms) and posts {@link UpdateUiState}. When the + * download finishes (SUCCESSFUL) it moves to VERIFYING; MainActivity then verifies + * the APK signature and calls {@link #onReady()} / {@link #onError(String)}. + */ +public class UpdateViewModel extends ViewModel { + + private static final long POLL_MS = 400; + + private final OtaDownloadGateway gateway; + private final Handler handler = new Handler(Looper.getMainLooper()); + private final MutableLiveData state = new MutableLiveData<>(UpdateUiState.idle()); + + private long downloadId = -1; + private Runnable poller; + + public UpdateViewModel(OtaDownloadGateway gateway) { + this.gateway = gateway; + } + + public LiveData state() { + return state; + } + + /** Start tracking a DownloadManager download id; polls until it is terminal. */ + public void track(long id) { + downloadId = id; + stopPolling(); + poller = new Runnable() { + @Override public void run() { + DownloadProgress p = gateway.query(downloadId); + state.setValue(UpdateUiState.fromDownload(p)); + if (!p.isTerminal()) { + handler.postDelayed(this, POLL_MS); + } + } + }; + handler.post(poller); + } + + public void onReady() { state.setValue(UpdateUiState.ready()); } + public void onInstalling() { state.setValue(UpdateUiState.installing()); } + public void onError(String message) { stopPolling(); state.setValue(UpdateUiState.error(message)); } + + /** User cancelled: remove the download and reset. */ + public void cancel() { + stopPolling(); + if (downloadId >= 0) gateway.cancel(downloadId); + downloadId = -1; + state.setValue(UpdateUiState.idle()); + } + + private void stopPolling() { + if (poller != null) handler.removeCallbacks(poller); + poller = null; + } + + @Override + protected void onCleared() { + stopPolling(); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateViewModelFactory.java b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateViewModelFactory.java new file mode 100644 index 0000000..3fbe65f --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/update/presentation/UpdateViewModelFactory.java @@ -0,0 +1,39 @@ +/* + * ============================================================================ + * Name : UpdateViewModelFactory.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : Manual DI for UpdateViewModel. + * ============================================================================ + */ +package org.iiab.controller.update.presentation; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModel; +import androidx.lifecycle.ViewModelProvider; + +import org.iiab.controller.update.data.DownloadManagerGateway; +import org.iiab.controller.update.domain.OtaDownloadGateway; + +/** Hand-wired factory (same approach as the other slices; no DI framework). */ +public class UpdateViewModelFactory implements ViewModelProvider.Factory { + + private final Context appContext; + + public UpdateViewModelFactory(Context context) { + this.appContext = context.getApplicationContext(); + } + + @NonNull + @Override + @SuppressWarnings("unchecked") + public T create(@NonNull Class modelClass) { + if (modelClass.isAssignableFrom(UpdateViewModel.class)) { + OtaDownloadGateway gateway = new DownloadManagerGateway(appContext); + return (T) new UpdateViewModel(gateway); + } + throw new IllegalArgumentException("Unknown ViewModel class: " + modelClass.getName()); + } +} diff --git a/controller/app/src/main/res/layout/dialog_ota_progress.xml b/controller/app/src/main/res/layout/dialog_ota_progress.xml new file mode 100644 index 0000000..c9464ab --- /dev/null +++ b/controller/app/src/main/res/layout/dialog_ota_progress.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + diff --git a/controller/app/src/main/res/values-es/strings.xml b/controller/app/src/main/res/values-es/strings.xml index be407f5..e935045 100644 --- a/controller/app/src/main/res/values-es/strings.xml +++ b/controller/app/src/main/res/values-es/strings.xml @@ -503,4 +503,14 @@ No se pudo verificar la actualización y no se instaló. Inténtalo de nuevo. La descarga de la actualización falló. Inténtalo de nuevo. Permite instalar actualizaciones desde esta app y vuelve a tocar actualizar. + + Actualizando + Descargando actualización… + Verificando firma… + ✓ Firma verificada — lista para instalar + Iniciando el instalador… + La actualización falló + %1$d%% + Instalar + Cancelar \ No newline at end of file diff --git a/controller/app/src/main/res/values-fr/strings.xml b/controller/app/src/main/res/values-fr/strings.xml index 7950569..66391fb 100644 --- a/controller/app/src/main/res/values-fr/strings.xml +++ b/controller/app/src/main/res/values-fr/strings.xml @@ -501,4 +501,14 @@ Pas de connexion Internet — connectez-vous pour télécharger. Tailles estimées (hors ligne) Identifiants de synchronisation invalides ou non sécurisés. Veuillez scanner un QR code de synchronisation IIAB valide. + + Mise à jour + Téléchargement de la mise à jour… + Vérification de la signature… + ✓ Signature vérifiée — prêt à installer + Lancement de l\'installateur… + Échec de la mise à jour + %1$d%% + Installer + Annuler \ No newline at end of file diff --git a/controller/app/src/main/res/values-hi/strings.xml b/controller/app/src/main/res/values-hi/strings.xml index eb0f935..3b57e12 100644 --- a/controller/app/src/main/res/values-hi/strings.xml +++ b/controller/app/src/main/res/values-hi/strings.xml @@ -501,4 +501,14 @@ इंटरनेट कनेक्शन नहीं — डाउनलोड करने के लिए कनेक्ट करें। अनुमानित आकार (ऑफ़लाइन) अमान्य या असुरक्षित सिंक क्रेडेंशियल। कृपया एक मान्य IIAB सिंक QR कोड स्कैन करें। + + अपडेट हो रहा है + अपडेट डाउनलोड हो रहा है… + हस्ताक्षर सत्यापित हो रहा है… + ✓ हस्ताक्षर सत्यापित — इंस्टॉल के लिए तैयार + इंस्टॉलर शुरू हो रहा है… + अपडेट विफल + %1$d%% + इंस्टॉल करें + रद्द करें \ No newline at end of file diff --git a/controller/app/src/main/res/values-pt/strings.xml b/controller/app/src/main/res/values-pt/strings.xml index 015cf9f..eed5d57 100644 --- a/controller/app/src/main/res/values-pt/strings.xml +++ b/controller/app/src/main/res/values-pt/strings.xml @@ -503,4 +503,14 @@ Sem conexão à internet — conecte-se para baixar. Tamanhos estimados (offline) Credenciais de sincronização inválidas ou inseguras. Por favor, escaneie um QR code de sincronização IIAB válido. + + Atualizando + Baixando atualização… + Verificando assinatura… + ✓ Assinatura verificada — pronto para instalar + Iniciando o instalador… + Falha na atualização + %1$d%% + Instalar + Cancelar \ No newline at end of file diff --git a/controller/app/src/main/res/values-ru-rRU/strings.xml b/controller/app/src/main/res/values-ru-rRU/strings.xml index a2d39ee..743ec57 100644 --- a/controller/app/src/main/res/values-ru-rRU/strings.xml +++ b/controller/app/src/main/res/values-ru-rRU/strings.xml @@ -500,4 +500,14 @@ Нет подключения к интернету — подключитесь для загрузки. Оценочные размеры (офлайн) Недействительные или небезопасные учётные данные синхронизации. Отсканируйте действительный QR-код синхронизации IIAB. + + Обновление + Загрузка обновления… + Проверка подписи… + ✓ Подпись проверена — готово к установке + Запуск установщика… + Сбой обновления + %1$d%% + Установить + Отмена \ No newline at end of file diff --git a/controller/app/src/main/res/values/strings.xml b/controller/app/src/main/res/values/strings.xml index 79cdcd7..2970aef 100644 --- a/controller/app/src/main/res/values/strings.xml +++ b/controller/app/src/main/res/values/strings.xml @@ -518,4 +518,14 @@ The update could not be verified and was not installed. Please try again. The update download failed. Please try again. Allow installing updates from this app, then tap update again. + + Updating + Downloading update… + Verifying signature… + ✓ Signature verified — ready to install + Starting installer… + Update failed + %1$d%% + Install + Cancel \ No newline at end of file diff --git a/controller/app/src/test/java/org/iiab/controller/update/domain/DownloadProgressTest.java b/controller/app/src/test/java/org/iiab/controller/update/domain/DownloadProgressTest.java new file mode 100644 index 0000000..01f9353 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/update/domain/DownloadProgressTest.java @@ -0,0 +1,35 @@ +package org.iiab.controller.update.domain; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +/** Pure-JVM tests for the OTA download progress model. */ +public class DownloadProgressTest { + + @Test public void percentIsClampedAndRounded() { + assertEquals(50, new DownloadProgress(DownloadProgress.Status.RUNNING, 50, 100).percent()); + assertEquals(0, new DownloadProgress(DownloadProgress.Status.RUNNING, 0, 100).percent()); + assertEquals(100, new DownloadProgress(DownloadProgress.Status.SUCCESSFUL, 100, 100).percent()); + assertEquals(33, new DownloadProgress(DownloadProgress.Status.RUNNING, 1, 3).percent()); + } + + @Test public void unknownTotalIsIndeterminate() { + DownloadProgress p = new DownloadProgress(DownloadProgress.Status.RUNNING, 1234, -1); + assertTrue(p.isIndeterminate()); + assertEquals(-1, p.percent()); + } + + @Test public void terminalStates() { + assertTrue(new DownloadProgress(DownloadProgress.Status.SUCCESSFUL, 10, 10).isTerminal()); + assertTrue(new DownloadProgress(DownloadProgress.Status.FAILED, 0, 10).isTerminal()); + assertFalse(new DownloadProgress(DownloadProgress.Status.RUNNING, 5, 10).isTerminal()); + assertFalse(DownloadProgress.none().isTerminal()); + } + + @Test public void negativeBytesClampedToZero() { + assertEquals(0, new DownloadProgress(DownloadProgress.Status.PENDING, -5, 100).downloadedBytes); + } +} diff --git a/controller/app/src/test/java/org/iiab/controller/update/presentation/UpdateUiStateTest.java b/controller/app/src/test/java/org/iiab/controller/update/presentation/UpdateUiStateTest.java new file mode 100644 index 0000000..78c7b32 --- /dev/null +++ b/controller/app/src/test/java/org/iiab/controller/update/presentation/UpdateUiStateTest.java @@ -0,0 +1,33 @@ +package org.iiab.controller.update.presentation; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.iiab.controller.update.domain.DownloadProgress; +import org.junit.Test; + +/** Pure-JVM tests for the download-snapshot -> UI-state mapping. */ +public class UpdateUiStateTest { + + @Test public void runningMapsToDownloadingWithPercent() { + UpdateUiState s = UpdateUiState.fromDownload(new DownloadProgress(DownloadProgress.Status.RUNNING, 30, 100)); + assertEquals(UpdateUiState.Status.DOWNLOADING, s.status); + assertEquals(30, s.percent); + } + + @Test public void unknownTotalIsIndeterminateDownloading() { + UpdateUiState s = UpdateUiState.fromDownload(new DownloadProgress(DownloadProgress.Status.RUNNING, 10, -1)); + assertEquals(UpdateUiState.Status.DOWNLOADING, s.status); + assertTrue(s.indeterminate); + } + + @Test public void successMapsToVerifying() { + assertEquals(UpdateUiState.Status.VERIFYING, + UpdateUiState.fromDownload(new DownloadProgress(DownloadProgress.Status.SUCCESSFUL, 100, 100)).status); + } + + @Test public void failedMapsToError() { + assertEquals(UpdateUiState.Status.ERROR, + UpdateUiState.fromDownload(new DownloadProgress(DownloadProgress.Status.FAILED, 0, 100)).status); + } +}