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
25 changes: 25 additions & 0 deletions controller/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,31 @@ task refreshKolibriCatalog {
}
}

// ADFA-5361: `connectedAndroidTest` UNINSTALLS both APKs when it finishes. On a device that holds an
// installed rootfs, uninstalling the app deletes its data directory — that is, the whole box. Observed:
// one run wiped an installed system on a test device. The instrumentation tests are worth keeping, so
// the fix is to refuse the destructive entry point rather than to write a rule someone has to remember.
// Install and run explicitly instead (this installs, runs, and leaves both APKs in place):
// ./gradlew :app:installDebug :app:installDebugAndroidTest
// adb shell am instrument -w -e class <TestClass> org.iiab.controller.test/androidx.test.runner.AndroidJUnitRunner
// A device with nothing to lose can still opt in with -PallowUninstall=true.
tasks.configureEach { t ->
if (t.name.startsWith('connected') && t.name.endsWith('AndroidTest')) {
t.doFirst {
if (!project.hasProperty('allowUninstall')) {
throw new GradleException(
"Refusing to run ${t.name}: it uninstalls the app when it finishes, which on a "
+ "device holding an installed rootfs destroys the box (ADFA-5361).\n"
+ " Run instead:\n"
+ " ./gradlew :app:installDebug :app:installDebugAndroidTest\n"
+ " adb shell am instrument -w -e class <TestClass> \\\n"
+ " org.iiab.controller.test/androidx.test.runner.AndroidJUnitRunner\n"
+ " Or, if this device has nothing to lose: -PallowUninstall=true")
}
}
}
}

// ADFA-4466 / build resilience: apply the Firebase google-services plugin only when its config file is
// present. Without it the plugin fails the build; skipping it lets contributors build a working APK with
// analytics compiled out (BuildConfig.ANALYTICS_ENABLED = false). Applied at the bottom, as the plugin
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package org.iiab.controller.portal;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;

import android.webkit.CookieManager;

import androidx.test.ext.junit.runners.AndroidJUnit4;

import org.iiab.controller.portal.domain.AutoLoginPolicy;
import org.iiab.controller.portal.domain.SessionCookies;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

