diff --git a/iiab-android b/iiab-android
index 451e4ac4..0f9c7079 100644
--- a/iiab-android
+++ b/iiab-android
@@ -332,6 +332,30 @@ local current_pwd="$(pwd)"
fi
}
+# Pre-seed /opt/iiab/iiab and apply AppDevForAll's in-flight upstream patches
+# BEFORE the generic installer runs Ansible. Safe because the upstream installer
+# (/usr/sbin/iiab from iiab-factory) clones iiab only if it is absent and never
+# pulls/resets an existing checkout, so our patched tree survives into the run
+# (e.g. the kolibri role reads its .patch during it). The applier is idempotent
+# and exits non-zero on context drift, so a bad carry fails the build loudly
+# instead of baking a half-patched tree. See tools/upstream-patches/README.md.
+apply_upstream_patches() {
+ local applier="${K2GO_OPT_DIR}/tools/upstream-patches/apply-upstream-patches.sh"
+ if [ ! -f "$applier" ]; then
+ log "No upstream-patches applier found; skipping."
+ return 0
+ fi
+ command -v git > /dev/null 2>&1 || apt-get -y install git
+ command -v patch > /dev/null 2>&1 || apt-get -y install patch
+ mkdir -p /opt/iiab
+ if [ ! -d /opt/iiab/iiab ]; then
+ log "Pre-seeding /opt/iiab/iiab so upstream patches apply before Ansible..."
+ git clone https://github.com/iiab/iiab /opt/iiab/iiab || die "could not clone iiab/iiab to pre-seed upstream patches"
+ fi
+ log "Applying in-flight upstream patches to /opt/iiab/iiab..."
+ bash "$applier" --root /opt/iiab/iiab || die "upstream patch apply failed (see log above)"
+}
+
#-----------------------------
# Demo Contents Installer
#-----------------------------
@@ -533,6 +557,11 @@ disable_role_32bits kiwix "$LOCAL_VARS_DEST"
#-----------------------------
ensure_proot_safe_env
+#-----------------------------
+# Apply our in-flight upstream patches to /opt/iiab/iiab before Ansible runs
+#-----------------------------
+apply_upstream_patches
+
#-----------------------------
# Fetch install.txt with fallback and run it
#-----------------------------
diff --git a/tools/upstream-patches/README.md b/tools/upstream-patches/README.md
new file mode 100755
index 00000000..61ba4587
Binary files /dev/null and b/tools/upstream-patches/README.md differ
diff --git a/tools/upstream-patches/apply-upstream-patches.sh b/tools/upstream-patches/apply-upstream-patches.sh
new file mode 100755
index 00000000..1cc01495
--- /dev/null
+++ b/tools/upstream-patches/apply-upstream-patches.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+#
+# apply-upstream-patches.sh — apply AppDevForAll's in-flight changes to the
+# IIAB project checkout (/opt/iiab/iiab) that are proposed upstream but not yet
+# merged. Idempotent: a patch that is already present (e.g. upstream merged it,
+# or a previous run applied it) is detected and skipped, never applied twice.
+#
+# Design & conventions: see README.md in this folder.
+#
+# Usage (run inside the proot guest, after /opt/iiab/iiab exists and BEFORE the
+# Ansible roles run):
+# tools/upstream-patches/apply-upstream-patches.sh [--root DIR] [--dry-run]
+#
+# Exit codes: 0 = all patches applied or already-present; 1 = one or more
+# patches could not be applied (context drift → needs regeneration). We fail
+# loudly rather than bake a half-patched tree.
+
+set -euo pipefail
+
+ROOT="/opt/iiab/iiab" # the IIAB project checkout to patch
+DRY_RUN=0
+STRIP=1 # patches are generated relative to the repo root → -p1
+
+SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PATCH_DIR="${SELF_DIR}/patches"
+OVERLAY_DIR="${SELF_DIR}/overlays" # optional: whole-file replacements
+
+log() { printf '[upstream-patches] %s\n' "$*"; }
+warn() { printf '[upstream-patches] WARN: %s\n' "$*" >&2; }
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --root) ROOT="$2"; shift 2 ;;
+ --dry-run) DRY_RUN=1; shift ;;
+ -h|--help) grep '^#' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
+ *) warn "unknown arg: $1"; exit 2 ;;
+ esac
+done
+
+[[ -d "$ROOT" ]] || { warn "target root not found: $ROOT (nothing to patch)"; exit 0; }
+command -v patch >/dev/null 2>&1 || { warn "'patch' not installed in the guest"; exit 1; }
+
+rc=0
+applied=0 skipped=0 failed=0
+
+# --- 1) Unified-diff patches (the primary mechanism) ------------------------
+if [[ -d "$PATCH_DIR" ]]; then
+ # Deterministic order: NNNN- numeric prefix. *.patch only (templates use .txt).
+ shopt -s nullglob
+ for f in "$PATCH_DIR"/*.patch; do
+ name="$(basename "$f")"
+ # Already present? (upstream merged it, or a prior run applied it.) A clean
+ # REVERSE apply proves the change is already in the tree → skip.
+ if patch -p"$STRIP" -d "$ROOT" -R --dry-run < "$f" >/dev/null 2>&1; then
+ log "skip (already present): $name"
+ skipped=$((skipped+1))
+ continue
+ fi
+ # Not present → does it apply cleanly forward?
+ if patch -p"$STRIP" -d "$ROOT" --dry-run < "$f" >/dev/null 2>&1; then
+ if [[ "$DRY_RUN" -eq 1 ]]; then
+ log "would apply: $name"
+ else
+ patch -p"$STRIP" -d "$ROOT" < "$f" >/dev/null
+ log "applied: $name"
+ fi
+ applied=$((applied+1))
+ else
+ warn "could NOT apply (context drift / partially applied): $name"
+ warn " → regenerate against the current /opt/iiab/iiab, or drop if superseded."
+ failed=$((failed+1)); rc=1
+ fi
+ done
+ shopt -u nullglob
+fi
+
+# --- 2) Optional whole-file overlays ----------------------------------------
+# For cases better expressed as a full-file replacement than a diff (e.g.
+# replacing a file that is itself a .patch). Mirror the tree under overlays/
+# rooted at the repo root; a file is copied only if it differs (idempotent).
+if [[ -d "$OVERLAY_DIR" ]]; then
+ while IFS= read -r -d '' src; do
+ rel="${src#"$OVERLAY_DIR"/}"
+ dst="$ROOT/$rel"
+ if [[ -f "$dst" ]] && cmp -s "$src" "$dst"; then
+ log "skip overlay (identical): $rel"; skipped=$((skipped+1)); continue
+ fi
+ if [[ "$DRY_RUN" -eq 1 ]]; then
+ log "would overlay: $rel"
+ else
+ mkdir -p "$(dirname "$dst")"
+ cp -a "$src" "$dst"
+ log "overlay: $rel"
+ fi
+ applied=$((applied+1))
+ done < <(find "$OVERLAY_DIR" -type f ! -name '.gitkeep' -print0 2>/dev/null)
+fi
+
+log "summary: applied=${applied} skipped=${skipped} failed=${failed} (root=${ROOT})"
+exit "$rc"
diff --git a/tools/upstream-patches/overlays/.gitkeep b/tools/upstream-patches/overlays/.gitkeep
new file mode 100755
index 00000000..0e669ecb
--- /dev/null
+++ b/tools/upstream-patches/overlays/.gitkeep
@@ -0,0 +1 @@
+# Optional whole-file overlays mirror the /opt/iiab/iiab tree here. See README.md.
diff --git a/tools/upstream-patches/patches/0000-EXAMPLE-template.patch.txt b/tools/upstream-patches/patches/0000-EXAMPLE-template.patch.txt
new file mode 100755
index 00000000..86383ae6
--- /dev/null
+++ b/tools/upstream-patches/patches/0000-EXAMPLE-template.patch.txt
@@ -0,0 +1,21 @@
+EXAMPLE / TEMPLATE — not applied (the applier only picks up *.patch, not *.txt).
+Copy this header, generate a real diff, and save as NNNN--.patch.
+
+Upstream-PR: https://github.com/iiab/iiab/pull/XXXX # or "not yet submitted"
+Upstream-Status: open # open | merged-pending-release | superseded
+Applies-to: roles//... # path within /opt/iiab/iiab
+Summary: one sober line: what changes and why
+
+# Generate (relative to the repo root so it applies with -p1 over /opt/iiab/iiab):
+# git -C /opt/iiab/iiab diff > 0001--.patch
+# # or, mirroring the upstream commit:
+# git -C /opt/iiab/iiab format-patch -1 --stdout > 0001--.patch
+
+diff --git a/roles//tasks/main.yml b/roles//tasks/main.yml
+index 0000000..0000000 100644
+--- a/roles//tasks/main.yml
++++ b/roles//tasks/main.yml
+@@ -1,3 +1,3 @@
+ # (example context)
+-old line
++new line
diff --git a/tools/upstream-patches/patches/0001-kolibri-trust-system-ca.patch b/tools/upstream-patches/patches/0001-kolibri-trust-system-ca.patch
new file mode 100755
index 00000000..6b8bb402
--- /dev/null
+++ b/tools/upstream-patches/patches/0001-kolibri-trust-system-ca.patch
@@ -0,0 +1,100 @@
+Upstream-PR: https://github.com/iiab/iiab/pull/4442
+Upstream-Status: open
+Applies-to: roles/proot_services/files/kolibri-00-prevent_ip_error_out_due_A15_restrictions.patch
+Summary: Kolibri: trust the system CA bundle so "Kolibri Studio (online)" import works under Android/proot
+
+Mirrors upstream commit bbf6cb8 ("[kolibri] update proot patch: trust system CA
+so online import works"). This edits the kolibri proot patch file that the
+proot_services role applies to the installed Kolibri package. If the pinned
+iiab/iiab commit's copy of that file drifts, the applier fails loudly and this
+should be regenerated. Remove once #4442 merges and ships in the pinned commit.
+
+diff --git a/roles/proot_services/files/kolibri-00-prevent_ip_error_out_due_A15_restrictions.patch b/roles/proot_services/files/kolibri-00-prevent_ip_error_out_due_A15_restrictions.patch
+index 2ab48703d5..53924dafe7 100644
+--- a/roles/proot_services/files/kolibri-00-prevent_ip_error_out_due_A15_restrictions.patch
++++ b/roles/proot_services/files/kolibri-00-prevent_ip_error_out_due_A15_restrictions.patch
+@@ -1,9 +1,10 @@
+ This patch is necessary on,
+
++* Android 16 - yes
+ * Android 15 - yes
+ * Android 14 - untested
+ * Android 13 - untested
+-* Android 12 - no
++* Android 12 - untested
+ * Android 11 - untested
+ * Andoird 10 - untested
+
+@@ -41,3 +42,72 @@ index a11b0e9..4b0b8b7 100644
+
+
+ def get_urls(listen_port=None):
++--- a/usr/lib/python3/dist-packages/kolibri/utils/http_session.py
+++++ b/usr/lib/python3/dist-packages/kolibri/utils/http_session.py
++@@ -19,6 +19,24 @@
++ class SameHostSession(requests.Session):
++ """A :class:`requests.Session` that refuses cross-host redirects."""
++
+++ def __init__(self, *args, **kwargs):
+++ super().__init__(*args, **kwargs)
+++ # IIAB/proot: prefer the system CA bundle for outbound HTTPS. Under
+++ # Android/proot the cert store requests bundles can be stale or absent
+++ # while the system store is valid, which made every content-server
+++ # request fail and disabled "Kolibri Studio (online)" despite internet.
+++ import os
+++
+++ for _ca in (
+++ os.environ.get("REQUESTS_CA_BUNDLE"),
+++ os.environ.get("SSL_CERT_FILE"),
+++ "/etc/ssl/certs/ca-certificates.crt",
+++ "/etc/ssl/cert.pem",
+++ ):
+++ if _ca and os.path.exists(_ca):
+++ self.verify = _ca
+++ break
+++
++ def get_redirect_target(self, resp):
++ target = super().get_redirect_target(resp)
++ if target is None:
++--- a/usr/lib/python3/dist-packages/kolibri/core/content/api.py
+++++ b/usr/lib/python3/dist-packages/kolibri/core/content/api.py
++@@ -2066,6 +2066,41 @@
++ NetworkLocationConnectionFailure,
++ NetworkLocationNotFound,
++ ):
+++ # IIAB/proot fallback: NetworkClient can wrongly report the content
+++ # server as unreachable under Android/proot due stalled ca-certificates.
+++ # When the device actually has internet, confirm reachability
+++ # with a plain request that uses the system CA bundle and follows
+++ # redirects, so the online import option is not disabled wrongly.
+++ import os
+++ import requests as _requests
+++
+++ _verify = True
+++ for _ca in (
+++ os.environ.get("REQUESTS_CA_BUNDLE"),
+++ os.environ.get("SSL_CERT_FILE"),
+++ "/etc/ssl/certs/ca-certificates.crt",
+++ "/etc/ssl/cert.pem",
+++ ):
+++ if _ca and os.path.exists(_ca):
+++ _verify = _ca
+++ break
+++ try:
+++ _resp = _requests.get(
+++ CENTRAL_CONTENT_BASE_URL.rstrip("/") + "/api/public/info",
+++ timeout=15,
+++ allow_redirects=True,
+++ verify=_verify,
+++ )
+++ if _resp.status_code == 200:
+++ try:
+++ _data = _resp.json()
+++ except ValueError:
+++ _data = {}
+++ _data["available"] = True
+++ _data["status"] = "online"
+++ return Response(_data)
+++ except Exception:
+++ pass
++ return Response({"status": "offline", "available": False})