Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 125 additions & 4 deletions controller/app/src/main/java/org/iiab/controller/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
}
}

Expand All @@ -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();
}
Expand All @@ -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");

Expand All @@ -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
Expand All @@ -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;
}
Expand All @@ -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,
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Loading