/**
* ADFA-5361: on-device proof that the reconcile directives do what the pure tests describe.
* The JVM tests pin the STRINGS; only the real WebView cookie store can say whether
* "Max-Age=0" at a given Path actually deletes, and whether a deeper-path copy outranks the
* one we install — which is the mechanism that turned one guest page load into a permanent
* guest session.
*
* <p>Uses a test-only origin so it never touches the box's real cookies.
*/
@RunWith(AndroidJUnit4.class)
public class SessionCookieReconcileTest {

private static final String ORIGIN = "http://cookie-reconcile.test/";
private static final String FRESH = "session=fresh-admin; remember_token=7|fresh";

private CookieManager cm;

private void wipe() {
for (String name : new String[]{"session", "remember_token"}) {
for (String path : new String[]{"/", "/books", "/books/"}) {
cm.setCookie(ORIGIN, name + "=; Path=" + path + "; Max-Age=0");
}
}
cm.flush();
}

@Before public void setUp() {
cm = CookieManager.getInstance();
cm.setAcceptCookie(true);
wipe();
}

@After public void tearDown() {
wipe();
}

/** The stale copy the old code could never replace: same name, deeper path. */
@Test public void deeperPathCookieOutranksTheRootOne() {
cm.setCookie(ORIGIN, "session=stale-guest; path=/books");
cm.setCookie(ORIGIN, "session=fresh-admin; path=/");
cm.flush();

String header = cm.getCookie(ORIGIN + "books/");
assertTrue("both copies are sent: " + header, header.contains("stale-guest"));
assertTrue("both copies are sent: " + header, header.contains("fresh-admin"));
// The service reads the first "session" it is given, and that is the stale one.
assertTrue("stale copy is served first: " + header,
header.indexOf("stale-guest") < header.indexOf("fresh-admin"));
}

/** The fix: clearing every candidate path first leaves only what we just installed. */
@Test public void clearThenSetLeavesOnlyTheFreshSession() {
cm.setCookie(ORIGIN, "session=stale-guest; path=/books");
cm.setCookie(ORIGIN, "session=stale-guest; path=/books/");
cm.setCookie(ORIGIN, "remember_token=1|stale; path=/books");
cm.flush();

String prefix = AutoLoginPolicy.prefixFor("http://localhost:8085/books/book/12");
assertEquals("/books", prefix);
for (String directive : SessionCookies.clearDirectives(FRESH, prefix)) {
cm.setCookie(ORIGIN, directive);
}
for (String directive : SessionCookies.setDirectives(FRESH)) {
cm.setCookie(ORIGIN, directive);
}
cm.flush();

String header = cm.getCookie(ORIGIN + "books/book/12");
assertFalse("stale session survived: " + header, header.contains("stale-guest"));
assertFalse("stale remember_token survived: " + header, header.contains("1|stale"));
assertTrue("fresh session missing: " + header, header.contains("session=fresh-admin"));
assertTrue("fresh remember_token missing: " + header, header.contains("remember_token=7|fresh"));
}

/** Max-Age=0 must delete at the exact path it names, not only at the root. */
@Test public void clearDeletesAtEveryNamedPath() {
cm.setCookie(ORIGIN, "session=at-root; path=/");
cm.setCookie(ORIGIN, "session=at-prefix; path=/books");
cm.setCookie(ORIGIN, "session=at-prefix-slash; path=/books/");
cm.flush();

for (String directive : SessionCookies.clearDirectives("session=x", "/books")) {
cm.setCookie(ORIGIN, directive);
}
cm.flush();

String header = cm.getCookie(ORIGIN + "books/");
assertTrue("expected no session cookie left, got: " + header,
header == null || !header.contains("session="));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,13 @@
import android.os.Handler;
import android.os.Looper;

import org.iiab.controller.portal.domain.AutoLoginPolicy;
import org.iiab.controller.portal.domain.NavigationPolicy;
import org.iiab.controller.portal.domain.PdfPolicy;
import org.iiab.controller.portal.domain.PdfViewerUrl;
import org.iiab.controller.portal.domain.PdfViewerBuild;
import org.iiab.controller.portal.domain.PdfViewerRouter;
import org.iiab.controller.portal.domain.SessionCookies;
import org.iiab.controller.portal.domain.WebViewVersion;
import org.iiab.controller.portal.data.PdfViewerCatalog;
import org.iiab.controller.util.AppExecutors;
Expand Down Expand Up @@ -372,8 +374,11 @@ public boolean onConsoleMessage(android.webkit.ConsoleMessage consoleMessage) {
// ADFA-5043: Books (Calibre-Web) / Courses (Kolibri) auto-login as box admin — fetch a session
// cookie, inject it into the WebView CookieManager, THEN load, so the card opens already
// authenticated. Degrades gracefully: if the service isn't installed/ready, just load without it.
String authService = getIntent().getStringExtra("AUTH_SERVICE");
if (authService != null && !authService.isEmpty()) {
// ADFA-5361: the service is DERIVED from the URL, not passed in by the caller. As an Intent
// extra it was a fact each launcher had to remember, and two of the three did not — those
// opened Calibre-Web unauthenticated and left a guest session in the shared cookie jar.
String authService = AutoLoginPolicy.serviceFor(finalTargetUrl);
if (authService != null) {
autoLoginThenLoad(authService, finalTargetUrl);
} else {
// Native architecture: content is served locally; load it directly.
Expand All @@ -385,15 +390,29 @@ public boolean onConsoleMessage(android.webkit.ConsoleMessage consoleMessage) {
* failure (service absent/not ready) load without a cookie — the card still opens. */
private void autoLoginThenLoad(String service, String targetUrl) {
showAuthOverlay();
org.iiab.controller.redesign.AuthClient.session(service, new org.iiab.controller.redesign.AuthClient.SessionCb() {
// ADFA-5361: the box must mint the session FOR THIS WebView. Calibre-Web binds a session to a
// fingerprint of the User-Agent, so one minted under the box's own agent is rejected on the
// first request here and the card opens as the anonymous Guest. Read verbatim from the very
// WebView that will present it — its only job is to match.
String consumerUa = webView.getSettings().getUserAgentString();
org.iiab.controller.redesign.AuthClient.session(service, consumerUa, new org.iiab.controller.redesign.AuthClient.SessionCb() {
@Override public void onOk(String cookie) {
if (isFinishing() || isDestroyed()) return; // ADFA-5043: left mid-sign-in; don't touch dead views
android.webkit.CookieManager cm = android.webkit.CookieManager.getInstance();
cm.setAcceptCookie(true);
// ADFA-5361: clear before set, so the jar holds the session we just minted and nothing
// else. Setting alone appends: a same-name cookie at a deeper path (the box fronts the
// service under a prefix) is never overwritten and outranks ours in the Cookie header,
// which is how one guest page load became a permanent guest session. Clear and set are
// one operation — a failed sign-in leaves the jar untouched (see onErr), so a still-valid
// remember-me from an earlier open is not thrown away over a transient failure.
String prefix = AutoLoginPolicy.prefixFor(targetUrl);
for (String directive : SessionCookies.clearDirectives(cookie, prefix)) {
cm.setCookie(BoxEndpoints.BASE + "/", directive);
}
// Host-wide on the box origin so every request under the service prefix carries it.
for (String pair : cookie.split(";")) {
String p = pair.trim();
if (!p.isEmpty()) cm.setCookie(BoxEndpoints.BASE + "/", p + "; path=/");
for (String directive : SessionCookies.setDirectives(cookie)) {
cm.setCookie(BoxEndpoints.BASE + "/", directive);
}
cm.flush();
hideAuthOverlay();
Expand All @@ -402,6 +421,11 @@ private void autoLoginThenLoad(String service, String targetUrl) {
@Override public void onErr() {
if (isFinishing() || isDestroyed()) return; // ADFA-5043: left mid-sign-in; don't touch dead views
hideAuthOverlay();
// ADFA-5361: say so. The page still opens — read-only, as the anonymous guest — but
// silently landing there is what made "I can't manage my own books" look like a
// permissions mystery instead of a sign-in that did not happen. Reuses the string the
// Authentication screen already shows for this same fact (no new l10n).
Toast.makeText(PortalActivity.this, R.string.k2go_auth_load_failed, Toast.LENGTH_LONG).show();
webView.loadUrl(targetUrl);
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
* ============================================================================
* Name : AutoLoginPolicy.java
* Author : AppDevForAll
* Copyright : Copyright (c) 2026 AppDevForAll
* Description : ADFA-5361. Decides, from the URL alone, whether a portal page opens as the box
* admin (Calibre-Web "books" / Kolibri "courses") and under which path prefix the
* box fronts it. Single owner of that fact: ADFA-5043 kept it in a private method of
* one fragment and carried it as an Intent extra, so the two call sites that did not
* pass the extra opened Calibre-Web unauthenticated — which planted a guest session
* in the shared WebView cookie jar. Deriving it here means no call site can forget.
* Pure (no android.*), so it is JVM-unit-testable.
* ============================================================================
*/
package org.iiab.controller.portal.domain;

/**
* Maps a portal target URL to the service whose admin session the WebView should carry.
*
* <p>The service names are the ones the box's credential store uses
* ({@code /k2go-api/auth/&lt;service&gt;/session}), which are NOT the URL segments: Calibre-Web is
* served at {@code /books} but is called {@code calibre}. The path prefix is returned separately
* because the cookie work needs it (see {@link SessionCookies}).
*/
public final class AutoLoginPolicy {

/** Box credential-store service names. */
public static final String CALIBRE = "calibre";
public static final String KOLIBRI = "kolibri";

private AutoLoginPolicy() {}

/**
* The auto-login service for {@code url}, or {@code null} when the page needs no admin
* session. Only pages served by the local box qualify — the session cookie is an admin
* credential and must never be minted for, or injected against, an external host.
*/
public static String serviceFor(String url) {
String segment = firstSegment(url);
if (segment == null) return null;
if (segment.equals("books")) return CALIBRE;
if (segment.equals("kolibri")) return KOLIBRI;
return null;
}

/**
* The path prefix the box fronts the service under (e.g. {@code "/books"}), or {@code null}
* when the URL has no auto-login service. Derived from the same segment as
* {@link #serviceFor(String)} so the two can never disagree.
*/
public static String prefixFor(String url) {
if (serviceFor(url) == null) return null;
return "/" + firstSegment(url);
}

/**
* First path segment of an internal-host URL, lowercased; {@code null} if the URL is unusable,
* points at an external host, or has no path segment.
*
* <p>Hand-parsed rather than via {@code android.net.Uri} because this layer stays pure.
*/
private static String firstSegment(String url) {
if (url == null) return null;
String s = url.trim();
if (s.isEmpty()) return null;

int schemeEnd = s.indexOf("://");
if (schemeEnd < 0) return null; // relative URLs never reach the portal
int authorityStart = schemeEnd + 3;

int pathStart = indexOfAny(s, authorityStart, '/', '?', '#');
String authority = pathStart < 0 ? s.substring(authorityStart) : s.substring(authorityStart, pathStart);
if (!NavigationPolicy.isInternalHost(hostOf(authority))) return null;
if (pathStart < 0 || s.charAt(pathStart) != '/') return null; // no path at all

String path = s.substring(pathStart + 1);
int cut = indexOfAny(path, 0, '/', '?', '#');
if (cut >= 0) path = path.substring(0, cut);
path = path.trim().toLowerCase();
return path.isEmpty() ? null : path;
}

/** Host of an {@code authority} ({@code user@host:port}), without credentials or port. */
private static String hostOf(String authority) {
String a = authority;
int at = a.lastIndexOf('@');
if (at >= 0) a = a.substring(at + 1);
int colon = a.lastIndexOf(':');
if (colon >= 0) a = a.substring(0, colon);
return a;
}

/** First index at or after {@code from} of any of {@code chars}, or -1. */
private static int indexOfAny(String s, int from, char... chars) {
for (int i = from; i < s.length(); i++) {
for (char c : chars) {
if (s.charAt(i) == c) return i;
}
}
return -1;
}
}
Loading
Loading