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)); + } +}