From 7233a3ed7208d9ed0957aee357815fc3ecfaf88b Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 31 Jul 2026 02:12:15 -0600 Subject: [PATCH 1/2] =?UTF-8?q?ADFA-4957:=20deep-op=20protection=20layer?= =?UTF-8?q?=20=E2=80=94=20foundation=20+=20foreground=20service=20(engine)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of the shared resilience layer. backup/restore/clone run their work on the Fragment executor today with only EnvironmentLock + a mute WatchdogService; this brings the install-grade mechanism (a service that owns the op + app-scoped progress + a swipe-proof notification) to them. - DeepOpState + DeepOpProgressRepository: app-scoped observable single source of truth for a deep-env op's progress (mirrors InstallState/InstallProgressRepository), so the op screen re-binds after recreation/backgrounding and a notification tap can land on the live op. - EnvironmentControl: Context-based pdsm stop/start + createFakeSysData, extracted from ServerController (which now delegates) so a service can quiesce/boot the environment off any Activity. - DeepOpService: foreground service that OWNS backup/restore off the UI — hardware locks, swipe-proof progress notification (re-asserted via startForeground) with a content intent + Cancel, and brackets EnvironmentLock (+ InstallGuard for a destructive restore); publishes to DeepOpProgressRepository. Adoption (migrate BackupJobFragment to start the service + observe the repo, return-to-op routing, recovery) and clone land next. The service's boot-handoff to the activity's ServerController wants a device pass. Foundation for ADFA-4957; no behavior change yet (nothing starts the service). --- controller/app/src/main/AndroidManifest.xml | 5 + .../org/iiab/controller/ServerController.java | 41 +-- .../deepop/DeepOpProgressRepository.java | 51 ++++ .../iiab/controller/deepop/DeepOpService.java | 251 ++++++++++++++++++ .../iiab/controller/deepop/DeepOpState.java | 57 ++++ .../controller/env/EnvironmentControl.java | 121 +++++++++ .../app/src/main/res/values/strings_k2go.xml | 4 + 7 files changed, 492 insertions(+), 38 deletions(-) create mode 100644 controller/app/src/main/java/org/iiab/controller/deepop/DeepOpProgressRepository.java create mode 100644 controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java create mode 100644 controller/app/src/main/java/org/iiab/controller/deepop/DeepOpState.java create mode 100644 controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java diff --git a/controller/app/src/main/AndroidManifest.xml b/controller/app/src/main/AndroidManifest.xml index 4357d32b..239d1e13 100644 --- a/controller/app/src/main/AndroidManifest.xml +++ b/controller/app/src/main/AndroidManifest.xml @@ -121,6 +121,11 @@ android:exported="false" android:foregroundServiceType="specialUse" /> + + state = new MutableLiveData<>(DeepOpState.idle()); + private long seq = 0L; + + private DeepOpProgressRepository() {} + + public LiveData state() { return state; } + + public DeepOpState current() { + DeepOpState s = state.getValue(); + return s != null ? s : DeepOpState.idle(); + } + + /** True while a (non-terminal) deep-env op is in flight. */ + public boolean isRunning() { return current().isRunning(); } + + // All posts are thread-safe (callable from the DeepOpService worker thread). + public void postRunning(EnvironmentLock.Owner owner, String step, int percent) { post(DeepOpState.running(owner, step, percent)); } + public void postSuccess(EnvironmentLock.Owner owner, String message) { post(DeepOpState.success(owner, message)); } + public void postFailed(EnvironmentLock.Owner owner, String message) { post(DeepOpState.failed(owner, message)); } + public void postIdle() { post(DeepOpState.idle()); } + + private synchronized void post(DeepOpState s) { + state.postValue(s.withSeq(++seq)); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java b/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java new file mode 100644 index 00000000..5f78a2a4 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java @@ -0,0 +1,251 @@ +/* + * ============================================================================ + * Name : DeepOpService.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-4957. Foreground service that OWNS a deep-environment operation (backup / restore) + * off the UI, so it survives the app being backgrounded or the op screen being recreated. + * Mirrors the resilience install already has (InstallService): hardware locks + a + * swipe-proof progress notification (re-asserted via startForeground) whose content + * intent brings the user back, and it brackets the EnvironmentLock (+ InstallGuard for a + * destructive restore). Progress is published to DeepOpProgressRepository, the app-scoped + * single source of truth the op screen observes. Clone adoption + the fragment migration + * (return-to-op routing) land next; this commit is the engine. + * + * Job shapes: + * BACKUP (EXTRA_URI = SAF dest) : stop services -> stream tar|gzip to the file -> boot. + * RESTORE (EXTRA_PATH = temp file) : stop services -> extract over the rootfs -> boot. + * For restore the caller (UI) has already copied + validated the archive and shown the + * destructive confirm; the service owns the kill-sensitive extract and sets InstallGuard. + * ============================================================================ + */ +package org.iiab.controller.deepop; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; +import android.util.Log; + +import androidx.core.app.NotificationCompat; + +import org.iiab.controller.InstallGuard; +import org.iiab.controller.PRootEngine; +import org.iiab.controller.R; +import org.iiab.controller.TarExtractor; +import org.iiab.controller.backup.domain.BackupEngine; +import org.iiab.controller.env.EnvironmentControl; +import org.iiab.controller.env.EnvironmentLock; +import org.iiab.controller.redesign.LibraryActivity; +import org.iiab.controller.util.AppExecutors; + +import java.io.File; +import java.io.OutputStream; + +public final class DeepOpService extends Service { + + private static final String TAG = "IIAB-DeepOpService"; + private static final String CHANNEL_ID = "deepop_channel"; + private static final int NOTIFICATION_ID = 7; + + public static final String ACTION_BACKUP = "org.iiab.controller.DEEPOP_BACKUP"; + public static final String ACTION_RESTORE = "org.iiab.controller.DEEPOP_RESTORE"; + public static final String ACTION_CANCEL = "org.iiab.controller.DEEPOP_CANCEL"; + public static final String EXTRA_URI = "uri"; // backup: SAF dest content:// uri + public static final String EXTRA_PATH = "path"; // restore: already-validated temp file path + + private final Handler main = new Handler(Looper.getMainLooper()); + private PowerManager.WakeLock wakeLock; + private WifiManager.WifiLock wifiLock; + private volatile boolean started = false; + private volatile boolean finished = false; + private EnvironmentLock.Owner owner; + private String stepText = ""; + // Held so the booted environment's `tail -f` proot isn't GC'd after the service tears down; the + // process itself is app-scoped (it lives on until the app process dies), the activity's + // ServerController then reflects it on the next poll. + @SuppressWarnings("unused") + private static PRootEngine sBootEngine; + + /** Start a backup: stream a gzip'd tar of the rootfs to the SAF destination. */ + public static void startBackup(Context ctx, Uri dest) { + Intent i = new Intent(ctx, DeepOpService.class).setAction(ACTION_BACKUP).putExtra(EXTRA_URI, dest.toString()); + startFg(ctx, i); + } + + /** Start a restore: extract the already-validated temp archive over the rootfs (destructive). */ + public static void startRestore(Context ctx, String validatedTempPath) { + Intent i = new Intent(ctx, DeepOpService.class).setAction(ACTION_RESTORE).putExtra(EXTRA_PATH, validatedTempPath); + startFg(ctx, i); + } + + private static void startFg(Context ctx, Intent i) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i); + else ctx.startService(i); + } + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (intent == null) { stopSelf(); return START_NOT_STICKY; } + final String action = intent.getAction(); + if (ACTION_CANCEL.equals(action)) { teardown(); return START_NOT_STICKY; } + if (started) return START_NOT_STICKY; // one op per service instance + started = true; + + owner = ACTION_RESTORE.equals(action) ? EnvironmentLock.Owner.RESTORE : EnvironmentLock.Owner.BACKUP; + stepText = getString(R.string.k2go_br_status_stopping); + startForeground(NOTIFICATION_ID, buildNotification(stepText)); + acquireHardwareLocks(); + + EnvironmentLock.acquire(this, owner); + if (owner == EnvironmentLock.Owner.RESTORE) InstallGuard.begin(this); // damage recovery for a killed extract + post(stepText, -1); + + if (owner == EnvironmentLock.Owner.RESTORE) { + final String path = intent.getStringExtra(EXTRA_PATH); + EnvironmentControl.stop(this, this::log, () -> runRestore(path)); + } else { + final String uriStr = intent.getStringExtra(EXTRA_URI); + EnvironmentControl.stop(this, this::log, () -> runBackup(uriStr)); + } + return START_NOT_STICKY; + } + + // ---- BACKUP ---- + private void runBackup(final String uriStr) { + setStep(getString(R.string.k2go_br_status_backing), -1); + AppExecutors.get().io().execute(() -> { + boolean ok; + try (OutputStream os = getContentResolver().openOutputStream(Uri.parse(uriStr))) { + ok = os != null && BackupEngine.streamBackup(this, os); + } catch (Exception e) { + Log.e(TAG, "backup failed", e); + ok = false; + } + final boolean success = ok; + main.post(() -> finishJob(success, + getString(R.string.k2go_br_backup_done), getString(R.string.k2go_br_backup_failed))); + }); + } + + // ---- RESTORE ---- + private void runRestore(final String path) { + setStep(getString(R.string.k2go_br_status_restoring), -1); + final File destParent = new File(getFilesDir(), "rootfs"); + new TarExtractor().startExtraction(this, path, destParent.getAbsolutePath(), true, + new TarExtractor.ExtractionListener() { + @Override public void onComplete(String destDir) { main.post(() -> endRestore(path, true)); } + @Override public void onError(String error) { main.post(() -> endRestore(path, false)); } + @Override public void onProgress(String line) { } + }); + } + + private void endRestore(String tempPath, boolean ok) { + File temp = new File(tempPath); + if (temp.exists()) temp.delete(); + finishJob(ok, getString(R.string.k2go_br_restore_done), getString(R.string.k2go_br_restore_failed)); + } + + // ---- shared terminal ---- + private void finishJob(boolean ok, String okMsg, String failMsg) { + // Boot the (possibly replaced) environment back; the returned engine is app-scoped. + sBootEngine = EnvironmentControl.start(this, this::log); + if (owner == EnvironmentLock.Owner.RESTORE) InstallGuard.end(this); + EnvironmentLock.release(this); + if (ok) DeepOpProgressRepository.get().postSuccess(owner, okMsg); + else DeepOpProgressRepository.get().postFailed(owner, failMsg); + teardown(); + } + + private void teardown() { + finished = true; + releaseHardwareLocks(); + stopForeground(true); + stopSelf(); + } + + // ---- progress ---- + private void setStep(String step, int percent) { + stepText = step; + updateNotification(step); + post(step, percent); + } + + private void post(String step, int percent) { + DeepOpProgressRepository.get().postRunning(owner, step, percent); + } + + private void log(String line) { Log.d(TAG, line); } + + // ---- hardware locks (mirrors WatchdogService) ---- + private void acquireHardwareLocks() { + PowerManager pm = (PowerManager) getSystemService(Context.POWER_SERVICE); + if (pm != null) { + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "IIAB:DeepOpWakeLock"); + wakeLock.acquire(); + } + WifiManager wm = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (wm != null) { + wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "IIAB:DeepOpWifiLock"); + wifiLock.acquire(); + } + } + + private void releaseHardwareLocks() { + if (wakeLock != null && wakeLock.isHeld()) { wakeLock.release(); wakeLock = null; } + if (wifiLock != null && wifiLock.isHeld()) { wifiLock.release(); wifiLock = null; } + } + + // ---- notification (swipe-proof: re-assert startForeground, never notify(); mirrors InstallService) ---- + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, getString(R.string.deepop_channel_name), NotificationManager.IMPORTANCE_LOW); + channel.setDescription(getString(R.string.deepop_channel_desc)); + NotificationManager m = getSystemService(NotificationManager.class); + if (m != null) m.createNotificationChannel(channel); + } + } + + private Notification buildNotification(String text) { + Intent open = new Intent(this, LibraryActivity.class); + PendingIntent contentIntent = PendingIntent.getActivity(this, 0, open, PendingIntent.FLAG_IMMUTABLE); + Intent cancel = new Intent(this, DeepOpService.class).setAction(ACTION_CANCEL); + PendingIntent cancelIntent = PendingIntent.getService(this, 1, cancel, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle(getString(R.string.deepop_notif_title)) + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setContentIntent(contentIntent) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setOnlyAlertOnce(true) + .addAction(0, getString(R.string.deepop_notif_cancel), cancelIntent) + .build(); + } + + private void updateNotification(final String text) { + if (finished) return; + main.post(() -> { if (!finished) startForeground(NOTIFICATION_ID, buildNotification(text)); }); + } + + @Override + public IBinder onBind(Intent intent) { return null; } +} diff --git a/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpState.java b/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpState.java new file mode 100644 index 00000000..f5f840d0 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpState.java @@ -0,0 +1,57 @@ +/* + * ============================================================================ + * Name : DeepOpState.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-4957. Immutable snapshot of a deep-environment operation (backup / restore / + * clone) in progress, published through DeepOpProgressRepository so the UI re-binds + * after a configuration-change recreation or backgrounding, and the foreground + * notification can route the user back to the live operation. Terminal states carry a + * monotonic seq so the UI fires one-shot effects exactly once. Mirrors InstallState. + * ============================================================================ + */ +package org.iiab.controller.deepop; + +import org.iiab.controller.env.EnvironmentLock; + +public final class DeepOpState { + + public enum Phase { IDLE, RUNNING, SUCCESS, FAILED } + + public final Phase phase; + /** Which deep-env op this state belongs to (BACKUP / RESTORE / CLONE); null only when IDLE. */ + public final EnvironmentLock.Owner owner; + public final int percent; // 0..100, or -1 for indeterminate + public final String step; // resolved status label, e.g. "Backing up" + public final String message; // terminal message / error text + public final long seq; // assigned by the repository; identifies terminal events + + private DeepOpState(Phase phase, EnvironmentLock.Owner owner, int percent, String step, String message, long seq) { + this.phase = phase; + this.owner = owner; + this.percent = percent; + this.step = step != null ? step : ""; + this.message = message != null ? message : ""; + this.seq = seq; + } + + public boolean isRunning() { return phase == Phase.RUNNING; } + public boolean isTerminal() { return phase == Phase.SUCCESS || phase == Phase.FAILED; } + + /** Returns a copy with the given sequence number (the repository assigns it). */ + DeepOpState withSeq(long seq) { return new DeepOpState(phase, owner, percent, step, message, seq); } + + public static DeepOpState idle() { return new DeepOpState(Phase.IDLE, null, 0, "", "", 0L); } + + public static DeepOpState running(EnvironmentLock.Owner owner, String step, int percent) { + return new DeepOpState(Phase.RUNNING, owner, percent, step, "", 0L); + } + + public static DeepOpState success(EnvironmentLock.Owner owner, String message) { + return new DeepOpState(Phase.SUCCESS, owner, 0, "", message, 0L); + } + + public static DeepOpState failed(EnvironmentLock.Owner owner, String message) { + return new DeepOpState(Phase.FAILED, owner, 0, "", message, 0L); + } +} diff --git a/controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java new file mode 100644 index 00000000..c09b8d88 --- /dev/null +++ b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java @@ -0,0 +1,121 @@ +/* + * ============================================================================ + * Name : EnvironmentControl.java + * Author : AppDevForAll + * Copyright : Copyright (c) 2026 AppDevForAll + * Description : ADFA-4957. Context-based control of the Debian environment SERVICES (pdsm start/stop), + * extracted from ServerController so a foreground service (DeepOpService) can quiesce and + * boot the environment off ANY Activity — the same deterministic pdsm commands the UI + * path uses. start() first writes the fake /proc data the container expects, then runs + * `pdsm start && tail -f /dev/null` (the tail holds the container); stop() runs + * `pdsm stop`. Callbacks fire on the PRootEngine worker thread (a service is already off + * the UI thread). ServerController.createFakeSysData delegates here so there is one copy. + * ============================================================================ + */ +package org.iiab.controller.env; + +import android.content.Context; +import android.os.SystemClock; +import android.util.Log; + +import org.iiab.controller.PRootEngine; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.Locale; + +public final class EnvironmentControl { + + private static final String TAG = "IIAB-EnvControl"; + private static final String PATH_ENV = + "/usr/bin/env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -lc "; + private static final String CMD_STOP = PATH_ENV + "'/usr/local/bin/pdsm stop'"; + private static final String CMD_START = PATH_ENV + "'/usr/local/bin/pdsm start && tail -f /dev/null'"; + + private EnvironmentControl() {} + + /** The installed rootfs services directory the pdsm commands run in. */ + public static File rootfs(Context ctx) { + return new File(ctx.getApplicationContext().getFilesDir(), "rootfs/installed-rootfs/iiab"); + } + + /** Optional log sink for the pdsm output lines. */ + public interface LineSink { void onLine(String line); } + + /** + * Quiesce the environment's services (pdsm stop) so a backup/restore reads or writes a STATIC + * rootfs. {@code onDone} runs when pdsm stop exits (or errors — we proceed either way, the point is + * that no service is left writing). Runs on the PRootEngine worker thread. + */ + public static void stop(Context ctx, final LineSink log, final Runnable onDone) { + new PRootEngine().executeInContainer(ctx.getApplicationContext(), rootfs(ctx).getAbsolutePath(), CMD_STOP, + new PRootEngine.OutputListener() { + @Override public void onOutputLine(String line) { if (log != null) log.onLine("[PDSM Stop] " + line); } + @Override public void onProcessExit(int exitCode) { if (onDone != null) onDone.run(); } + @Override public void onError(String error) { if (onDone != null) onDone.run(); } + }); + } + + /** + * Boot the environment's services (pdsm start). Writes the fake /proc data first. Returns the + * engine, which the caller must keep referenced: the `tail -f /dev/null` keeps the container alive. + */ + public static PRootEngine start(Context ctx, final LineSink log) { + File rootfsDir = rootfs(ctx); + createFakeSysData(rootfsDir); + PRootEngine engine = new PRootEngine(); + engine.executeInContainer(ctx.getApplicationContext(), rootfsDir.getAbsolutePath(), CMD_START, + new PRootEngine.OutputListener() { + @Override public void onOutputLine(String line) { if (log != null) log.onLine("[Server] " + line); } + @Override public void onProcessExit(int exitCode) { if (log != null) log.onLine("[Server] engine exit " + exitCode); } + @Override public void onError(String error) { if (log != null) log.onLine("[Server] error " + error); } + }); + return engine; + } + + /** + * Writes the fake /proc files (uptime/version/stat/loadavg) the container expects. Extracted + * verbatim from ServerController.createFakeSysData; that method now delegates here so there is a + * single implementation. + */ + public static void createFakeSysData(File rootfsDir) { + try { + File procDir = new File(rootfsDir, "proc"); + if (!procDir.exists()) procDir.mkdirs(); + + long uptimeMillis = SystemClock.elapsedRealtime(); + long bootTimeSeconds = (System.currentTimeMillis() - uptimeMillis) / 1000; + double uptimeSeconds = uptimeMillis / 1000.0; + + File uptimeFile = new File(procDir, ".uptime"); + if (uptimeFile.exists()) uptimeFile.delete(); + FileOutputStream fosUp = new FileOutputStream(uptimeFile); + fosUp.write(String.format(Locale.US, "%.2f %.2f\n", uptimeSeconds, uptimeSeconds).getBytes()); + fosUp.close(); + + File versionFile = new File(procDir, ".version"); + if (!versionFile.exists()) { + FileOutputStream fosVer = new FileOutputStream(versionFile); + fosVer.write("Linux version 6.17.0-PRoot-IIAB (builder@iiab) (Android NDK) #1 SMP PREEMPT Thu Apr 30 20:00:00 UTC 2026\n".getBytes()); + fosVer.close(); + } + + File statFile = new File(procDir, ".stat"); + if (statFile.exists()) statFile.delete(); + FileOutputStream fosStat = new FileOutputStream(statFile); + String statContent = "cpu 1000 0 1000 10000 0 0 0 0 0 0\n" + + "btime " + bootTimeSeconds + "\n"; + fosStat.write(statContent.getBytes()); + fosStat.close(); + + File loadavgFile = new File(procDir, ".loadavg"); + if (!loadavgFile.exists()) { + FileOutputStream fosLoad = new FileOutputStream(loadavgFile); + fosLoad.write("0.00 0.00 0.00 1/1 1\n".getBytes()); + fosLoad.close(); + } + } catch (Exception e) { + Log.e(TAG, "Failed to create dynamic fake sysdata", e); + } + } +} diff --git a/controller/app/src/main/res/values/strings_k2go.xml b/controller/app/src/main/res/values/strings_k2go.xml index 8b4815bc..9a237bf9 100644 --- a/controller/app/src/main/res/values/strings_k2go.xml +++ b/controller/app/src/main/res/values/strings_k2go.xml @@ -175,6 +175,10 @@ System restored Restore failed This is running and can\'t be interrupted — please wait. + Backup & restore + Keeps a backup or restore running safely in the background + Backup & restore + Cancel DEVELOPER Network & DNS Use a custom DNS. Off = server defaults. From 95cbd891889a47d72a4465de1f3b4af11153dd37 Mon Sep 17 00:00:00 2001 From: Luis Guzman Date: Fri, 31 Jul 2026 02:38:33 -0600 Subject: [PATCH 2/2] ADFA-4957: fix Cancel cleanup + define boot handoff (review #308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 Cancel: the notification Cancel now runs the full terminal cleanup (release EnvironmentLock, post terminal) via a done-guard, instead of a bare teardown that stranded the environment stopped and the lock held. Cancel is offered ONLY for backup (read-only); restore has no Cancel action (destructive, hard gate). A clean restore clears InstallGuard; a FAILED restore leaves it set so next-launch recovery repairs the torn rootfs. #2 Boot handoff: the service no longer boots the environment. Mirroring InstallService (whose server restart is owned by the install index), DeepOpService leaves it stopped and the hosting Activity boots via serverController.startEnvironment() on return / next launch — so the service and ServerController never both own the proot container. Removes sBootEngine and the now-unused EnvironmentControl.start(). --- .../iiab/controller/deepop/DeepOpService.java | 71 +++++++++++++------ .../controller/env/EnvironmentControl.java | 31 ++------ 2 files changed, 54 insertions(+), 48 deletions(-) diff --git a/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java b/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java index 5f78a2a4..b9a45840 100644 --- a/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java +++ b/controller/app/src/main/java/org/iiab/controller/deepop/DeepOpService.java @@ -12,11 +12,18 @@ * single source of truth the op screen observes. Clone adoption + the fragment migration * (return-to-op routing) land next; this commit is the engine. * + * BOOT HANDOFF (ADFA-4957 review #2): the service does NOT boot the environment. Like + * InstallService (whose server restart is owned by the install index, not the service), + * DeepOpService leaves the environment stopped and the hosting Activity boots it via + * serverController.startEnvironment() on return / next launch — so the service and + * ServerController never both own the proot container. + * * Job shapes: - * BACKUP (EXTRA_URI = SAF dest) : stop services -> stream tar|gzip to the file -> boot. - * RESTORE (EXTRA_PATH = temp file) : stop services -> extract over the rootfs -> boot. + * BACKUP (EXTRA_URI = SAF dest) : stop services -> stream tar|gzip -> post terminal. + * RESTORE (EXTRA_PATH = temp file) : stop services -> extract over the rootfs -> terminal. * For restore the caller (UI) has already copied + validated the archive and shown the * destructive confirm; the service owns the kill-sensitive extract and sets InstallGuard. + * Backup is cancellable from the notification (read-only); restore is not (hard gate). * ============================================================================ */ package org.iiab.controller.deepop; @@ -40,7 +47,6 @@ import androidx.core.app.NotificationCompat; import org.iiab.controller.InstallGuard; -import org.iiab.controller.PRootEngine; import org.iiab.controller.R; import org.iiab.controller.TarExtractor; import org.iiab.controller.backup.domain.BackupEngine; @@ -68,14 +74,10 @@ public final class DeepOpService extends Service { private PowerManager.WakeLock wakeLock; private WifiManager.WifiLock wifiLock; private volatile boolean started = false; - private volatile boolean finished = false; + private volatile boolean finished = false; // notification teardown reached + private volatile boolean done = false; // terminal reached (cancel OR natural) — clean up once private EnvironmentLock.Owner owner; private String stepText = ""; - // Held so the booted environment's `tail -f` proot isn't GC'd after the service tears down; the - // process itself is app-scoped (it lives on until the app process dies), the activity's - // ServerController then reflects it on the next poll. - @SuppressWarnings("unused") - private static PRootEngine sBootEngine; /** Start a backup: stream a gzip'd tar of the rootfs to the SAF destination. */ public static void startBackup(Context ctx, Uri dest) { @@ -104,7 +106,7 @@ public void onCreate() { public int onStartCommand(Intent intent, int flags, int startId) { if (intent == null) { stopSelf(); return START_NOT_STICKY; } final String action = intent.getAction(); - if (ACTION_CANCEL.equals(action)) { teardown(); return START_NOT_STICKY; } + if (ACTION_CANCEL.equals(action)) { if (started) cancel(); else stopSelf(); return START_NOT_STICKY; } if (started) return START_NOT_STICKY; // one op per service instance started = true; @@ -127,8 +129,9 @@ public int onStartCommand(Intent intent, int flags, int startId) { return START_NOT_STICKY; } - // ---- BACKUP ---- + // ---- BACKUP (read-only) ---- private void runBackup(final String uriStr) { + if (done) return; setStep(getString(R.string.k2go_br_status_backing), -1); AppExecutors.get().io().execute(() -> { boolean ok; @@ -144,8 +147,9 @@ private void runBackup(final String uriStr) { }); } - // ---- RESTORE ---- + // ---- RESTORE (destructive) ---- private void runRestore(final String path) { + if (done) return; setStep(getString(R.string.k2go_br_status_restoring), -1); final File destParent = new File(getFilesDir(), "rootfs"); new TarExtractor().startExtraction(this, path, destParent.getAbsolutePath(), true, @@ -162,17 +166,35 @@ private void endRestore(String tempPath, boolean ok) { finishJob(ok, getString(R.string.k2go_br_restore_done), getString(R.string.k2go_br_restore_failed)); } - // ---- shared terminal ---- + // ---- single terminal path (natural completion OR cancel), run once ---- + /** + * The service does NOT boot the environment (review #2): the hosting Activity owns that + * (serverController.startEnvironment on return / next launch), so the service and ServerController + * never both own the container. A CLEAN restore clears InstallGuard; a FAILED restore leaves it set + * so next-launch recovery repairs the torn rootfs. + */ private void finishJob(boolean ok, String okMsg, String failMsg) { - // Boot the (possibly replaced) environment back; the returned engine is app-scoped. - sBootEngine = EnvironmentControl.start(this, this::log); - if (owner == EnvironmentLock.Owner.RESTORE) InstallGuard.end(this); + if (done) return; + done = true; + if (owner == EnvironmentLock.Owner.RESTORE && ok) InstallGuard.end(this); EnvironmentLock.release(this); if (ok) DeepOpProgressRepository.get().postSuccess(owner, okMsg); else DeepOpProgressRepository.get().postFailed(owner, failMsg); teardown(); } + /** + * Notification "Cancel" — offered only for BACKUP (read-only, safe to abandon). Restore has no + * Cancel action (destructive, hard gate). The in-flight backup stream sees {@code done} and no-ops + * on completion. Runs the same cleanup as a failure so the lock is released and the op ends. + */ + private void cancel() { + if (owner == EnvironmentLock.Owner.BACKUP) { + finishJob(false, "", getString(R.string.k2go_br_backup_failed)); + } + // A stray CANCEL for restore (which has no cancel action) is ignored — the extract must finish. + } + private void teardown() { finished = true; releaseHardwareLocks(); @@ -226,19 +248,22 @@ private void createNotificationChannel() { private Notification buildNotification(String text) { Intent open = new Intent(this, LibraryActivity.class); PendingIntent contentIntent = PendingIntent.getActivity(this, 0, open, PendingIntent.FLAG_IMMUTABLE); - Intent cancel = new Intent(this, DeepOpService.class).setAction(ACTION_CANCEL); - PendingIntent cancelIntent = PendingIntent.getService(this, 1, cancel, - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - return new NotificationCompat.Builder(this, CHANNEL_ID) + NotificationCompat.Builder b = new NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(getString(R.string.deepop_notif_title)) .setContentText(text) .setSmallIcon(android.R.drawable.stat_sys_download) .setContentIntent(contentIntent) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) - .setOnlyAlertOnce(true) - .addAction(0, getString(R.string.deepop_notif_cancel), cancelIntent) - .build(); + .setOnlyAlertOnce(true); + // Cancel only for backup (read-only). Restore is destructive → uncancellable (hard gate). + if (owner == EnvironmentLock.Owner.BACKUP) { + Intent cancel = new Intent(this, DeepOpService.class).setAction(ACTION_CANCEL); + PendingIntent cancelIntent = PendingIntent.getService(this, 1, cancel, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + b.addAction(0, getString(R.string.deepop_notif_cancel), cancelIntent); + } + return b.build(); } private void updateNotification(final String text) { diff --git a/controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java index c09b8d88..3565d2d5 100644 --- a/controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java +++ b/controller/app/src/main/java/org/iiab/controller/env/EnvironmentControl.java @@ -3,13 +3,12 @@ * Name : EnvironmentControl.java * Author : AppDevForAll * Copyright : Copyright (c) 2026 AppDevForAll - * Description : ADFA-4957. Context-based control of the Debian environment SERVICES (pdsm start/stop), - * extracted from ServerController so a foreground service (DeepOpService) can quiesce and - * boot the environment off ANY Activity — the same deterministic pdsm commands the UI - * path uses. start() first writes the fake /proc data the container expects, then runs - * `pdsm start && tail -f /dev/null` (the tail holds the container); stop() runs - * `pdsm stop`. Callbacks fire on the PRootEngine worker thread (a service is already off - * the UI thread). ServerController.createFakeSysData delegates here so there is one copy. + * Description : ADFA-4957. Context-based quiescing of the Debian environment SERVICES (pdsm stop), + * extracted from ServerController so a foreground service (DeepOpService) can stop the + * environment off ANY Activity — the same deterministic pdsm command the UI path uses. + * stop() runs `pdsm stop`; callbacks fire on the PRootEngine worker thread. Booting is + * deliberately NOT here: the hosting Activity boots via ServerController.startEnvironment() + * (which shares createFakeSysData below, so the fake /proc data has a single copy). * ============================================================================ */ package org.iiab.controller.env; @@ -30,7 +29,6 @@ public final class EnvironmentControl { private static final String PATH_ENV = "/usr/bin/env PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -lc "; private static final String CMD_STOP = PATH_ENV + "'/usr/local/bin/pdsm stop'"; - private static final String CMD_START = PATH_ENV + "'/usr/local/bin/pdsm start && tail -f /dev/null'"; private EnvironmentControl() {} @@ -56,23 +54,6 @@ public static void stop(Context ctx, final LineSink log, final Runnable onDone) }); } - /** - * Boot the environment's services (pdsm start). Writes the fake /proc data first. Returns the - * engine, which the caller must keep referenced: the `tail -f /dev/null` keeps the container alive. - */ - public static PRootEngine start(Context ctx, final LineSink log) { - File rootfsDir = rootfs(ctx); - createFakeSysData(rootfsDir); - PRootEngine engine = new PRootEngine(); - engine.executeInContainer(ctx.getApplicationContext(), rootfsDir.getAbsolutePath(), CMD_START, - new PRootEngine.OutputListener() { - @Override public void onOutputLine(String line) { if (log != null) log.onLine("[Server] " + line); } - @Override public void onProcessExit(int exitCode) { if (log != null) log.onLine("[Server] engine exit " + exitCode); } - @Override public void onError(String error) { if (log != null) log.onLine("[Server] error " + error); } - }); - return engine; - } - /** * Writes the fake /proc files (uptime/version/stat/loadavg) the container expects. Extracted * verbatim from ServerController.createFakeSysData; that method now delegates here so there is a