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
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ public void onCreate() {
// K2GO-439: one owner pads every activity's content by the system-bar insets (targetSdk 35
// edge-to-edge); screens that manage their own insets opt out via EdgeToEdge.SelfManaged.
registerActivityLifecycleCallbacks(new org.appdevforall.k2go.ui.EdgeToEdgeCallbacks());
// K2GO-557: one app-scoped holder of the foreground Activity. Exposes current() (a reusable
// handle for app-wide work) and re-shows a pending dash-node update failure on the next resume.
registerActivityLifecycleCallbacks(new org.appdevforall.k2go.ui.ForegroundActivityHolder());
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@


public class SplashActivity extends AppCompatActivity
implements org.appdevforall.k2go.ui.EdgeToEdge.SelfManaged {
implements org.appdevforall.k2go.ui.EdgeToEdge.SelfManaged,
org.appdevforall.k2go.ui.ForegroundActivityHolder.DialogExempt {

private static final long EXIT_AT_MS = 3100L;
private static final long EXIT_FADE_MS = 400L;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* ============================================================================
* Name : DeviceMemoryReader.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-557. Reads live device memory into a domain DeviceMemory, from
* ActivityManager.MemoryInfo (device-wide figures, not this process heap). Data layer:
* the only place in this slice that touches android.*.
* ============================================================================
*/
package org.appdevforall.k2go.dashboard.data;

import android.app.ActivityManager;
import android.content.Context;

import androidx.annotation.NonNull;

import org.appdevforall.k2go.dashboard.domain.DeviceMemory;

public final class DeviceMemoryReader {
private DeviceMemoryReader() {}

/** Snapshot device memory now. If ActivityManager is unavailable, return a snapshot that reads as
* "not low", so the advisory never fires on a bad read (fail open: the update is non-blocking). */
@NonNull
public static DeviceMemory read(@NonNull Context ctx) {
ActivityManager am = (ActivityManager) ctx.getSystemService(Context.ACTIVITY_SERVICE);
if (am == null) return new DeviceMemory(Long.MAX_VALUE, 0L, false);
ActivityManager.MemoryInfo info = new ActivityManager.MemoryInfo();
am.getMemoryInfo(info);
return new DeviceMemory(info.availMem, info.totalMem, info.lowMemory);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
* ============================================================================
* Name : DeviceMemory.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-557. A snapshot of device memory at one moment, as plain numbers. Pure domain:
* no android.* import, so the low-memory rule that reads it stays unit-testable on a
* plain JVM. The data source (DeviceMemoryReader) fills this from ActivityManager.
* ============================================================================
*/
package org.appdevforall.k2go.dashboard.domain;

public final class DeviceMemory {
private final long availableBytes;
private final long totalBytes;
private final boolean systemLowMemory;

public DeviceMemory(long availableBytes, long totalBytes, boolean systemLowMemory) {
this.availableBytes = availableBytes;
this.totalBytes = totalBytes;
this.systemLowMemory = systemLowMemory;
}

public long availableBytes() { return availableBytes; }

public long totalBytes() { return totalBytes; }

/** True when Android already reports the system is under memory pressure (MemoryInfo.lowMemory). */
public boolean systemLowMemory() { return systemLowMemory; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* ============================================================================
* Name : LowMemoryAdvice.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-557. The single rule for "should we advise the user to close apps before this
* dash-node update". The update stages a second node and a tsc build next to the live
* server, so it needs working memory. We advise ONLY when free memory is at or below
* what the update needs: with clear headroom we stay silent (no nagging). The advice is
* advisory, never a gate: the update still runs. Pure domain, unit-tested.
* ============================================================================
*/
package org.appdevforall.k2go.dashboard.domain;

public final class LowMemoryAdvice {
private LowMemoryAdvice() {}

/**
* Working memory the live update needs (the staging node plus the tsc build, beside the live
* server). Measured on a 64-bit device for K2GO-557: a cold build (the box was idle, which is the
* real user case) consumes about 386 MB and also uses swap. This value is that peak plus a small
* margin. We advise only at or below it, so a device with clear headroom is left alone. Tune it
* again from field results. A 32-bit device runs lighter per process but has far less headroom.
*/
public static final long UPDATE_WORKING_SET_BYTES = 450L * 1024L * 1024L; // about 450 MB

/** Advise (advisory only) when the system already reports memory pressure, or when free memory is
* at or below what the update needs. */
public static boolean shouldAdviseCloseApps(DeviceMemory memory) {
if (memory == null) return false;
if (memory.systemLowMemory()) return true;
return memory.availableBytes() <= UPDATE_WORKING_SET_BYTES;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* ============================================================================
* Name : DashUpdateFailureDialog.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-557. The IN-APP "dashboard update failed" notice, replacing the old system
* notification. Shown two ways from one guarded helper: the instant pop while the user
* watches the card (STATE_ERROR broadcast), and a re-show on the next foreground when the
* failure happened with no screen watching (maybeShow, driven by the persisted flag in
* UpdateStatusCache). Any dismissal acknowledges the failure (clears the flag), so it shows
* once per failure and never nags. Mirrors LibraryActivity.showDamagedDialog.
* ============================================================================
*/
package org.appdevforall.k2go.dashboard.presentation;

import android.app.Activity;
import android.view.ContextThemeWrapper;

import androidx.annotation.NonNull;
import androidx.annotation.StringRes;

import org.appdevforall.k2go.LogRepository;
import org.appdevforall.k2go.R;
import org.appdevforall.k2go.feedback.domain.FeedbackType;
import org.appdevforall.k2go.feedback.presentation.FeedbackFab;
import org.appdevforall.k2go.redesign.OpReturnNavigator;
import org.appdevforall.k2go.redesign.UpdateStatusCache;
import org.appdevforall.k2go.ui.dialog.BrandDialog;

import java.lang.ref.WeakReference;
import java.util.List;

public final class DashUpdateFailureDialog {
private DashUpdateFailureDialog() {}

// One dialog at a time across the app: the instant pop (dashboard) and the app-wide re-show
// (ForegroundActivityHolder) both route here, so guard against a second copy stacking on top.
private static WeakReference<BrandDialog.Handle> showing;

private static boolean isShowing() {
BrandDialog.Handle h = showing == null ? null : showing.get();
return h != null && h.getDialog() != null && h.getDialog().isShowing();
}

/**
* Re-show a pending failure on any foreground screen (the app-wide path). No-op when nothing is
* pending or a copy is already up. The primary action opens the dashboard, where Rebuild retries.
*/
public static void maybeShow(@NonNull Activity act) {
if (!UpdateStatusCache.pendingFailure(act)) return;
show(act, R.string.k2go_dash_update_open_dashboard, () -> openDashboard(act));
}

/**
* Show the failure dialog now. {@code primaryText}/{@code onPrimary} are the primary action: "Try
* again" in place on the dashboard card, or "Open dashboard" from any other screen. The reason line is
* the same for every failure ("the previous version was kept", which holds whether the build failed or
* never started), so it is not threaded through: one accurate message, one source.
*/
public static void show(@NonNull Activity act, @StringRes int primaryText, @NonNull Runnable onPrimary) {
if (act.isFinishing() || act.isDestroyed() || isShowing()) return;
CharSequence message = act.getString(R.string.k2go_dash_live_error) + "\n\n"
+ act.getString(R.string.k2go_dash_update_failed_text);
// Theme-wrap so the Material dialog inflates even if the host Activity's own theme is not Material.
BrandDialog.Handle h = new BrandDialog(new ContextThemeWrapper(act, R.style.Theme_K2Go))
.setIcon(R.drawable.ic_error_outline_24, R.color.k2go_clay)
.setTitle(R.string.k2go_dash_update_failed_title)
.setMessage(message)
.setPositive(primaryText, BrandDialog.Role.PRIMARY, () -> { ack(act); onPrimary.run(); })
.setNegative(R.string.k2go_damaged_close, () -> ack(act))
// Report sends the APK log tail and keeps the dialog up (box-side log is deferred, K2GO-557).
.setNeutral(R.string.k2go_damaged_report, () -> FeedbackFab.sendFeedback(
act, "dash-update-failed", FeedbackType.BUG, report()))
.setDismissOnNeutral(false)
.setOnCancel(() -> ack(act))
.show();
showing = new WeakReference<>(h);
}

/** Acknowledge: a failure the user has now seen is no longer pending. */
private static void ack(@NonNull Activity act) {
UpdateStatusCache.setPendingFailure(act, false);
}

private static void openDashboard(@NonNull Activity act) {
act.startActivity(OpReturnNavigator.dashboardRebuild(act));
}

/** K2GO-557: the APK-side log tail, as the body of a report. Box-side log is a later ticket. */
private static String report() {
List<String> all = LogRepository.get().snapshot();
int from = Math.max(0, all.size() - 80);
StringBuilder sb = new StringBuilder("Dashboard update did not finish. Last lines:\n");
for (int i = from; i < all.size(); i++) sb.append(all.get(i)).append('\n');
return sb.toString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
/*
* ============================================================================
* Name : LowMemoryGate.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : K2GO-557. Pre-start in-app gate for the dash-node live update. When the device is low on
* memory the update may fail, so warn BEFORE it starts and let the user close other apps
* first ("Not now") or proceed ("Start anyway"). Presentation only: the "is memory tight"
* rule stays pure in LowMemoryAdvice, the read in DeviceMemoryReader; this just turns that
* boolean into a dialog. Mirrors dependency.presentation.DashNodeGate.
* ============================================================================
*/
package org.appdevforall.k2go.dashboard.presentation;

import android.app.Activity;
import android.view.ContextThemeWrapper;

import androidx.annotation.NonNull;

import org.appdevforall.k2go.R;
import org.appdevforall.k2go.dashboard.data.DeviceMemoryReader;
import org.appdevforall.k2go.dashboard.domain.LowMemoryAdvice;
import org.appdevforall.k2go.ui.dialog.BrandDialog;

public final class LowMemoryGate {
private LowMemoryGate() {}

/**
* Run {@code onProceed} straight away when memory is fine. When it is tight, show the advisory and run
* {@code onProceed} only if the user picks "Start anyway"; "Not now" (or dismissing) does nothing, so
* the user can close apps and retry. Advisory, not a block: the choice is always the user's.
*/
public static void guard(@NonNull Activity act, @NonNull Runnable onProceed) {
if (!LowMemoryAdvice.shouldAdviseCloseApps(DeviceMemoryReader.read(act))) {
onProceed.run();
return;
}
new BrandDialog(new ContextThemeWrapper(act, R.style.Theme_K2Go))
.setTitle(R.string.k2go_dash_lowmem_title)
.setMessage(R.string.k2go_dash_lowmem_text)
.setPositive(R.string.k2go_dash_lowmem_start_anyway, BrandDialog.Role.PRIMARY, onProceed::run)
.setNegative(R.string.k2go_dash_lowmem_not_now, null)
.show();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,13 @@ public class DashboardDetailFragment extends Fragment {
} else if (DashboardRebuildService.STATE_DONE.equals(state)) {
setUpdating(false);
refreshAfterLiveUpdate();
} else if (DashboardRebuildService.STATE_ERROR.equals(state)
|| DashboardRebuildService.STATE_CANCELLED.equals(state)) {
} else if (DashboardRebuildService.STATE_ERROR.equals(state)) {
setUpdating(false);
fetchUpdateStatus(); // version unchanged; re-resolve the pill/emphasis
// K2GO-557: pop the in-app failure dialog now (the user is watching). The service already
// set the pending-failure flag, so if this card is gone it re-shows app-wide instead.
showFailureHere();
} else if (DashboardRebuildService.STATE_CANCELLED.equals(state)) {
setUpdating(false);
fetchUpdateStatus(); // version unchanged; re-resolve the pill/emphasis
}
Expand Down Expand Up @@ -154,6 +159,23 @@ public void onStart() {
ContextCompat.registerReceiver(requireContext(), rebuildState, f,
ContextCompat.RECEIVER_NOT_EXPORTED);
resolveInitialUpdatingState();
// K2GO-557: if a past update failed while this card was away, re-show it HERE (Try again retries
// in place). onStart runs before the app-wide holder's resume, so the card's "Try again" wins over
// the holder's generic "Open dashboard" (the shared dialog guard prevents a double).
if (!DashboardRebuildService.isRunning() && UpdateStatusCache.pendingFailure(requireContext())) {
showFailureHere();
}
}

/** K2GO-557: the in-app update-failure dialog for THIS card, where "Try again" re-runs the gated start
* in place (gates, confirm, low-memory advisory, then start). */
private void showFailureHere() {
if (!isAdded()) return;
final View root = getView();
if (root == null) return;
org.appdevforall.k2go.dashboard.presentation.DashUpdateFailureDialog.show(
requireActivity(), R.string.k2go_dash_update_retry,
() -> { if (isAdded()) DashboardRebuild.confirmAndStart(this, root, updateAvailable); });
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,12 @@ public static void confirmAndStart(@NonNull Fragment host, @NonNull View anchor,
: R.string.k2go_dash_rebuild_confirm_title)
.setMessage(R.string.k2go_dash_rebuild_confirm_msg)
.setCheckbox(R.string.k2go_dash_update_site, true)
// K2GO-557: after the user confirms, a low-memory device gets one more in-app advisory
// ("Not now / Start anyway") before the update actually starts. Fine memory proceeds at once.
.setPositive(updateAvailable ? R.string.k2go_dash_update : R.string.k2go_dash_rebuild,
BrandDialog.Role.PRIMARY, checked -> start(host, anchor, checked))
BrandDialog.Role.PRIMARY,
checked -> org.appdevforall.k2go.dashboard.presentation.LowMemoryGate.guard(
host.requireActivity(), () -> start(host, anchor, checked)))
.setNegative(android.R.string.cancel, null)
.show();
}
Expand Down
Loading
Loading