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 @@ -31,7 +31,9 @@
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.os.SystemClock;
import android.util.Log;
Expand All @@ -57,6 +59,7 @@
import java.io.File;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class DiskGuard {
Expand Down Expand Up @@ -88,7 +91,8 @@ public final class DiskGuard {
private static final long GROWTH_MIN_BYTES = 16L * 1024 * 1024; // 16 MiB within GROWTH_PROBE_MS

private static final String CHANNEL_ID = "disk_guard_channel";
private static final int NOTIF_ID = 7386;
private static final int NOTIF_ID = 7386; // escalation (stopped and staying down)
private static final int NOTIF_ID_FIREHOSE = 7387; // firehose contain (reaped, kept alive)

// Recent-trip state, in memory on purpose: it resets when the app process restarts, so a stale count
// never carries across a restart. Read and written only under advanceTripState (class monitor).
Expand Down Expand Up @@ -125,7 +129,7 @@ public static boolean checkFirehoseSignal(Context ctx) {
if (ctx == null) return false;
FirehoseSignal sig = FirehoseSignalSource.read();
if (sig == null || !sig.isFresh(FIREHOSE_FRESH_WINDOW_MS)) return false; // no live alert
return actOnFirehose(ctx, sig.maxStreak);
return actOnFirehose(ctx, "firehose_signal", sig.maxStreak, sig.paths);
}

/**
Expand All @@ -135,7 +139,7 @@ public static boolean checkFirehoseSignal(Context ctx) {
*/
public static boolean checkFirehoseForced(Context ctx) {
if (ctx == null) return false;
return actOnFirehose(ctx, -1);
return actOnFirehose(ctx, "debug", -1, Collections.emptyList());
}

private static boolean run(Context ctx, long floorBytes, boolean forced) {
Expand Down Expand Up @@ -167,14 +171,16 @@ private static boolean run(Context ctx, long floorBytes, boolean forced) {
// Last resort: the fill keeps returning after restarts. Stop and stay down through the one
// persisted lever, and tell the user. The user re-enables the server after freeing space.
ServerLifecycleReconciler.get().setUserWantsOn(ctx, false);
notifyUser(ctx);
report(ctx, "escalated_stopped", floorBytes, reaped, reclaimed, v.tripCount);
report(ctx, "low_disk", "escalated_stopped", floorBytes, reaped, reclaimed, v.tripCount, null);
notifyUser(ctx, NOTIF_ID, ctx.getString(R.string.disk_guard_notif_title),
ctx.getString(R.string.disk_guard_notif_body),
buildReportMessage(ctx, "low_disk", "escalated_stopped", reaped, reclaimed, v.tripCount, null));
Log.w(TAG, "K2GO-386: recurring disk pressure (trip " + v.tripCount + "): stopped and staying down");
} else {
// Default: keep the system alive. Leave desired=UP and ask the reconciler to relaunch a fresh
// box now. Report only the first trip of a spell so a thrash does not spam telemetry.
ServerLifecycleReconciler.get().requestReconcileNow();
if (v.firstOfSpell) report(ctx, "contained", floorBytes, reaped, reclaimed, v.tripCount);
if (v.firstOfSpell) report(ctx, "low_disk", "contained", floorBytes, reaped, reclaimed, v.tripCount, null);
Log.w(TAG, "K2GO-386: contained disk pressure (trip " + v.tripCount + "): reaped=" + reaped
+ ", reclaimed=" + reclaimed + " B, box restarting");
}
Expand Down Expand Up @@ -224,7 +230,7 @@ private static boolean deepOpActive(Context ctx) {
* off-proot orphan (unlike an in-box kill), so a fresh box does not refill. The low-disk path stays
* the sole escalation authority, so a firehose reap never counts toward stop-and-stay-down.
*/
private static boolean actOnFirehose(Context ctx, int streak) {
private static boolean actOnFirehose(Context ctx, String source, int streak, List<String> paths) {
if (!confirmFirehoseGrowing(ctx)) return false;
if (deepOpActive(ctx)) {
Log.w(TAG, "K2GO-386: firehose confirmed but a deep op holds the box; not reaping this tick");
Expand All @@ -233,7 +239,10 @@ private static boolean actOnFirehose(Context ctx, int streak) {
boolean reaped = EnvironmentProcess.reapBox(ctx);
long reclaimed = reclaimRunawayLog(ctx);
ServerLifecycleReconciler.get().requestReconcileNow();
report(ctx, "contained_firehose", 0L, reaped, reclaimed, streak);
report(ctx, source, "contained_firehose", 0L, reaped, reclaimed, streak, paths);
notifyUser(ctx, NOTIF_ID_FIREHOSE, ctx.getString(R.string.disk_guard_firehose_title),
ctx.getString(R.string.disk_guard_firehose_body),
buildReportMessage(ctx, source, "contained_firehose", reaped, reclaimed, streak, paths));
Log.w(TAG, "K2GO-386: contained recurring firehose (streak " + streak + "): reaped=" + reaped
+ ", reclaimed=" + reclaimed + " B, box restarting");
return true;
Expand Down Expand Up @@ -304,25 +313,34 @@ private static File biggestLog(File dir) {
* reporting is off or Sentry has no DSN. See IIABApplication (ADFA-4533) and ADR-386 section 7.
* The user-facing, user-sent report is a separate channel (the closing K2GO-386 ticket).
*/
private static void report(Context ctx, String action, long floorBytes, boolean reaped,
long reclaimed, int trip) {
private static void report(Context ctx, String source, String action, long floorBytes, boolean reaped,
long reclaimed, int count, List<String> paths) {
try {
if (!CrashReportConsent.isEnabled(ctx)) return;
Sentry.withScope(scope -> {
scope.setLevel(SentryLevel.WARNING);
scope.setTag("event", "disk_guard");
scope.setTag("action", action);
scope.setTag("source", source);
scope.setTag("reaped", String.valueOf(reaped));
scope.setExtra("floor_bytes", String.valueOf(floorBytes));
scope.setExtra("reclaimed_bytes", String.valueOf(reclaimed));
scope.setExtra("trip", String.valueOf(trip));
scope.setExtra("trip_or_streak", String.valueOf(count));
scope.setExtra("firehose_paths", formatPaths(paths));
Sentry.captureMessage("K2GO-386 disk-guard " + action);
});
} catch (Throwable t) {
Log.w(TAG, "K2GO-386: could not report disk-guard event", t);
}
}

/** Join the firehosing paths for a report. Just short path strings -- never log content. Already
* bounded in count and length by FirehoseSignalSource; empty for the low-disk path. */
private static String formatPaths(List<String> paths) {
if (paths == null || paths.isEmpty()) return "";
return String.join(", ", paths);
}

/**
* Truncate the biggest {@code *.log} file anywhere under the box's {@code /var/log} to reclaim the
* space the runaway consumed (a real file that persists after its writer dies). Recurses
Expand All @@ -346,29 +364,61 @@ private static long reclaimRunawayLog(Context ctx) {
}

/**
* Warn the user that the box was stopped to protect the device. Best-effort: a no-op if the
* POST_NOTIFICATIONS permission is not granted (API 33+). The teardown still happened.
* Tell the user the guard acted -- it stopped the box (escalation) or it reaped and kept the system
* alive (firehose) -- and offer a report the user sends. The tap opens the app with the pre-filled
* diagnostic (K2GO-391). Best-effort: a no-op if POST_NOTIFICATIONS is not granted (API 33+); the
* containment already happened. Each event kind passes its own notifId so one does not replace the
* other.
*/
private static void notifyUser(Context ctx) {
private static void notifyUser(Context ctx, int notifId, String title, String body, String reportMessage) {
try {
NotificationManager nm = (NotificationManager) ctx.getSystemService(Context.NOTIFICATION_SERVICE);
if (nm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(new NotificationChannel(
CHANNEL_ID, ctx.getString(R.string.disk_guard_notif_title),
NotificationManager.IMPORTANCE_HIGH));
}
// K2GO-391 / ADR-386 section 12: the guard runs in a background service, so it cannot launch
// the feedback email itself. The tap opens the app with the pre-filled diagnostic; the app
// (which has an Activity) hands it to the existing feedback flow.
Intent open = new Intent(ctx, org.appdevforall.k2go.redesign.LibraryActivity.class)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP
| Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra(org.appdevforall.k2go.redesign.LibraryActivity.EXTRA_DISK_GUARD_REPORT, reportMessage);
PendingIntent pi = PendingIntent.getActivity(ctx, notifId, open,
PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
Notification n = new NotificationCompat.Builder(ctx, CHANNEL_ID)
.setContentTitle(ctx.getString(R.string.disk_guard_notif_title))
.setContentText(ctx.getString(R.string.disk_guard_notif_body))
.setStyle(new NotificationCompat.BigTextStyle()
.bigText(ctx.getString(R.string.disk_guard_notif_body)))
.setContentTitle(title)
.setContentText(body)
.setStyle(new NotificationCompat.BigTextStyle().bigText(body))
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setContentIntent(pi)
.setAutoCancel(true)
.build();
NotificationManagerCompat.from(ctx).notify(NOTIF_ID, n);
NotificationManagerCompat.from(ctx).notify(notifId, n);
} catch (Exception e) {
Log.w(TAG, "K2GO-386: could not post the disk-guard notification", e);
}
}

/**
* The pre-filled body handed to the feedback email (K2GO-391): the disk-guard facts, plain and short.
* The feedback flow adds the standard envelope (app version, build, device, ABI, ...), so this only
* carries what the guard knows. English on purpose (it lands in a dev inbox / triage).
*/
private static String buildReportMessage(Context ctx, String source, String action, boolean reaped,
long reclaimed, int count, List<String> paths) {
Long free = StorageProbe.freeBytes(ctx);
String pathsStr = formatPaths(paths);
StringBuilder sb = new StringBuilder("K2Go disk guard acted.\n")
.append("source: ").append(source).append('\n') // low_disk | firehose_signal | debug
.append("action: ").append(action).append('\n')
.append("reaped: ").append(reaped).append('\n')
.append("reclaimed_bytes: ").append(reclaimed).append('\n')
.append("trip_or_streak: ").append(count).append('\n')
.append("free_bytes_now: ").append(free == null ? "unknown" : String.valueOf(free)).append('\n');
if (!pathsStr.isEmpty()) sb.append("firehose_paths: ").append(pathsStr).append('\n');
return sb.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,16 @@

import org.appdevforall.k2go.config.BoxEndpoints;
import org.appdevforall.k2go.diskguard.domain.FirehoseSignal;
import org.json.JSONArray;
import org.json.JSONObject;

import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;

/**
* Reads {@code GET /k2go-api/system/disk-guard/firehose} -> a {@link FirehoseSignal}, or {@code null}
Expand All @@ -37,6 +40,9 @@ public final class FirehoseSignalSource {
private static final String URL_PATH = BoxEndpoints.API + "/system/disk-guard/firehose";
private static final int TIMEOUT_MS = 4000;
private static final int MAX_BYTES = 8 * 1024; // a handful of fields; refuse the absurd
// Bound the paths so a pathological signal cannot bloat the report (paths are short by nature).
private static final int MAX_PATHS = 10;
private static final int MAX_PATH_LEN = 200;

private FirehoseSignalSource() {}

Expand All @@ -50,13 +56,26 @@ public static FirehoseSignal read() {
o.optBoolean("recurring", false),
o.optInt("maxStreak", 0),
o.optLong("lastTruncatedAtMs", 0L),
o.optLong("now", 0L));
o.optLong("now", 0L),
parsePaths(o.optJSONArray("paths")));
} catch (Exception e) {
Log.i(TAG, "K2GO-386: firehose signal read failed: " + e.getMessage());
return null;
}
}

/** Parse the firehosing paths, bounded in count and length. Never carries log content. */
private static List<String> parsePaths(JSONArray arr) {
List<String> out = new ArrayList<>();
if (arr == null) return out;
for (int i = 0; i < arr.length() && out.size() < MAX_PATHS; i++) {
String p = arr.optString(i, "");
if (p.isEmpty()) continue;
out.add(p.length() > MAX_PATH_LEN ? p.substring(0, MAX_PATH_LEN) : p);
}
return out;
}

private static String httpGet(String urlStr) throws Exception {
HttpURLConnection c = (HttpURLConnection) new URL(urlStr).openConnection();
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,26 +11,35 @@
*/
package org.appdevforall.k2go.diskguard.domain;

import java.util.Collections;
import java.util.List;

/**
* A recurring firehose means the in-box guard truncated a runaway log on several consecutive ticks:
* an off-proot orphan the box cannot stop. This signal is the app's ALERT to look; it is NOT a command
* to reap. The app re-probes live log growth before it acts (ADR-386 §6, confirm before acting).
*
* <p>{@code nowMs} and {@code lastTruncatedAtMs} are both the dash-node wall-clock, so freshness is
* judged in the server's own time frame -- no app-vs-server clock skew.
*
* <p>{@code paths} are the firehosing log paths the server reported (which log is the culprit), for the
* report. They are just short path strings -- never log content -- and the data source caps them.
*/
public final class FirehoseSignal {

public final boolean recurring;
public final int maxStreak;
public final long lastTruncatedAtMs; // server wall-clock of the last truncation, or 0 if never
public final long nowMs; // server wall-clock when it answered
public final List<String> paths; // firehosing log paths (bounded by the data source)

public FirehoseSignal(boolean recurring, int maxStreak, long lastTruncatedAtMs, long nowMs) {
public FirehoseSignal(boolean recurring, int maxStreak, long lastTruncatedAtMs, long nowMs,
List<String> paths) {
this.recurring = recurring;
this.maxStreak = maxStreak;
this.lastTruncatedAtMs = lastTruncatedAtMs;
this.nowMs = nowMs;
this.paths = paths == null ? Collections.emptyList() : paths;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public class LibraryActivity extends AppCompatActivity implements ServerControll
private static final long NO_SYSTEM_GATE_MS = 900L;
/** Set by the Setup "Download" so the gate waits for the install to finish, not a timeout. */
public static final String EXTRA_INSTALLING = "installing";
// K2GO-391: the disk guard's notification opens this activity with a pre-filled report to send.
public static final String EXTRA_DISK_GUARD_REPORT = "disk_guard_report";
/** ADFA-4777: preselect a bottom-nav tab on launch (e.g. from the wizard's "Copy from a phone"). */
public static final String EXTRA_TAB = "tab";
/**
Expand Down Expand Up @@ -877,6 +879,27 @@ protected void onResume() {
if (serverController != null) serverController.onResume();
if (updateController != null) updateController.registerDownloadReceiver();
maybeAutoCheckUpdate(); // ADFA-4984: deferred until the boot gate has opened
maybeStartDiskGuardReport(); // K2GO-391
}

/**
* K2GO-391 / ADR-386 section 12: the disk guard runs in a background service and cannot launch the
* feedback email itself, so its notification opens this activity carrying a pre-filled report. Hand it
* to the existing feedback flow here (onResume covers both a fresh start and a tap onto the running
* app). Consume the extra so a later resume -- rotation, returning from another screen -- never
* re-fires it. Posted so the screenshot capture runs after the view is laid out.
*/
private void maybeStartDiskGuardReport() {
Intent i = getIntent();
if (i == null) return;
String msg = i.getStringExtra(EXTRA_DISK_GUARD_REPORT);
if (msg == null || msg.isEmpty()) return;
i.removeExtra(EXTRA_DISK_GUARD_REPORT);
setIntent(i);
getWindow().getDecorView().post(() ->
org.appdevforall.k2go.feedback.presentation.FeedbackFab.sendFeedback(
this, "disk-guard",
org.appdevforall.k2go.feedback.domain.FeedbackType.BUG, msg));
}

@Override
Expand Down
6 changes: 4 additions & 2 deletions controller/app/src/main/res/values/strings_untranslated.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
-->
<resources>

<!-- K2GO-386: the app-side free-space guard's alert when it stops the box to avoid ENOSPC. -->
<!-- K2GO-386 / K2GO-391: the app-side disk guard's alerts, each inviting a user-sent report. -->
<string name="disk_guard_notif_title" translatable="false">Storage critically low</string>
<string name="disk_guard_notif_body" translatable="false">The server was stopped to protect your device from running out of space.</string>
<string name="disk_guard_notif_body" translatable="false">The server was stopped to protect your device from running out of space. Tap to send a report.</string>
<string name="disk_guard_firehose_title" translatable="false">K2Go contained unusual activity</string>
<string name="disk_guard_firehose_body" translatable="false">K2Go handled a process that was using too much storage and kept your system running. Tap to send a report.</string>

</resources>
Loading
Loading