diff --git a/controller/app/src/main/AndroidManifest.xml b/controller/app/src/main/AndroidManifest.xml
index 6c3bd9c3e..cba273c96 100644
--- a/controller/app/src/main/AndroidManifest.xml
+++ b/controller/app/src/main/AndroidManifest.xml
@@ -73,12 +73,13 @@
the IME. singleTop lets the keep-alive notification's SINGLE_TOP intent reuse the running
instance. Not exported: launched only in-app (redesign Settings, the terminal's own
notification PendingIntent). -->
+
+ android:configChanges="orientation|screenSize|screenLayout|keyboardHidden" />
{
if (!navSyncing) {
currentTab = item.getItemId();
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/ui/EdgeToEdge.java b/controller/app/src/main/java/org/appdevforall/k2go/ui/EdgeToEdge.java
new file mode 100644
index 000000000..379223031
--- /dev/null
+++ b/controller/app/src/main/java/org/appdevforall/k2go/ui/EdgeToEdge.java
@@ -0,0 +1,87 @@
+/*
+ * ============================================================================
+ * Name : EdgeToEdge.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : Shared system-bar inset handling (K2GO-439). targetSdk 35 forces
+ * edge-to-edge on Android 15+, so app content draws behind the
+ * status bar and the navigation bar. Each screen pads the right
+ * views by the system-bar (and display-cutout) insets so content
+ * keeps clear of the bars. Single source for the rule; the call
+ * site is per-screen because the activities share no base class.
+ * See https://developer.android.com/develop/ui/views/layout/edge-to-edge
+ * ============================================================================
+ */
+package org.appdevforall.k2go.ui;
+
+import android.view.View;
+
+import androidx.core.graphics.Insets;
+import androidx.core.view.ViewCompat;
+import androidx.core.view.WindowInsetsCompat;
+
+/** Stateless helper that pads a view by the system-bar insets, preserving its original padding. */
+public final class EdgeToEdge {
+
+ private EdgeToEdge() {}
+
+ /**
+ * Activities that manage their own system-bar insets, that want none (a full-screen splash),
+ * or whose layout already consumes insets (android:fitsSystemWindows, or an inset listener of
+ * their own) implement this to opt out of the global {@link EdgeToEdgeCallbacks} handler, so the
+ * insets are never applied twice.
+ */
+ public interface SelfManaged {}
+
+ /** Pad the view's top by the status-bar (and top cutout) inset. */
+ public static void padTop(View v) { apply(v, true, false, false, false, false); }
+
+ /** Pad the view's bottom by the navigation-bar inset. */
+ public static void padBottom(View v) { apply(v, false, false, false, true, false); }
+
+ /** Pad the view on every system-bar edge (top, bottom, and the sides for cutouts/landscape). */
+ public static void padAll(View v) { apply(v, true, true, true, true, false); }
+
+ /**
+ * Like {@link #padAll} but the bottom edge also clears the soft keyboard (IME), so a focused
+ * field is not hidden. Use for a screen's own content, not for a bottom bar that should stay put.
+ */
+ public static void padAllAndIme(View v) { apply(v, true, true, true, true, true); }
+
+ private static void apply(final View v, final boolean top, final boolean left,
+ final boolean right, final boolean bottom, final boolean ime) {
+ if (v == null) return;
+ // Capture the view's own padding once so repeated inset passes (e.g. rotation) never accumulate.
+ final int pl = v.getPaddingLeft();
+ final int pt = v.getPaddingTop();
+ final int pr = v.getPaddingRight();
+ final int pb = v.getPaddingBottom();
+ ViewCompat.setOnApplyWindowInsetsListener(v, (view, insets) -> {
+ int mask = WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout();
+ if (ime) mask |= WindowInsetsCompat.Type.ime();
+ Insets bars = insets.getInsets(mask);
+ int[] p = resolvePadding(pl, pt, pr, pb, bars.left, bars.top, bars.right, bars.bottom,
+ top, left, right, bottom);
+ view.setPadding(p[0], p[1], p[2], p[3]);
+ return insets;
+ });
+ // Insets may have already been delivered before the listener was attached; request a fresh pass.
+ ViewCompat.requestApplyInsets(v);
+ }
+
+ /**
+ * Pure rule: the new [left, top, right, bottom] padding = the original padding plus the inset on
+ * each selected edge. Adds to the captured original (never to the already-padded value), so an
+ * inset pass is idempotent. Package-private for unit testing.
+ */
+ static int[] resolvePadding(int pl, int pt, int pr, int pb,
+ int il, int it, int ir, int ib,
+ boolean top, boolean left, boolean right, boolean bottom) {
+ return new int[]{
+ left ? pl + il : pl,
+ top ? pt + it : pt,
+ right ? pr + ir : pr,
+ bottom ? pb + ib : pb,
+ };
+ }
+}
diff --git a/controller/app/src/main/java/org/appdevforall/k2go/ui/EdgeToEdgeCallbacks.java b/controller/app/src/main/java/org/appdevforall/k2go/ui/EdgeToEdgeCallbacks.java
new file mode 100644
index 000000000..1a7188ec3
--- /dev/null
+++ b/controller/app/src/main/java/org/appdevforall/k2go/ui/EdgeToEdgeCallbacks.java
@@ -0,0 +1,43 @@
+/*
+ * ============================================================================
+ * Name : EdgeToEdgeCallbacks.java
+ * Author : AppDevForAll
+ * Copyright : Copyright (c) 2026 AppDevForAll
+ * Description : One owner for edge-to-edge insets (K2GO-439). Registered from
+ * IIABApplication, it pads every activity's content by the system
+ * bars (and the IME) so nothing draws under the status / navigation
+ * bars or the keyboard on targetSdk 35 (Android 15+). Screens that
+ * manage their own insets (LibraryActivity), whose layout already
+ * consumes them (HelpViewerActivity via fitsSystemWindows), or want
+ * none (SplashActivity) opt out via EdgeToEdge.SelfManaged.
+ * PortalActivity stays in: its immersive mode zeroes the insets when
+ * it hides the bars, so the one handler covers both of its states.
+ * ============================================================================
+ */
+package org.appdevforall.k2go.ui;
+
+import android.app.Activity;
+import android.app.Application;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+/** Applies system-bar and IME insets to each activity's content view, except opted-out screens. */
+public final class EdgeToEdgeCallbacks implements Application.ActivityLifecycleCallbacks {
+
+ @Override
+ public void onActivityCreated(@NonNull Activity a, @Nullable Bundle savedInstanceState) {
+ if (a instanceof EdgeToEdge.SelfManaged) return;
+ // android.R.id.content is the frame that holds setContentView's view; padding it insets the screen.
+ // These screens have no bottom bar, so clearing the keyboard (IME) on the bottom is safe.
+ EdgeToEdge.padAllAndIme(a.findViewById(android.R.id.content));
+ }
+
+ @Override public void onActivityStarted(@NonNull Activity a) {}
+ @Override public void onActivityResumed(@NonNull Activity a) {}
+ @Override public void onActivityPaused(@NonNull Activity a) {}
+ @Override public void onActivityStopped(@NonNull Activity a) {}
+ @Override public void onActivitySaveInstanceState(@NonNull Activity a, @NonNull Bundle outState) {}
+ @Override public void onActivityDestroyed(@NonNull Activity a) {}
+}
diff --git a/controller/app/src/test/java/org/appdevforall/k2go/ui/EdgeToEdgeTest.java b/controller/app/src/test/java/org/appdevforall/k2go/ui/EdgeToEdgeTest.java
new file mode 100644
index 000000000..7ee0d599b
--- /dev/null
+++ b/controller/app/src/test/java/org/appdevforall/k2go/ui/EdgeToEdgeTest.java
@@ -0,0 +1,48 @@
+package org.appdevforall.k2go.ui;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import org.junit.Test;
+
+/**
+ * Unit tests for {@link EdgeToEdge#resolvePadding} (K2GO-439): the pure rule that adds the selected
+ * inset edges to the view's original padding. Padding is [left, top, right, bottom].
+ */
+public class EdgeToEdgeTest {
+
+ @Test
+ public void padAllAddsEveryEdgeToTheOriginal() {
+ assertArrayEquals(new int[]{15, 25, 35, 45},
+ EdgeToEdge.resolvePadding(5, 5, 5, 5, 10, 20, 30, 40,
+ true, true, true, true));
+ }
+
+ @Test
+ public void padTopAddsOnlyTheTopEdge() {
+ assertArrayEquals(new int[]{5, 25, 5, 5},
+ EdgeToEdge.resolvePadding(5, 5, 5, 5, 10, 20, 30, 40,
+ true, false, false, false));
+ }
+
+ @Test
+ public void padBottomAddsOnlyTheBottomEdge() {
+ assertArrayEquals(new int[]{5, 5, 5, 45},
+ EdgeToEdge.resolvePadding(5, 5, 5, 5, 10, 20, 30, 40,
+ false, false, false, true));
+ }
+
+ @Test
+ public void noEdgesKeepsTheOriginalPadding() {
+ assertArrayEquals(new int[]{5, 5, 5, 5},
+ EdgeToEdge.resolvePadding(5, 5, 5, 5, 10, 20, 30, 40,
+ false, false, false, false));
+ }
+
+ @Test
+ public void addsToTheOriginalNotToZero() {
+ // The original padding is preserved and the inset is added on top (not replaced).
+ assertArrayEquals(new int[]{100, 107, 100, 100},
+ EdgeToEdge.resolvePadding(100, 100, 100, 100, 1, 7, 3, 9,
+ true, false, false, false));
+ }
+}