diff --git a/.github/workflows/android-prepaint.yml b/.github/workflows/android-prepaint.yml
new file mode 100644
index 0000000..3f623c6
--- /dev/null
+++ b/.github/workflows/android-prepaint.yml
@@ -0,0 +1,55 @@
+name: Android prepaint viewer
+
+on:
+ push:
+ paths:
+ - "android-prepaint/**"
+ - "bin/termux_prepaint_url.grease"
+ - "tests/test_termux_prepaint_url.grease"
+ - "docs/prepaint-display-contract.md"
+ - ".github/workflows/android-prepaint.yml"
+ pull_request:
+ paths:
+ - "android-prepaint/**"
+ - "bin/termux_prepaint_url.grease"
+ - "tests/test_termux_prepaint_url.grease"
+ - "docs/prepaint-display-contract.md"
+ - ".github/workflows/android-prepaint.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ build-apk:
+ runs-on: ubuntu-latest
+ defaults:
+ run:
+ working-directory: android-prepaint
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Exercise the exact Termux command boundary
+ working-directory: ${{ github.workspace }}
+ run: sh tests/test_termux_prepaint_url.grease
+
+ - uses: actions/setup-java@v4
+ with:
+ distribution: temurin
+ java-version: "17"
+
+ - uses: android-actions/setup-android@v3
+
+ - name: Install Android platform
+ run: sdkmanager "platforms;android-36" "build-tools;36.0.0"
+
+ - uses: gradle/actions/setup-gradle@v4
+ with:
+ gradle-version: "8.13"
+
+ - name: Test, lint, and build debug APK
+ run: gradle --no-daemon :app:testDebugUnitTest :app:lintDebug :app:verifyPrepaintBoundary
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ib-prepaint-viewer-apk
+ path: android-prepaint/app/build/outputs/apk/debug/app-debug.apk
diff --git a/.github/workflows/idric-core.yml b/.github/workflows/idric-core.yml
index 0d3bffd..ce0fbd5 100644
--- a/.github/workflows/idric-core.yml
+++ b/.github/workflows/idric-core.yml
@@ -29,6 +29,9 @@ jobs:
- name: Install system dependencies through Grease
run: sh bin/ci_browser_foundation.grease install-dependencies
+ - name: Exercise ICU search and bounded network pre-decision
+ run: sh bin/ci_browser_foundation.grease exercise-prepaint-network
+
- name: Restore Idric compiler
id: idric-cache
uses: actions/cache/restore@v4
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1f3c047
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+android-prepaint/.gradle/
+android-prepaint/app/build/
+android-prepaint/dist/
+android-prepaint/local.properties
+src/build/
diff --git a/README.md b/README.md
index bf4db52..9f62eed 100644
--- a/README.md
+++ b/README.md
@@ -28,4 +28,11 @@ Python and Ithon are not IB implementation layers. A disposable comparison may e
Android or other native code is a narrow platform boundary for facilities Idriç and Grease cannot yet reach directly: NativeActivity, EGL, renderers, kernel-enforced filesystem operations, clipboard and share handoff, and similar FFI edges. Those adapters do not own browser state.
+`android-prepaint/` is a deliberately small phone-visible harness for the Idriç
+information prepaint. It uses native Android views rather than `WebView`, applies
+a fixed dark presentation to extracted text, preserves fetched image colors, and
+replaces partial projections with later complete revisions. Its first live path
+passes an exact URL through Termux to Grease, ICU, and the Idriç HTML prepaint
+producer, then validates the bounded result before changing the visible page.
+
The current Idriç core covers ordered history values, rebuildable indexes, storage classification and read policy, and the renderer-independent inspector model. Scientific-media work adds HTML-first arXiv harvesting, ordered image downloads, caption and alternate-text naming, PDF fallback, and a low-priority second naming pass. The task, category, and generic inference records documented above remain design boundaries rather than claims of completed implementation.
diff --git a/android-prepaint/README.md b/android-prepaint/README.md
new file mode 100644
index 0000000..f288b6b
--- /dev/null
+++ b/android-prepaint/README.md
@@ -0,0 +1,85 @@
+# IB Prepaint APK
+
+This is a deliberately small display harness for IB's renderer-neutral information
+view. It paints successive prepaint revisions as a dark, linear document made from
+native Android text, rows, forms, links, and images. It also accepts an ordinary
+UTF-8 text file directly: prose becomes readable paragraphs and a line containing
+one absolute HTTP(S) URL becomes a tappable link.
+
+It is not a browser engine. The APK has:
+
+- no `WebView`;
+- no network permission;
+- no HTML, CSS, or JavaScript parser;
+- no durable tab or history ownership;
+- no tint, inversion, or crop applied to fetched images.
+
+The bundled `sample.prepaint` is a small article rather than a developer-workbench
+fixture. It waits 1.4 seconds, then atomically replaces the useful prefix with the
+complete projection. **Replay** runs the repaint again. Hack Regular is bundled for
+all visible text. **Open** accepts either another local `.prepaint` artifact or a
+plain text file through Android's document picker; the app does not need broad
+storage access. A rejected structured artifact or unreadable file leaves the
+current page visible.
+
+The bottom input accepts either one absolute HTTP(S) URL or search words. An
+absolute URL is preserved byte-for-byte after surrounding whitespace is removed;
+other input becomes a percent-encoded Google search URL. Live addresses are
+bounded to 2,048 visible ASCII characters. The APK sends that URL as one argument
+to `~/opt/ib/bin/termux_prepaint_url.grease` through Termux's
+`RUN_COMMAND` service. Grease runs ICU, passes the fetched HTML to the Idriç
+information extractor, and returns a complete `ib-prepaint` artifact. The APK
+still has no network permission and does not substitute WebView or an Android HTTP
+stack.
+
+Termux 0.109 or newer must be installed with `allow-external-apps=true`, and IB
+must be granted Termux's **Run commands in Termux environment** permission. The IB
+checkout is expected at `~/opt/ib`; `icu` must be on Termux's `PATH`; and
+`src/build/exec/ib-html-prepaint` must have been built from `src/HtmlPrepaint.idric`.
+Those executable-installation requirements are intentionally explicit rather than
+silently falling back to another fetcher.
+
+Build the Termux-side renderer once with:
+
+```sh
+cd ~/opt/ib/src
+idris2 HtmlPrepaint.idric -o ib-html-prepaint
+```
+
+Each request has a private nonce and increasing identifier. The viewer accepts a
+result only for the newest request, only when Termux reports untruncated stdout
+and stderr, only on a clean exit, and only when the structured artifact names the
+exact requested URL and ends in a complete revision. The result is additionally
+bounded to 64 KiB. Any missing executable, rejected permission, stale response,
+truncation, command failure, or malformed artifact leaves the existing page
+visible.
+
+## Build
+
+With Android SDK 36 and Gradle 8.13 available:
+
+```text
+cd android-prepaint
+gradle --no-daemon :app:testDebugUnitTest :app:lintDebug :app:verifyPrepaintBoundary
+```
+
+The APK is written to `app/build/outputs/apk/debug/app-debug.apk`. The package name
+is `org.isomorphisms.ib.prepaint`, so this harness can remain installed beside the
+IB storage inspector or a later browser shell.
+
+The boundary check rejects `android.webkit`, `WebView`, an Internet permission, or
+a debug APK larger than 2 MiB.
+
+## Boundary
+
+Idriç owns HTML extraction and the `InformationView`; Grease owns ICU execution
+and its temporary files. The Android boundary recognizes an exact address versus
+search input, invokes the fixed Termux command, and validates the returned
+artifact without owning browser state.
+This APK parses the disposable display interchange described in
+`docs/prepaint-display-contract.md`, plus the deliberately narrower plain-text
+fallback, and maps the resulting blocks to Android views. It does not parse HTML.
+The interchange is a cache format, not canonical browsing state.
+
+Hack is from Source Foundry's Hack project. Its license is retained in
+`licenses/Hack-LICENSE.md`.
diff --git a/android-prepaint/app/build.gradle.kts b/android-prepaint/app/build.gradle.kts
new file mode 100644
index 0000000..97ba0f5
--- /dev/null
+++ b/android-prepaint/app/build.gradle.kts
@@ -0,0 +1,57 @@
+plugins {
+ id("com.android.application")
+}
+
+android {
+ namespace = "org.isomorphisms.ib.prepaint"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "org.isomorphisms.ib.prepaint"
+ minSdk = 26
+ targetSdk = 36
+ versionCode = 3
+ versionName = "0.3.0"
+ }
+
+ buildTypes {
+ getByName("release") {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+}
+
+dependencies {
+ testImplementation("junit:junit:4.13.2")
+}
+
+tasks.register("verifyPrepaintBoundary") {
+ dependsOn("assembleDebug")
+ doLast {
+ val implementationFiles = fileTree("src/main") {
+ include("**/*.java", "**/*.xml")
+ }
+ val implementation = implementationFiles.files.joinToString("\n") { it.readText() }
+ check(!implementation.contains("android.webkit")) {
+ "The prepaint harness must not depend on android.webkit."
+ }
+ check(!implementation.contains("WebView")) {
+ "The prepaint harness must not contain a WebView."
+ }
+ check(!file("src/main/AndroidManifest.xml").readText()
+ .contains("android.permission.INTERNET")) {
+ "The prepaint harness must not request Internet access."
+ }
+
+ val apks = fileTree("build/outputs/apk/debug") { include("*.apk") }.files
+ check(apks.size == 1) { "Expected exactly one debug APK, found ${apks.size}." }
+ check(apks.single().length() <= 2L * 1024L * 1024L) {
+ "Debug APK exceeds the 2 MiB harness budget."
+ }
+ }
+}
diff --git a/android-prepaint/app/src/main/AndroidManifest.xml b/android-prepaint/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..a65a16e
--- /dev/null
+++ b/android-prepaint/app/src/main/AndroidManifest.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android-prepaint/app/src/main/assets/fonts/Hack-Regular.ttf b/android-prepaint/app/src/main/assets/fonts/Hack-Regular.ttf
new file mode 100644
index 0000000..92a90cb
Binary files /dev/null and b/android-prepaint/app/src/main/assets/fonts/Hack-Regular.ttf differ
diff --git a/android-prepaint/app/src/main/assets/sample.prepaint b/android-prepaint/app/src/main/assets/sample.prepaint
new file mode 100644
index 0000000..3960fda
--- /dev/null
+++ b/android-prepaint/app/src/main/assets/sample.prepaint
@@ -0,0 +1,18 @@
+ib-prepaint 1
+revision 1 partial
+requested-url https://example.test/field-notes
+resolved-url https://example.test/field-notes
+title Field notes
+heading 1 Small pages first
+text Useful article text can appear before a site finishes loading everything else.
+end
+revision 2 complete
+requested-url https://example.test/field-notes
+resolved-url https://example.test/field-notes
+title Field notes
+heading 1 Small pages first
+text Useful article text can appear before a site finishes loading everything else.
+text The pre-paint keeps ordinary paragraphs readable, preserves links, and leaves fetched images in their original colors.
+image resource:sample_image An open book below green hills and a blue sky. A bundled image painted without tint, inversion, or crop. /full-image
+link Read the source /article
+end
diff --git a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintActivity.java b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintActivity.java
new file mode 100644
index 0000000..8624327
--- /dev/null
+++ b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintActivity.java
@@ -0,0 +1,652 @@
+package org.isomorphisms.ib.prepaint;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.graphics.Color;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.provider.OpenableColumns;
+import android.text.InputType;
+import android.util.Base64;
+import android.view.Gravity;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.inputmethod.EditorInfo;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.ScrollView;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.CodingErrorAction;
+import java.nio.charset.StandardCharsets;
+
+public final class PrepaintActivity extends Activity {
+ private static final int OPEN_PREPAINT = 7;
+ private static final int GRANT_TERMUX_COMMAND = 8;
+ private static final long REPAINT_DELAY_MILLIS = 1400;
+ private static final int BACKGROUND = Color.rgb(18, 21, 24);
+ private static final int SURFACE = Color.rgb(28, 33, 38);
+ private static final int FOREGROUND = Color.rgb(245, 247, 248);
+ private static final int SECONDARY = Color.rgb(185, 193, 200);
+ private static final int LINK = Color.rgb(139, 196, 255);
+ private static final int RULE = Color.rgb(57, 66, 74);
+
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private final Runnable advance = this::advanceRevision;
+
+ private Typeface hack;
+ private PrepaintDocument document;
+ private LinearLayout page;
+ private ScrollView scroll;
+ private TextView status;
+ private EditText searchInput;
+ private int revisionIndex;
+ private Uri openedDocument;
+ private PrepaintDocument.Revision currentRevision;
+ private String permissionPendingUrl;
+ private String permissionPendingTitle;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ configureWindow();
+ hack = Typeface.createFromAsset(getAssets(), "fonts/Hack-Regular.ttf");
+ setContentView(buildScreen());
+ try {
+ document = loadSample();
+ replayRepaint();
+ } catch (IOException error) {
+ showFailure(error.getMessage());
+ }
+ if (!handleTermuxResult(getIntent())
+ && TermuxPrepaintClient.hasActiveRequest(this)) {
+ status.setText("IB PREPAINT · fetching through ICU");
+ }
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ handleTermuxResult(intent);
+ }
+
+ @Override
+ protected void onDestroy() {
+ handler.removeCallbacks(advance);
+ super.onDestroy();
+ }
+
+ private void configureWindow() {
+ Window window = getWindow();
+ window.setStatusBarColor(BACKGROUND);
+ window.setNavigationBarColor(BACKGROUND);
+ window.getDecorView().setSystemUiVisibility(0);
+ }
+
+ private View buildScreen() {
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ root.setBackgroundColor(BACKGROUND);
+
+ LinearLayout chrome = new LinearLayout(this);
+ chrome.setOrientation(LinearLayout.HORIZONTAL);
+ chrome.setGravity(Gravity.CENTER_VERTICAL);
+ chrome.setPadding(dp(14), dp(8), dp(8), dp(8));
+ chrome.setBackgroundColor(SURFACE);
+
+ status = text("IB PREPAINT", 12, SECONDARY);
+ chrome.addView(status, new LinearLayout.LayoutParams(0,
+ ViewGroup.LayoutParams.WRAP_CONTENT, 1));
+
+ Button open = chromeButton("Open");
+ open.setOnClickListener(ignored -> openPrepaint());
+ chrome.addView(open);
+
+ Button replay = chromeButton("Replay");
+ LinearLayout.LayoutParams replayLayout = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ replayLayout.setMargins(dp(7), 0, 0, 0);
+ replay.setOnClickListener(ignored -> reloadAndReplay());
+ chrome.addView(replay, replayLayout);
+ root.addView(chrome, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+
+ scroll = new ScrollView(this);
+ scroll.setFillViewport(true);
+ scroll.setBackgroundColor(BACKGROUND);
+ page = new LinearLayout(this);
+ page.setOrientation(LinearLayout.VERTICAL);
+ page.setPadding(dp(18), dp(18), dp(18), dp(40));
+ scroll.addView(page, new ScrollView.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ root.addView(scroll, new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, 0, 1));
+ root.addView(buildSearchBar(), new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
+ return root;
+ }
+
+ private View buildSearchBar() {
+ LinearLayout bar = new LinearLayout(this);
+ bar.setOrientation(LinearLayout.HORIZONTAL);
+ bar.setGravity(Gravity.CENTER_VERTICAL);
+ bar.setPadding(dp(9), dp(7), dp(9), dp(9));
+ bar.setBackgroundColor(SURFACE);
+
+ searchInput = new EditText(this);
+ searchInput.setSingleLine(true);
+ searchInput.setHint("Search or URL");
+ searchInput.setHintTextColor(SECONDARY);
+ searchInput.setTextColor(FOREGROUND);
+ searchInput.setTextSize(14);
+ searchInput.setTypeface(hack);
+ searchInput.setInputType(InputType.TYPE_CLASS_TEXT);
+ searchInput.setImeOptions(EditorInfo.IME_ACTION_SEARCH);
+ searchInput.setPadding(dp(11), dp(8), dp(11), dp(8));
+ searchInput.setBackground(box(BACKGROUND, dp(5)));
+ searchInput.setOnEditorActionListener((view, action, event) -> {
+ if (action == EditorInfo.IME_ACTION_SEARCH) {
+ requestSearch();
+ return true;
+ }
+ return false;
+ });
+ bar.addView(searchInput, new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1));
+
+ Button search = chromeButton("Go");
+ LinearLayout.LayoutParams searchLayout = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ searchLayout.setMargins(dp(7), 0, 0, 0);
+ search.setOnClickListener(ignored -> requestSearch());
+ bar.addView(search, searchLayout);
+ return bar;
+ }
+
+ private PrepaintDocument loadSample() throws IOException {
+ try (InputStream input = getAssets().open("sample.prepaint");
+ InputStreamReader reader = new InputStreamReader(input, StandardCharsets.UTF_8)) {
+ return PrepaintDocument.parse(reader);
+ }
+ }
+
+ private void openPrepaint() {
+ Intent request = new Intent(Intent.ACTION_OPEN_DOCUMENT);
+ request.addCategory(Intent.CATEGORY_OPENABLE);
+ request.setType("text/*");
+ startActivityForResult(request, OPEN_PREPAINT);
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode != OPEN_PREPAINT || resultCode != RESULT_OK
+ || data == null || data.getData() == null) {
+ return;
+ }
+ Uri selected = data.getData();
+ try {
+ getContentResolver().takePersistableUriPermission(
+ selected, Intent.FLAG_GRANT_READ_URI_PERMISSION);
+ } catch (SecurityException | IllegalArgumentException ignored) {
+ // The one-time read grant is enough for this deliberately temporary harness.
+ }
+ try {
+ PrepaintDocument selectedDocument = loadDocument(selected, displayName(selected));
+ openedDocument = selected;
+ document = selectedDocument;
+ replayRepaint();
+ } catch (IOException | SecurityException error) {
+ showOpenFailure(error.getMessage());
+ }
+ }
+
+ private void reloadAndReplay() {
+ if (openedDocument == null) {
+ replayRepaint();
+ return;
+ }
+ try {
+ document = loadDocument(openedDocument, displayName(openedDocument));
+ replayRepaint();
+ } catch (IOException | SecurityException error) {
+ showOpenFailure(error.getMessage());
+ }
+ }
+
+ private PrepaintDocument loadDocument(Uri source, String title) throws IOException {
+ try (InputStream input = getContentResolver().openInputStream(source)) {
+ if (input == null) {
+ throw new IOException("The selected text could not be opened.");
+ }
+ try (InputStreamReader reader = new InputStreamReader(input,
+ StandardCharsets.UTF_8.newDecoder()
+ .onMalformedInput(CodingErrorAction.REPORT)
+ .onUnmappableCharacter(CodingErrorAction.REPORT))) {
+ return PrepaintDocument.parseOrPlainText(reader, title);
+ }
+ }
+ }
+
+ private String displayName(Uri source) {
+ try (Cursor cursor = getContentResolver().query(source,
+ new String[] {OpenableColumns.DISPLAY_NAME}, null, null, null)) {
+ if (cursor != null && cursor.moveToFirst()) {
+ int column = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+ if (column >= 0) {
+ String name = cursor.getString(column);
+ if (name != null && !name.trim().isEmpty()) {
+ return name;
+ }
+ }
+ }
+ } catch (RuntimeException ignored) {
+ // A provider is allowed to omit display metadata.
+ }
+ String segment = source.getLastPathSegment();
+ return segment == null || segment.isEmpty() ? "Plain text" : segment;
+ }
+
+ private void replayRepaint() {
+ if (document == null) {
+ return;
+ }
+ handler.removeCallbacks(advance);
+ revisionIndex = 0;
+ renderRevision(document.revisions.get(revisionIndex));
+ scheduleAdvance();
+ }
+
+ private void advanceRevision() {
+ if (document == null || revisionIndex + 1 >= document.revisions.size()) {
+ return;
+ }
+ revisionIndex += 1;
+ renderRevision(document.revisions.get(revisionIndex));
+ scheduleAdvance();
+ }
+
+ private void scheduleAdvance() {
+ if (revisionIndex + 1 < document.revisions.size()) {
+ handler.postDelayed(advance, REPAINT_DELAY_MILLIS);
+ }
+ }
+
+ private void renderRevision(PrepaintDocument.Revision revision) {
+ currentRevision = revision;
+ int oldScroll = scroll.getScrollY();
+ page.removeAllViews();
+ if (PrepaintDocument.TEXT_SOURCE.equals(document.sourceKind)) {
+ status.setText("IB PREPAINT · plain text");
+ } else {
+ status.setText("IB PREPAINT " + (revisionIndex + 1) + "/"
+ + document.revisions.size() + " · "
+ + (revision.complete ? "complete" : "partial"));
+ }
+
+ if (!revision.title.isEmpty()) {
+ TextView title = text(revision.title, 25, FOREGROUND);
+ title.setTypeface(hack, Typeface.BOLD);
+ addBlock(title, 0, 0, 0, 11);
+ }
+ String visibleUrl = revision.resolvedUrl.isEmpty()
+ ? revision.requestedUrl : revision.resolvedUrl;
+ if (!visibleUrl.isEmpty()) {
+ TextView url = text(visibleUrl, 12, SECONDARY);
+ url.setTextIsSelectable(true);
+ addBlock(url, 0, 0, 0, 19);
+ }
+
+ for (PrepaintDocument.Block block : revision.blocks) {
+ addInformationBlock(block);
+ }
+ scroll.post(() -> scroll.scrollTo(0, oldScroll));
+ }
+
+ private void addInformationBlock(PrepaintDocument.Block block) {
+ switch (block.kind) {
+ case PrepaintDocument.Block.HEADING:
+ float headingSize = block.level == 1 ? 20 : block.level == 2 ? 17 : 15;
+ TextView heading = text(block.values.get(0), headingSize, FOREGROUND);
+ heading.setTypeface(hack, Typeface.BOLD);
+ addBlock(heading, 0, block.level == 1 ? 8 : 4, 0, 8);
+ break;
+ case PrepaintDocument.Block.TEXT:
+ TextView paragraph = text(block.values.get(0), 15, FOREGROUND);
+ paragraph.setLineSpacing(0, 1.18f);
+ paragraph.setTextIsSelectable(true);
+ addBlock(paragraph, 0, 0, 0, 14);
+ break;
+ case PrepaintDocument.Block.LINK:
+ String label = block.values.get(0);
+ String target = block.values.get(1);
+ TextView link = text(label.equals(target) ? target : label + "\n" + target,
+ 14, LINK);
+ link.setPadding(dp(8), dp(5), dp(8), dp(5));
+ link.setBackground(box(SURFACE, dp(4)));
+ if (!target.isEmpty()) {
+ link.setOnClickListener(ignored -> requestLink(target));
+ }
+ addBlock(link, 0, 2, 0, 14);
+ break;
+ case PrepaintDocument.Block.ROW:
+ addRow(block);
+ break;
+ case PrepaintDocument.Block.FORM:
+ TextView form = text("[ " + block.values.get(0) + " ] → "
+ + block.values.get(1), 14, FOREGROUND);
+ form.setPadding(dp(12), dp(10), dp(12), dp(10));
+ form.setBackground(box(SURFACE, dp(4)));
+ addBlock(form, 0, 3, 0, 14);
+ break;
+ case PrepaintDocument.Block.IMAGE:
+ addImage(block);
+ break;
+ default:
+ throw new IllegalStateException("unhandled block " + block.kind);
+ }
+ }
+
+ private void addRow(PrepaintDocument.Block block) {
+ LinearLayout row = new LinearLayout(this);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ row.setGravity(Gravity.TOP);
+ row.setPadding(dp(1), dp(1), dp(1), dp(1));
+ row.setBackgroundColor(RULE);
+ for (String cellText : block.values) {
+ TextView cell = text(cellText, 13, FOREGROUND);
+ cell.setTextIsSelectable(true);
+ cell.setPadding(dp(9), dp(9), dp(9), dp(9));
+ cell.setBackgroundColor(SURFACE);
+ LinearLayout.LayoutParams cellLayout = new LinearLayout.LayoutParams(
+ 0, ViewGroup.LayoutParams.WRAP_CONTENT, 1);
+ cellLayout.setMargins(dp(1), dp(1), dp(1), dp(1));
+ row.addView(cell, cellLayout);
+ }
+ addBlock(row, 0, 0, 0, 3);
+ }
+
+ private void addImage(PrepaintDocument.Block block) {
+ String source = block.values.get(0);
+ String alternate = block.values.get(1);
+ String caption = block.values.get(2);
+ String target = block.values.get(3);
+ Drawable drawable = loadDrawable(source);
+ if (drawable == null) {
+ TextView missing = text("[image] " + alternate, 13, SECONDARY);
+ if (!target.isEmpty()) {
+ missing.setTextColor(LINK);
+ missing.setOnClickListener(ignored -> requestLink(target));
+ }
+ addBlock(missing, 0, 3, 0, 12);
+ return;
+ }
+
+ ImageView image = new ImageView(this);
+ image.setAdjustViewBounds(true);
+ image.setScaleType(ImageView.ScaleType.FIT_CENTER);
+ image.setContentDescription(alternate);
+ image.setImageDrawable(drawable);
+ if (!target.isEmpty()) {
+ image.setClickable(true);
+ image.setFocusable(true);
+ image.setOnClickListener(ignored -> requestLink(target));
+ }
+ addBlock(image, 0, 5, 0, caption.isEmpty() ? 14 : 5);
+
+ if (!caption.isEmpty()) {
+ TextView captionView = text(caption, 12, SECONDARY);
+ captionView.setLineSpacing(0, 1.12f);
+ addBlock(captionView, 0, 0, 0, 14);
+ }
+ }
+
+ private Drawable loadDrawable(String source) {
+ try {
+ if (source.startsWith("resource:")) {
+ String name = source.substring("resource:".length());
+ int identifier = getResources().getIdentifier(name, "drawable", getPackageName());
+ return identifier == 0 ? null : getDrawable(identifier);
+ }
+ if (source.startsWith("asset:")) {
+ try (InputStream input = getAssets().open(source.substring("asset:".length()))) {
+ return Drawable.createFromStream(input, source);
+ }
+ }
+ if (source.startsWith("data:image/") && source.contains(",")) {
+ int comma = source.indexOf(',');
+ byte[] bytes = Base64.decode(source.substring(comma + 1), Base64.DEFAULT);
+ try (InputStream input = new ByteArrayInputStream(bytes)) {
+ return Drawable.createFromStream(input, "inline prepaint image");
+ }
+ }
+ Uri uri = Uri.parse(source);
+ if ("content".equals(uri.getScheme()) || "file".equals(uri.getScheme())) {
+ try (InputStream input = getContentResolver().openInputStream(uri)) {
+ return input == null ? null : Drawable.createFromStream(input, source);
+ }
+ }
+ } catch (IOException | IllegalArgumentException | SecurityException ignored) {
+ return null;
+ }
+ return null;
+ }
+
+ private void showFailure(String message) {
+ handler.removeCallbacks(advance);
+ page.removeAllViews();
+ status.setText("IB PREPAINT · invalid artifact");
+ TextView failure = text(message == null ? "Could not read prepaint." : message,
+ 14, FOREGROUND);
+ addBlock(failure, 0, 0, 0, 0);
+ }
+
+ private void showOpenFailure(String message) {
+ String visible = message == null ? "Could not read the selected text." : message;
+ Toast.makeText(this, visible, Toast.LENGTH_LONG).show();
+ }
+
+ private void requestSearch() {
+ String input = searchInput.getText().toString().trim();
+ if (input.isEmpty()) {
+ Toast.makeText(this, "Type a search or URL first.", Toast.LENGTH_SHORT).show();
+ return;
+ }
+ String url = UrlRecognition.navigationUrl(input);
+ String title = UrlRecognition.isAbsoluteHttpUrl(input)
+ ? input : "Search: " + input;
+ requestNavigation(url, title);
+ }
+
+ private void requestLink(String target) {
+ String base = currentRevision == null ? "" : currentRevision.resolvedUrl;
+ if (base.isEmpty() && currentRevision != null) {
+ base = currentRevision.requestedUrl;
+ }
+ String resolved = UrlRecognition.resolve(base, target);
+ if (!UrlRecognition.isAbsoluteHttpUrl(resolved)) {
+ Toast.makeText(this, "This link has no absolute HTTP target yet.",
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+ requestNavigation(resolved, "Link request");
+ }
+
+ private void requestNavigation(String url, String title) {
+ if (!UrlRecognition.isAbsoluteHttpUrl(url)) {
+ Toast.makeText(this, "The HTTP(S) address is invalid or exceeds 2,048 characters.",
+ Toast.LENGTH_LONG).show();
+ return;
+ }
+ if (checkSelfPermission(TermuxPrepaintClient.TERMUX_PERMISSION)
+ != PackageManager.PERMISSION_GRANTED) {
+ permissionPendingUrl = url;
+ permissionPendingTitle = title;
+ requestPermissions(new String[] {TermuxPrepaintClient.TERMUX_PERMISSION},
+ GRANT_TERMUX_COMMAND);
+ return;
+ }
+ startTermuxNavigation(url, title);
+ }
+
+ private void startTermuxNavigation(String url, String title) {
+ String error = TermuxPrepaintClient.start(this, url);
+ if (error != null) {
+ Toast.makeText(this, error, Toast.LENGTH_LONG).show();
+ restoreStatus();
+ return;
+ }
+ status.setText("IB PREPAINT · fetching through ICU");
+ Toast.makeText(this, title, Toast.LENGTH_SHORT).show();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions,
+ int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ if (requestCode != GRANT_TERMUX_COMMAND) {
+ return;
+ }
+ String url = permissionPendingUrl;
+ String title = permissionPendingTitle;
+ permissionPendingUrl = null;
+ permissionPendingTitle = null;
+ if (grantResults.length > 0
+ && grantResults[0] == PackageManager.PERMISSION_GRANTED
+ && url != null) {
+ startTermuxNavigation(url, title == null ? url : title);
+ } else {
+ Toast.makeText(this,
+ "Grant IB permission to run its prepaint command in Termux.",
+ Toast.LENGTH_LONG).show();
+ restoreStatus();
+ }
+ }
+
+ private boolean handleTermuxResult(Intent intent) {
+ if (intent == null
+ || !TermuxPrepaintClient.RESULT_ACTION.equals(intent.getAction())) {
+ return false;
+ }
+ final long requestId;
+ final String nonce;
+ final String requestedUrl;
+ try {
+ requestId = intent.getLongExtra(TermuxPrepaintClient.EXTRA_REQUEST_ID, -1L);
+ nonce = intent.getStringExtra(TermuxPrepaintClient.EXTRA_REQUEST_NONCE);
+ requestedUrl = intent.getStringExtra(TermuxPrepaintClient.EXTRA_REQUEST_URL);
+ } catch (RuntimeException malformedEnvelope) {
+ return true;
+ }
+ if (!TermuxPrepaintClient.isActiveResult(
+ this, requestId, nonce, requestedUrl)) {
+ return true;
+ }
+
+ TermuxPrepaintResult validated;
+ try {
+ Bundle result = intent.getBundleExtra("result");
+ if (result == null) {
+ validated = TermuxPrepaintResult.validate(requestedUrl,
+ null, null, null, null, -1, 0,
+ "Termux returned no result bundle");
+ } else {
+ validated = TermuxPrepaintResult.validate(requestedUrl,
+ result.getString("stdout"),
+ result.getString("stdout_original_length"),
+ result.getString("stderr"),
+ result.getString("stderr_original_length"),
+ result.getInt("exitCode", -1),
+ result.getInt("err", 0),
+ result.getString("errmsg"));
+ }
+ } catch (RuntimeException malformedResult) {
+ validated = TermuxPrepaintResult.validate(requestedUrl,
+ null, null, null, null, -1, 0,
+ "Termux returned a malformed result bundle");
+ }
+ TermuxPrepaintClient.clearIfActive(this, requestId, nonce);
+ if (!validated.accepted()) {
+ Toast.makeText(this, validated.error, Toast.LENGTH_LONG).show();
+ restoreStatus();
+ return true;
+ }
+
+ document = validated.document;
+ openedDocument = null;
+ replayRepaint();
+ return true;
+ }
+
+ private void restoreStatus() {
+ if (document == null || currentRevision == null) {
+ status.setText("IB PREPAINT");
+ return;
+ }
+ if (PrepaintDocument.TEXT_SOURCE.equals(document.sourceKind)) {
+ status.setText("IB PREPAINT · plain text");
+ } else {
+ status.setText("IB PREPAINT " + (revisionIndex + 1) + "/"
+ + document.revisions.size() + " · "
+ + (currentRevision.complete ? "complete" : "partial"));
+ }
+ }
+
+ private TextView text(String value, float sp, int color) {
+ TextView view = new TextView(this);
+ view.setText(value);
+ view.setTextSize(sp);
+ view.setTextColor(color);
+ view.setTypeface(hack);
+ view.setIncludeFontPadding(false);
+ return view;
+ }
+
+ private Button chromeButton(String label) {
+ Button button = new Button(this);
+ button.setAllCaps(false);
+ button.setText(label);
+ button.setTextColor(FOREGROUND);
+ button.setTextSize(12);
+ button.setTypeface(hack);
+ button.setMinHeight(0);
+ button.setMinWidth(0);
+ button.setPadding(dp(14), dp(7), dp(14), dp(7));
+ button.setBackground(box(RULE, dp(5)));
+ return button;
+ }
+
+ private void addBlock(View view, int left, int top, int right, int bottom) {
+ LinearLayout.LayoutParams layout = new LinearLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
+ layout.setMargins(dp(left), dp(top), dp(right), dp(bottom));
+ page.addView(view, layout);
+ }
+
+ private GradientDrawable box(int color, int radius) {
+ GradientDrawable drawable = new GradientDrawable();
+ drawable.setColor(color);
+ drawable.setCornerRadius(radius);
+ return drawable;
+ }
+
+ private int dp(int value) {
+ return Math.round(value * getResources().getDisplayMetrics().density);
+ }
+}
diff --git a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java
new file mode 100644
index 0000000..b10dbdc
--- /dev/null
+++ b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/PrepaintDocument.java
@@ -0,0 +1,369 @@
+package org.isomorphisms.ib.prepaint;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+final class PrepaintDocument {
+ static final String FORMAT = "ib-prepaint";
+ static final String VERSION = "1";
+ static final String ARTIFACT_SOURCE = "artifact";
+ static final String TEXT_SOURCE = "text";
+ private static final long MAX_CHARACTERS = 4L * 1024L * 1024L;
+ private static final int MAX_REVISIONS = 32;
+ private static final int MAX_BLOCKS_PER_REVISION = 4096;
+
+ final List revisions;
+ final String sourceKind;
+
+ private PrepaintDocument(List revisions, String sourceKind) {
+ this.revisions = Collections.unmodifiableList(new ArrayList<>(revisions));
+ this.sourceKind = sourceKind;
+ }
+
+ static PrepaintDocument parseOrPlainText(Reader source, String title) throws IOException {
+ String text = readBoundedText(source);
+ if (!text.isEmpty() && text.charAt(0) == '\ufeff') {
+ text = text.substring(1);
+ }
+ rejectNul(text);
+
+ if (startsLikeStructuredArtifact(text)) {
+ return parse(new StringReader(text));
+ }
+ return fromPlainText(text, title);
+ }
+
+ static PrepaintDocument fromPlainText(String text, String title) {
+ String normalized = text.replace("\r\n", "\n").replace('\r', '\n');
+ List blocks = new ArrayList<>();
+ StringBuilder paragraph = new StringBuilder();
+
+ String[] lines = normalized.split("\n", -1);
+ for (String line : lines) {
+ String trimmed = line.trim();
+ if (trimmed.isEmpty()) {
+ finishParagraph(paragraph, blocks);
+ } else if (UrlRecognition.isAbsoluteHttpUrl(trimmed)) {
+ finishParagraph(paragraph, blocks);
+ blocks.add(Block.of(Block.LINK, trimmed, trimmed));
+ } else {
+ if (paragraph.length() != 0) {
+ paragraph.append('\n');
+ }
+ paragraph.append(line);
+ }
+ }
+ finishParagraph(paragraph, blocks);
+
+ String visibleTitle = title == null || title.trim().isEmpty()
+ ? "Plain text" : title.trim();
+ Revision revision = new Revision(0, true, "", "", visibleTitle, blocks);
+ return new PrepaintDocument(Collections.singletonList(revision), TEXT_SOURCE);
+ }
+
+ static PrepaintDocument parse(Reader source) throws IOException {
+ BufferedReader reader = new BufferedReader(new BoundedReader(source, MAX_CHARACTERS));
+ String line;
+ int lineNumber = 0;
+ boolean sawHeader = false;
+ long lastSequence = -1;
+ RevisionBuilder current = null;
+ List revisions = new ArrayList<>();
+
+ while ((line = reader.readLine()) != null) {
+ lineNumber += 1;
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+ List fields = splitFields(line, lineNumber);
+ String record = fields.get(0);
+
+ if (!sawHeader) {
+ require(fields.size() == 2
+ && FORMAT.equals(fields.get(0))
+ && VERSION.equals(fields.get(1)), lineNumber,
+ "expected ib-prepaint version 1 header");
+ sawHeader = true;
+ continue;
+ }
+
+ if ("revision".equals(record)) {
+ require(current == null, lineNumber, "nested revision");
+ require(fields.size() == 3, lineNumber,
+ "revision needs sequence and partial/complete state");
+ long sequence = parseSequence(fields.get(1), lineNumber);
+ require(sequence > lastSequence, lineNumber,
+ "revision sequence must increase");
+ String state = fields.get(2);
+ require("partial".equals(state) || "complete".equals(state), lineNumber,
+ "revision state must be partial or complete");
+ current = new RevisionBuilder(sequence, "complete".equals(state));
+ lastSequence = sequence;
+ continue;
+ }
+
+ if ("end".equals(record)) {
+ require(fields.size() == 1, lineNumber, "end takes no fields");
+ require(current != null, lineNumber, "end outside revision");
+ require(revisions.size() < MAX_REVISIONS, lineNumber, "too many revisions");
+ revisions.add(current.build());
+ current = null;
+ continue;
+ }
+
+ require(current != null, lineNumber, "record outside revision");
+ require(current.blocks.size() < MAX_BLOCKS_PER_REVISION, lineNumber,
+ "too many blocks in revision");
+ switch (record) {
+ case "title":
+ require(fields.size() == 2, lineNumber, "title needs text");
+ current.title = fields.get(1);
+ break;
+ case "requested-url":
+ require(fields.size() == 2, lineNumber, "requested-url needs a value");
+ current.requestedUrl = fields.get(1);
+ break;
+ case "resolved-url":
+ require(fields.size() == 2, lineNumber, "resolved-url needs a value");
+ current.resolvedUrl = fields.get(1);
+ break;
+ case "heading":
+ require(fields.size() == 3, lineNumber, "heading needs level and text");
+ int level = parseHeadingLevel(fields.get(1), lineNumber);
+ current.blocks.add(Block.heading(level, fields.get(2)));
+ break;
+ case "text":
+ require(fields.size() == 2, lineNumber, "text needs a value");
+ current.blocks.add(Block.of(Block.TEXT, fields.get(1)));
+ break;
+ case "link":
+ require(fields.size() == 3, lineNumber, "link needs label and target");
+ current.blocks.add(Block.of(Block.LINK, fields.get(1), fields.get(2)));
+ break;
+ case "row":
+ require(fields.size() >= 2, lineNumber, "row needs at least one cell");
+ current.blocks.add(Block.of(Block.ROW,
+ fields.subList(1, fields.size()).toArray(new String[0])));
+ break;
+ case "form":
+ require(fields.size() == 3, lineNumber, "form needs label and action");
+ current.blocks.add(Block.of(Block.FORM, fields.get(1), fields.get(2)));
+ break;
+ case "image":
+ require(fields.size() == 4 || fields.size() == 5, lineNumber,
+ "image needs source, alternate text, caption, and optional link");
+ current.blocks.add(fields.size() == 4
+ ? Block.of(Block.IMAGE,
+ fields.get(1), fields.get(2), fields.get(3), "")
+ : Block.of(Block.IMAGE,
+ fields.get(1), fields.get(2), fields.get(3), fields.get(4)));
+ break;
+ default:
+ throw parseError(lineNumber, "unknown record " + record);
+ }
+ }
+
+ require(sawHeader, lineNumber, "missing header");
+ require(current == null, lineNumber, "unterminated revision");
+ require(!revisions.isEmpty(), lineNumber, "no revisions");
+ return new PrepaintDocument(revisions, ARTIFACT_SOURCE);
+ }
+
+ private static void finishParagraph(StringBuilder paragraph, List blocks) {
+ if (paragraph.length() == 0) {
+ return;
+ }
+ blocks.add(Block.of(Block.TEXT, paragraph.toString()));
+ paragraph.setLength(0);
+ }
+
+ private static String readBoundedText(Reader source) throws IOException {
+ StringBuilder text = new StringBuilder();
+ char[] buffer = new char[8192];
+ long characters = 0;
+ int amount;
+ while ((amount = source.read(buffer)) != -1) {
+ characters += amount;
+ if (characters > MAX_CHARACTERS) {
+ throw new IOException("prepaint exceeds 4 MiB text budget");
+ }
+ text.append(buffer, 0, amount);
+ }
+ return text.toString();
+ }
+
+ private static boolean startsLikeStructuredArtifact(String text) {
+ int lineEnd = text.indexOf('\n');
+ String firstLine = lineEnd < 0 ? text : text.substring(0, lineEnd);
+ if (firstLine.endsWith("\r")) {
+ firstLine = firstLine.substring(0, firstLine.length() - 1);
+ }
+ return firstLine.startsWith(FORMAT);
+ }
+
+ private static void rejectNul(String text) throws IOException {
+ if (text.indexOf('\0') >= 0) {
+ throw new IOException("selected file is not plain UTF-8 text");
+ }
+ }
+
+ private static long parseSequence(String value, int lineNumber) throws IOException {
+ try {
+ long sequence = Long.parseLong(value);
+ require(sequence >= 0, lineNumber, "revision sequence must be nonnegative");
+ return sequence;
+ } catch (NumberFormatException error) {
+ throw parseError(lineNumber, "invalid revision sequence");
+ }
+ }
+
+ private static int parseHeadingLevel(String value, int lineNumber) throws IOException {
+ try {
+ int level = Integer.parseInt(value);
+ require(level >= 1 && level <= 6, lineNumber,
+ "heading level must be between 1 and 6");
+ return level;
+ } catch (NumberFormatException error) {
+ throw parseError(lineNumber, "invalid heading level");
+ }
+ }
+
+ private static List splitFields(String line, int lineNumber) throws IOException {
+ List fields = new ArrayList<>();
+ StringBuilder field = new StringBuilder();
+ boolean escaping = false;
+ for (int index = 0; index < line.length(); index += 1) {
+ char value = line.charAt(index);
+ if (escaping) {
+ switch (value) {
+ case 'n': field.append('\n'); break;
+ case 'r': field.append('\r'); break;
+ case 't': field.append('\t'); break;
+ case '\\': field.append('\\'); break;
+ default: throw parseError(lineNumber, "invalid escape \\" + value);
+ }
+ escaping = false;
+ } else if (value == '\\') {
+ escaping = true;
+ } else if (value == '\t') {
+ fields.add(field.toString());
+ field.setLength(0);
+ } else {
+ field.append(value);
+ }
+ }
+ require(!escaping, lineNumber, "trailing escape");
+ fields.add(field.toString());
+ return fields;
+ }
+
+ private static void require(boolean condition, int lineNumber, String message)
+ throws IOException {
+ if (!condition) {
+ throw parseError(lineNumber, message);
+ }
+ }
+
+ private static IOException parseError(int lineNumber, String message) {
+ return new IOException("prepaint line " + lineNumber + ": " + message);
+ }
+
+ static final class Revision {
+ final long sequence;
+ final boolean complete;
+ final String requestedUrl;
+ final String resolvedUrl;
+ final String title;
+ final List blocks;
+
+ Revision(long sequence, boolean complete, String requestedUrl,
+ String resolvedUrl, String title, List blocks) {
+ this.sequence = sequence;
+ this.complete = complete;
+ this.requestedUrl = requestedUrl;
+ this.resolvedUrl = resolvedUrl;
+ this.title = title;
+ this.blocks = Collections.unmodifiableList(new ArrayList<>(blocks));
+ }
+ }
+
+ static final class Block {
+ static final String HEADING = "heading";
+ static final String TEXT = "text";
+ static final String LINK = "link";
+ static final String ROW = "row";
+ static final String FORM = "form";
+ static final String IMAGE = "image";
+
+ final String kind;
+ final int level;
+ final List values;
+
+ private Block(String kind, int level, String... values) {
+ this.kind = kind;
+ this.level = level;
+ List copy = new ArrayList<>();
+ Collections.addAll(copy, values);
+ this.values = Collections.unmodifiableList(copy);
+ }
+
+ static Block heading(int level, String text) {
+ return new Block(HEADING, level, text);
+ }
+
+ static Block of(String kind, String... values) {
+ return new Block(kind, 0, values);
+ }
+ }
+
+ private static final class RevisionBuilder {
+ final long sequence;
+ final boolean complete;
+ String requestedUrl = "";
+ String resolvedUrl = "";
+ String title = "";
+ final List blocks = new ArrayList<>();
+
+ RevisionBuilder(long sequence, boolean complete) {
+ this.sequence = sequence;
+ this.complete = complete;
+ }
+
+ Revision build() {
+ return new Revision(sequence, complete, requestedUrl, resolvedUrl, title, blocks);
+ }
+ }
+
+ private static final class BoundedReader extends Reader {
+ private final Reader source;
+ private long remaining;
+
+ BoundedReader(Reader source, long maximumCharacters) {
+ this.source = source;
+ this.remaining = maximumCharacters;
+ }
+
+ @Override
+ public int read(char[] buffer, int offset, int length) throws IOException {
+ if (remaining == 0) {
+ throw new IOException("prepaint exceeds 4 MiB text budget");
+ }
+ int boundedLength = (int) Math.min((long) length, remaining);
+ int amount = source.read(buffer, offset, boundedLength);
+ if (amount > 0) {
+ remaining -= amount;
+ }
+ return amount;
+ }
+
+ @Override
+ public void close() throws IOException {
+ source.close();
+ }
+ }
+}
diff --git a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/TermuxPrepaintClient.java b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/TermuxPrepaintClient.java
new file mode 100644
index 0000000..d16fcd9
--- /dev/null
+++ b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/TermuxPrepaintClient.java
@@ -0,0 +1,120 @@
+package org.isomorphisms.ib.prepaint;
+
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.os.Build;
+
+import java.util.UUID;
+
+final class TermuxPrepaintClient {
+ static final String TERMUX_PERMISSION = "com.termux.permission.RUN_COMMAND";
+ static final String RESULT_ACTION = "org.isomorphisms.ib.prepaint.TERMUX_RESULT";
+ static final String EXTRA_REQUEST_ID = "ib.request.id";
+ static final String EXTRA_REQUEST_NONCE = "ib.request.nonce";
+ static final String EXTRA_REQUEST_URL = "ib.request.url";
+
+ private static final String TERMUX_PACKAGE = "com.termux";
+ private static final String TERMUX_SERVICE = "com.termux.app.RunCommandService";
+ private static final String RUN_COMMAND_ACTION = "com.termux.RUN_COMMAND";
+ private static final String EXTRA_COMMAND_PATH = "com.termux.RUN_COMMAND_PATH";
+ private static final String EXTRA_ARGUMENTS = "com.termux.RUN_COMMAND_ARGUMENTS";
+ private static final String EXTRA_BACKGROUND = "com.termux.RUN_COMMAND_BACKGROUND";
+ private static final String EXTRA_PENDING_INTENT =
+ "com.termux.RUN_COMMAND_PENDING_INTENT";
+ private static final String EXTRA_COMMAND_LABEL =
+ "com.termux.RUN_COMMAND_COMMAND_LABEL";
+ private static final String COMMAND_PATH = "~/opt/ib/bin/termux_prepaint_url.grease";
+
+ private static final String PREFERENCES = "termux-prepaint";
+ private static final String ACTIVE_ID = "active-id";
+ private static final String ACTIVE_NONCE = "active-nonce";
+ private static final String ACTIVE_URL = "active-url";
+ private static final String NEXT_ID = "next-id";
+
+ private TermuxPrepaintClient() {
+ }
+
+ static String start(Activity activity, String url) {
+ SharedPreferences preferences = preferences(activity);
+ long requestId = preferences.getLong(NEXT_ID, 0L) + 1L;
+ String nonce = UUID.randomUUID().toString();
+ if (!preferences.edit()
+ .putLong(NEXT_ID, requestId)
+ .putLong(ACTIVE_ID, requestId)
+ .putString(ACTIVE_NONCE, nonce)
+ .putString(ACTIVE_URL, url)
+ .commit()) {
+ return "Could not record the IB request.";
+ }
+
+ Intent result = new Intent(activity, PrepaintActivity.class)
+ .setAction(RESULT_ACTION)
+ .putExtra(EXTRA_REQUEST_ID, requestId)
+ .putExtra(EXTRA_REQUEST_NONCE, nonce)
+ .putExtra(EXTRA_REQUEST_URL, url);
+ int pendingFlags = PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_CANCEL_CURRENT;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ pendingFlags |= PendingIntent.FLAG_MUTABLE;
+ }
+ PendingIntent callback = PendingIntent.getActivity(activity,
+ (int) (requestId & 0x7fffffff), result, pendingFlags);
+
+ Intent command = new Intent()
+ .setClassName(TERMUX_PACKAGE, TERMUX_SERVICE)
+ .setAction(RUN_COMMAND_ACTION)
+ .putExtra(EXTRA_COMMAND_PATH, COMMAND_PATH)
+ .putExtra(EXTRA_ARGUMENTS, new String[] {url})
+ .putExtra(EXTRA_BACKGROUND, true)
+ .putExtra(EXTRA_PENDING_INTENT, callback)
+ .putExtra(EXTRA_COMMAND_LABEL, "IB prepaint");
+ try {
+ ComponentName started = activity.startService(command);
+ if (started == null) {
+ clearIfActive(activity, requestId, nonce);
+ return "Termux is not installed or its command service is unavailable.";
+ }
+ return null;
+ } catch (SecurityException error) {
+ clearIfActive(activity, requestId, nonce);
+ return "Grant IB permission to run commands in Termux.";
+ } catch (RuntimeException error) {
+ clearIfActive(activity, requestId, nonce);
+ return "Could not start the Termux IB command.";
+ }
+ }
+
+ static boolean isActiveResult(Context context, long requestId, String nonce, String url) {
+ SharedPreferences preferences = preferences(context);
+ return requestId > 0
+ && requestId == preferences.getLong(ACTIVE_ID, -1L)
+ && nonce != null
+ && nonce.equals(preferences.getString(ACTIVE_NONCE, null))
+ && url != null
+ && url.equals(preferences.getString(ACTIVE_URL, null));
+ }
+
+ static boolean hasActiveRequest(Context context) {
+ return preferences(context).getLong(ACTIVE_ID, -1L) > 0;
+ }
+
+ static void clearIfActive(Context context, long requestId, String nonce) {
+ SharedPreferences preferences = preferences(context);
+ if (requestId == preferences.getLong(ACTIVE_ID, -1L)
+ && nonce != null
+ && nonce.equals(preferences.getString(ACTIVE_NONCE, null))) {
+ preferences.edit()
+ .remove(ACTIVE_ID)
+ .remove(ACTIVE_NONCE)
+ .remove(ACTIVE_URL)
+ .apply();
+ }
+ }
+
+ private static SharedPreferences preferences(Context context) {
+ return context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE);
+ }
+}
diff --git a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/TermuxPrepaintResult.java b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/TermuxPrepaintResult.java
new file mode 100644
index 0000000..139225d
--- /dev/null
+++ b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/TermuxPrepaintResult.java
@@ -0,0 +1,102 @@
+package org.isomorphisms.ib.prepaint;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+
+final class TermuxPrepaintResult {
+ static final int MAX_ARTIFACT_BYTES = 64 * 1024;
+ static final int TERMUX_NO_INTERNAL_ERROR = -1;
+
+ final PrepaintDocument document;
+ final String error;
+
+ private TermuxPrepaintResult(PrepaintDocument document, String error) {
+ this.document = document;
+ this.error = error;
+ }
+
+ static TermuxPrepaintResult validate(String requestedUrl, String stdout,
+ String stdoutOriginalLength, String stderr, String stderrOriginalLength,
+ int exitCode, int internalError, String internalMessage) {
+ if (!UrlRecognition.isAbsoluteHttpUrl(requestedUrl)) {
+ return failure("The active IB URL is invalid.");
+ }
+ if (internalError != TERMUX_NO_INTERNAL_ERROR) {
+ return failure(visibleError("Termux could not run the IB command", internalMessage));
+ }
+ if (stdout == null || stdoutOriginalLength == null) {
+ return failure("Termux returned no verifiable prepaint output.");
+ }
+ if (!lengthMatches(stdout, stdoutOriginalLength)) {
+ return failure("Termux truncated the prepaint output.");
+ }
+ String visibleStderr = stderr == null ? "" : stderr;
+ if (stderrOriginalLength == null || !lengthMatches(visibleStderr, stderrOriginalLength)) {
+ return failure("Termux truncated the prepaint error output.");
+ }
+ if (exitCode != 0) {
+ return failure(visibleError(
+ "IB prepaint exited with status " + exitCode, visibleStderr));
+ }
+ if (!visibleStderr.isEmpty()) {
+ return failure(visibleError("IB prepaint reported an error", visibleStderr));
+ }
+ if (stdout.indexOf('\0') >= 0) {
+ return failure("IB prepaint returned binary data.");
+ }
+ if (stdout.getBytes(StandardCharsets.UTF_8).length > MAX_ARTIFACT_BYTES) {
+ return failure("IB prepaint exceeded the 64 KiB result budget.");
+ }
+
+ final PrepaintDocument parsed;
+ try {
+ parsed = PrepaintDocument.parse(new StringReader(stdout));
+ } catch (IOException error) {
+ return failure(error.getMessage());
+ }
+ if (!PrepaintDocument.ARTIFACT_SOURCE.equals(parsed.sourceKind)) {
+ return failure("Termux did not return a structured prepaint artifact.");
+ }
+ PrepaintDocument.Revision finalRevision =
+ parsed.revisions.get(parsed.revisions.size() - 1);
+ if (!finalRevision.complete) {
+ return failure("IB prepaint returned no complete final revision.");
+ }
+ for (PrepaintDocument.Revision revision : parsed.revisions) {
+ if (!requestedUrl.equals(revision.requestedUrl)) {
+ return failure("IB prepaint returned a different requested URL.");
+ }
+ }
+ return new TermuxPrepaintResult(parsed, null);
+ }
+
+ boolean accepted() {
+ return document != null;
+ }
+
+ private static boolean lengthMatches(String value, String claimedLength) {
+ try {
+ return value.length() == Long.parseLong(claimedLength);
+ } catch (NumberFormatException ignored) {
+ return false;
+ }
+ }
+
+ private static String visibleError(String prefix, String detail) {
+ if (detail == null || detail.trim().isEmpty()) {
+ return prefix + ".";
+ }
+ String oneLine = detail.trim().replace('\n', ' ').replace('\r', ' ');
+ if (oneLine.length() > 240) {
+ oneLine = oneLine.substring(0, 240);
+ }
+ return prefix + ": " + oneLine;
+ }
+
+ private static TermuxPrepaintResult failure(String error) {
+ String visible = error == null || error.trim().isEmpty()
+ ? "IB prepaint returned an invalid artifact." : error;
+ return new TermuxPrepaintResult(null, visible);
+ }
+}
diff --git a/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/UrlRecognition.java b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/UrlRecognition.java
new file mode 100644
index 0000000..6a70828
--- /dev/null
+++ b/android-prepaint/app/src/main/java/org/isomorphisms/ib/prepaint/UrlRecognition.java
@@ -0,0 +1,71 @@
+package org.isomorphisms.ib.prepaint;
+
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.io.UnsupportedEncodingException;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+
+final class UrlRecognition {
+ private static final String GOOGLE_SEARCH = "https://www.google.com/search?q=";
+ private static final int MAX_URL_CHARACTERS = 2048;
+
+ private UrlRecognition() {
+ }
+
+ static boolean isAbsoluteHttpUrl(String candidate) {
+ if (candidate == null || candidate.isEmpty()
+ || candidate.length() > MAX_URL_CHARACTERS) {
+ return false;
+ }
+ for (int index = 0; index < candidate.length(); index += 1) {
+ char value = candidate.charAt(index);
+ if (value <= 0x20 || value >= 0x7f) {
+ return false;
+ }
+ }
+
+ String lower = candidate.toLowerCase(Locale.ROOT);
+ if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
+ return false;
+ }
+ try {
+ URI parsed = new URI(candidate);
+ return parsed.isAbsolute()
+ && parsed.getHost() != null
+ && !parsed.getHost().isEmpty()
+ && parsed.getRawUserInfo() == null
+ && !parsed.getRawAuthority().startsWith("[");
+ } catch (URISyntaxException ignored) {
+ return false;
+ }
+ }
+
+ static String googleSearchUrl(String query) {
+ try {
+ return GOOGLE_SEARCH + URLEncoder.encode(query, StandardCharsets.UTF_8.name());
+ } catch (UnsupportedEncodingException impossible) {
+ throw new AssertionError("UTF-8 is required by Android", impossible);
+ }
+ }
+
+ static String navigationUrl(String input) {
+ String trimmed = input == null ? "" : input.trim();
+ return isAbsoluteHttpUrl(trimmed) ? trimmed : googleSearchUrl(trimmed);
+ }
+
+ static String resolve(String base, String target) {
+ if (isAbsoluteHttpUrl(target)) {
+ return target;
+ }
+ if (base == null || base.isEmpty()) {
+ return target;
+ }
+ try {
+ return new URI(base).resolve(target).toASCIIString();
+ } catch (URISyntaxException | IllegalArgumentException ignored) {
+ return target;
+ }
+ }
+}
diff --git a/android-prepaint/app/src/main/res/drawable/sample_image.xml b/android-prepaint/app/src/main/res/drawable/sample_image.xml
new file mode 100644
index 0000000..1fde351
--- /dev/null
+++ b/android-prepaint/app/src/main/res/drawable/sample_image.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
diff --git a/android-prepaint/app/src/main/res/values/colors.xml b/android-prepaint/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..01340ac
--- /dev/null
+++ b/android-prepaint/app/src/main/res/values/colors.xml
@@ -0,0 +1,9 @@
+
+
+ #121518
+ #1C2126
+ #F5F7F8
+ #B9C1C8
+ #8BC4FF
+ #39424A
+
diff --git a/android-prepaint/app/src/main/res/values/styles.xml b/android-prepaint/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..a79b55b
--- /dev/null
+++ b/android-prepaint/app/src/main/res/values/styles.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/PrepaintDocumentTest.java b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/PrepaintDocumentTest.java
new file mode 100644
index 0000000..1f1038b
--- /dev/null
+++ b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/PrepaintDocumentTest.java
@@ -0,0 +1,126 @@
+package org.isomorphisms.ib.prepaint;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import java.io.IOException;
+import java.io.StringReader;
+
+import org.junit.Test;
+
+public final class PrepaintDocumentTest {
+ @Test
+ public void parsesCompleteReplacementRevisions() throws Exception {
+ PrepaintDocument document = parse(
+ "ib-prepaint\t1\n"
+ + "revision\t4\tpartial\n"
+ + "title\tFirst paint\n"
+ + "heading\t1\tStatus\n"
+ + "row\tservice\thealthy\n"
+ + "end\n"
+ + "revision\t5\tcomplete\n"
+ + "title\tFull paint\n"
+ + "image\tdata:image/png;base64,AA==\tplot\tFigure 1\t/full-figure\n"
+ + "end\n");
+
+ assertEquals(2, document.revisions.size());
+ assertEquals(4, document.revisions.get(0).sequence);
+ assertFalse(document.revisions.get(0).complete);
+ assertEquals(2, document.revisions.get(0).blocks.size());
+ assertEquals(5, document.revisions.get(1).sequence);
+ assertTrue(document.revisions.get(1).complete);
+ assertEquals(PrepaintDocument.Block.IMAGE,
+ document.revisions.get(1).blocks.get(0).kind);
+ assertEquals("/full-figure",
+ document.revisions.get(1).blocks.get(0).values.get(3));
+ }
+
+ @Test
+ public void unescapesTextWithoutChangingFieldBoundaries() throws Exception {
+ PrepaintDocument document = parse(
+ "ib-prepaint\t1\n"
+ + "revision\t1\tcomplete\n"
+ + "text\tone\\ttwo\\nthree\\\\four\n"
+ + "end\n");
+
+ assertEquals("one\ttwo\nthree\\four",
+ document.revisions.get(0).blocks.get(0).values.get(0));
+ }
+
+ @Test
+ public void acceptsAnUnlinkedImageFromTheOriginalVersionOneShape() throws Exception {
+ PrepaintDocument document = parse(
+ "ib-prepaint\t1\n"
+ + "revision\t1\tcomplete\n"
+ + "image\tcontent://image\talternate\tcaption\n"
+ + "end\n");
+
+ assertEquals("", document.revisions.get(0).blocks.get(0).values.get(3));
+ }
+
+ @Test
+ public void rejectsNonIncreasingRevisionSequence() {
+ assertThrows(IOException.class, () -> parse(
+ "ib-prepaint\t1\n"
+ + "revision\t2\tpartial\nend\n"
+ + "revision\t2\tcomplete\nend\n"));
+ }
+
+ @Test
+ public void rejectsMarkupInsteadOfTreatingItAsAWebPage() {
+ assertThrows(IOException.class, () -> parse("not a prepaint"));
+ }
+
+ @Test
+ public void prepaintsOrdinaryTextAsParagraphs() throws Exception {
+ PrepaintDocument document = parseOrPlainText(
+ "First paragraph.\nStill first.\n\nSecond paragraph.\n",
+ "notes.txt");
+
+ assertEquals(PrepaintDocument.TEXT_SOURCE, document.sourceKind);
+ assertEquals("notes.txt", document.revisions.get(0).title);
+ assertEquals(2, document.revisions.get(0).blocks.size());
+ assertEquals("First paragraph.\nStill first.",
+ document.revisions.get(0).blocks.get(0).values.get(0));
+ assertEquals("Second paragraph.",
+ document.revisions.get(0).blocks.get(1).values.get(0));
+ }
+
+ @Test
+ public void prepaintsAPlainTextUrlListAsLinks() throws Exception {
+ PrepaintDocument document = parseOrPlainText(
+ "https://example.com/one\nhttp://example.net/two?q=three\n",
+ "urls.txt");
+
+ assertEquals(2, document.revisions.get(0).blocks.size());
+ assertEquals(PrepaintDocument.Block.LINK,
+ document.revisions.get(0).blocks.get(0).kind);
+ assertEquals("https://example.com/one",
+ document.revisions.get(0).blocks.get(0).values.get(1));
+ assertEquals(PrepaintDocument.Block.LINK,
+ document.revisions.get(0).blocks.get(1).kind);
+ }
+
+ @Test
+ public void malformedStructuredArtifactDoesNotFallBackToPlainText() {
+ assertThrows(IOException.class, () -> parseOrPlainText(
+ "ib-prepaint\t2\nrevision\t1\tcomplete\nend\n", "bad.prepaint"));
+ }
+
+ @Test
+ public void rejectsNulBearingInputInsteadOfPaintingBinaryData() {
+ assertThrows(IOException.class, () -> parseOrPlainText(
+ "plain\0text", "not-text.bin"));
+ }
+
+ private static PrepaintDocument parse(String text) throws IOException {
+ return PrepaintDocument.parse(new StringReader(text));
+ }
+
+ private static PrepaintDocument parseOrPlainText(String text, String title)
+ throws IOException {
+ return PrepaintDocument.parseOrPlainText(new StringReader(text), title);
+ }
+}
diff --git a/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/TermuxPrepaintResultTest.java b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/TermuxPrepaintResultTest.java
new file mode 100644
index 0000000..28a8fad
--- /dev/null
+++ b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/TermuxPrepaintResultTest.java
@@ -0,0 +1,89 @@
+package org.isomorphisms.ib.prepaint;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public final class TermuxPrepaintResultTest {
+ private static final String URL = "https://example.test/page?q=one";
+
+ @Test
+ public void acceptsOneCompleteMatchingArtifact() {
+ String artifact = artifact(URL, "complete");
+ TermuxPrepaintResult result = validate(artifact,
+ Integer.toString(artifact.length()), "", "0", 0, -1);
+
+ assertTrue(result.accepted());
+ assertEquals("Visible result", result.document.revisions.get(0).title);
+ }
+
+ @Test
+ public void rejectsTruncatedBinderOutput() {
+ String artifact = artifact(URL, "complete");
+ TermuxPrepaintResult result = validate(artifact,
+ Integer.toString(artifact.length() + 20), "", "0", 0, -1);
+
+ assertFalse(result.accepted());
+ assertEquals("Termux truncated the prepaint output.", result.error);
+ }
+
+ @Test
+ public void rejectsFailedAndMalformedResults() {
+ TermuxPrepaintResult failed =
+ validate("", "0", "fetch failed", "12", 7, -1);
+ assertFalse(failed.accepted());
+ assertTrue(failed.error.contains("fetch failed"));
+ assertFalse(validate("plain text", "10", "", "0", 0, -1).accepted());
+ }
+
+ @Test
+ public void rejectsAStaleUrlOrIncompleteFinalRevision() {
+ assertFalse(validate(artifact("https://other.test/", "complete"),
+ null, "", "0", 0, -1).accepted());
+ assertFalse(validate(artifact(URL, "partial"),
+ null, "", "0", 0, -1).accepted());
+ }
+
+ @Test
+ public void rejectsAnInvalidActiveUrl() {
+ String artifact = artifact(URL, "complete");
+ assertFalse(TermuxPrepaintResult.validate(null, artifact,
+ Integer.toString(artifact.length()), "", "0", 0, -1, null).accepted());
+ }
+
+ @Test
+ public void rejectsMoreThanSixtyFourKibibytes() {
+ StringBuilder large = new StringBuilder(70_000);
+ large.append("ib-prepaint\t1\nrevision\t1\tcomplete\nrequested-url\t")
+ .append(URL).append("\ntext\t");
+ while (large.length() < 66_000) {
+ large.append('x');
+ }
+ large.append("\nend\n");
+ String artifact = large.toString();
+
+ assertFalse(validate(artifact, Integer.toString(artifact.length()),
+ "", "0", 0, -1).accepted());
+ }
+
+ private static TermuxPrepaintResult validate(String stdout,
+ String stdoutLength, String stderr, String stderrLength,
+ int exitCode, int internalError) {
+ String claimedStdout = stdoutLength == null
+ ? Integer.toString(stdout.length()) : stdoutLength;
+ return TermuxPrepaintResult.validate(URL, stdout, claimedStdout,
+ stderr, stderrLength, exitCode, internalError, null);
+ }
+
+ private static String artifact(String requestedUrl, String state) {
+ return "ib-prepaint\t1\n"
+ + "revision\t1\t" + state + "\n"
+ + "requested-url\t" + requestedUrl + "\n"
+ + "resolved-url\t" + requestedUrl + "\n"
+ + "title\tVisible result\n"
+ + "text\tFetched through ICU.\n"
+ + "end\n";
+ }
+}
diff --git a/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/UrlRecognitionTest.java b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/UrlRecognitionTest.java
new file mode 100644
index 0000000..f38f0ff
--- /dev/null
+++ b/android-prepaint/app/src/test/java/org/isomorphisms/ib/prepaint/UrlRecognitionTest.java
@@ -0,0 +1,48 @@
+package org.isomorphisms.ib.prepaint;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import org.junit.Test;
+
+public final class UrlRecognitionTest {
+ @Test
+ public void recognizesOnlyAbsoluteVisibleHttpUrls() {
+ assertTrue(UrlRecognition.isAbsoluteHttpUrl("https://example.com/a?q=b#c"));
+ assertTrue(UrlRecognition.isAbsoluteHttpUrl("http://127.0.0.1:8080/"));
+ assertFalse(UrlRecognition.isAbsoluteHttpUrl("/relative/path"));
+ assertFalse(UrlRecognition.isAbsoluteHttpUrl("ftp://example.com/file"));
+ assertFalse(UrlRecognition.isAbsoluteHttpUrl("https://example.com/a b"));
+ assertFalse(UrlRecognition.isAbsoluteHttpUrl("https://user@example.com/private"));
+ assertFalse(UrlRecognition.isAbsoluteHttpUrl("https://[::1]/private"));
+ assertFalse(UrlRecognition.isAbsoluteHttpUrl(
+ "https://example.com/" + "x".repeat(2049)));
+ }
+
+ @Test
+ public void searchUsesPlusForSpacesAndPercentEncodesUtf8() {
+ assertEquals("https://www.google.com/search?q=small+fast+browser",
+ UrlRecognition.googleSearchUrl("small fast browser"));
+ assertEquals("https://www.google.com/search?q=caf%C3%A9",
+ UrlRecognition.googleSearchUrl("caf\u00e9"));
+ }
+
+ @Test
+ public void navigationPreservesAnExactUrlAndSearchesOtherInput() {
+ assertEquals("https://example.com/a?q=one%20two&literal='value'",
+ UrlRecognition.navigationUrl(
+ " https://example.com/a?q=one%20two&literal='value' "));
+ assertEquals("https://www.google.com/search?q=small+fast+browser",
+ UrlRecognition.navigationUrl("small fast browser"));
+ }
+
+ @Test
+ public void resolvesPageRelativeLinksBeforeIcuHandoff() {
+ assertEquals("https://example.com/jobs/42",
+ UrlRecognition.resolve("https://example.com/deployments", "/jobs/42"));
+ assertEquals("https://other.test/page",
+ UrlRecognition.resolve("https://example.com/deployments", "https://other.test/page"));
+ }
+
+}
diff --git a/android-prepaint/build.gradle.kts b/android-prepaint/build.gradle.kts
new file mode 100644
index 0000000..80ef7f8
--- /dev/null
+++ b/android-prepaint/build.gradle.kts
@@ -0,0 +1,3 @@
+plugins {
+ id("com.android.application") version "8.13.2" apply false
+}
diff --git a/android-prepaint/gradle.properties b/android-prepaint/gradle.properties
new file mode 100644
index 0000000..de46cfb
--- /dev/null
+++ b/android-prepaint/gradle.properties
@@ -0,0 +1,3 @@
+org.gradle.jvmargs=-Xmx1536m -Dfile.encoding=UTF-8
+android.useAndroidX=false
+android.nonTransitiveRClass=true
diff --git a/android-prepaint/licenses/Hack-LICENSE.md b/android-prepaint/licenses/Hack-LICENSE.md
new file mode 100644
index 0000000..08927e5
--- /dev/null
+++ b/android-prepaint/licenses/Hack-LICENSE.md
@@ -0,0 +1,45 @@
+The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
+
+The work in the DejaVu project was committed to the public domain.
+
+Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
+
+### MIT License
+
+Copyright (c) 2018 Source Foundry Authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+### BITSTREAM VERA LICENSE
+
+Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
+
+The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
+
+The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
+
+This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
+
+The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
+
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+
+Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
diff --git a/android-prepaint/licenses/Hack-SOURCE.txt b/android-prepaint/licenses/Hack-SOURCE.txt
new file mode 100644
index 0000000..9d9bac2
--- /dev/null
+++ b/android-prepaint/licenses/Hack-SOURCE.txt
@@ -0,0 +1,9 @@
+Hack Regular was copied from:
+
+https://github.com/source-foundry/Hack/blob/a737c121cabb337fdfe655d8c7304729f351e30f/build/ttf/Hack-Regular.ttf
+
+Upstream revision: a737c121cabb337fdfe655d8c7304729f351e30f
+SHA-256: 15f55cc0c85a2988d2b4b3a8cdb5d77fdfbaf319e1bb5309d725db9818fb7125
+Retrieved: 2026-08-24
+
+The unmodified upstream license is in Hack-LICENSE.md.
diff --git a/android-prepaint/settings.gradle.kts b/android-prepaint/settings.gradle.kts
new file mode 100644
index 0000000..7539344
--- /dev/null
+++ b/android-prepaint/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "IB Prepaint"
+include(":app")
diff --git a/bin/ci_browser_foundation.grease b/bin/ci_browser_foundation.grease
index 105a255..c8d7414 100755
--- a/bin/ci_browser_foundation.grease
+++ b/bin/ci_browser_foundation.grease
@@ -8,7 +8,7 @@ pdf_harvester_ref=${PDF_HARVESTER_REF:-77d85dc6f7e89d109cb9e06f42706bc34f00e4a3}
install_dependencies() {
sudo apt-get update
- sudo apt-get install -y chezscheme curl xmlstarlet busybox poppler-utils
+ sudo apt-get install -y chezscheme curl xmlstarlet busybox poppler-utils jq libgmp-dev
}
build_idric() {
@@ -74,6 +74,19 @@ exercise_workbench() {
grep -Fx 'resident-at-10=10' /tmp/ib-workbench.txt
}
+exercise_prepaint_network() {
+ cd "$repository_root"
+ sh -n bin/icu_search.grease
+ sh -n bin/prepaint_predecision.grease
+ sh -n bin/termux_prepaint_url.grease
+ sh -n tests/test_icu_search.grease
+ sh -n tests/test_prepaint_predecision.grease
+ sh -n tests/test_termux_prepaint_url.grease
+ sh tests/test_icu_search.grease
+ sh tests/test_prepaint_predecision.grease
+ sh tests/test_termux_prepaint_url.grease
+}
+
build_information_programs() {
test -x "$idric_prefix/bin/idris2" || build_idric
cd "$repository_root/src"
@@ -85,6 +98,10 @@ build_information_programs() {
2>&1 | tee /tmp/idric-arxiv-prepaint-compile.txt
test -x ./build/exec/ib-arxiv-prepaint
! grep -q '^Error:' /tmp/idric-arxiv-prepaint-compile.txt
+ "$idric_prefix/bin/idris2" HtmlPrepaint.idric -o ib-html-prepaint \
+ 2>&1 | tee /tmp/idric-html-prepaint-compile.txt
+ test -x ./build/exec/ib-html-prepaint
+ ! grep -q '^Error:' /tmp/idric-html-prepaint-compile.txt
}
exercise_information() {
@@ -104,6 +121,29 @@ exercise_information() {
grep -Fx 'phone-api-row=True' /tmp/ib-information-smoke.txt
grep -Fx 'phone-lines=8' /tmp/ib-information-smoke.txt
grep -Fx 'phone-width-bounded=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-first-paragraph=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-second-paragraph=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-href=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-inline-href=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-inline-prefix=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-inline-suffix=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-linked-image=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'article-image-empty-link=False' /tmp/ib-information-smoke.txt
+ grep -Fx 'plain-first-url=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'plain-second-url=True' /tmp/ib-information-smoke.txt
+ grep -Fx 'cheap-url-rejects-relative=False' /tmp/ib-information-smoke.txt
+ src/build/exec/ib-html-prepaint \
+ 'https://arxiv.org/abs/2203.11355' \
+ 'https://arxiv.org/html/2203.11355' \
+ tests/fixtures/arxiv_site/html/2203.11355 \
+ > /tmp/ib-html-prepaint.txt
+ tab=$(printf '\t')
+ grep -Fx "ib-prepaint${tab}1" /tmp/ib-html-prepaint.txt
+ grep -Fx "revision${tab}1${tab}complete" /tmp/ib-html-prepaint.txt
+ grep -Fx "requested-url${tab}https://arxiv.org/abs/2203.11355" \
+ /tmp/ib-html-prepaint.txt
+ test "$(tail -n 1 /tmp/ib-html-prepaint.txt)" = end
+ test "$(wc -c < /tmp/ib-html-prepaint.txt)" -le 65536
sh -n bin/prepaint_arxiv_progressively.grease
sh -n tests/test_arxiv_progressive_prepaint.grease
sh tests/test_arxiv_progressive_prepaint.grease
@@ -153,11 +193,12 @@ case "${1:-}" in
exercise-information) exercise_information ;;
exercise-core) exercise_core ;;
exercise-workbench) exercise_workbench ;;
+ exercise-prepaint-network) exercise_prepaint_network ;;
exercise-scientific-media) exercise_scientific_media ;;
exercise-live-arxiv) exercise_live_arxiv ;;
exercise-live-arxiv-prepaint) exercise_live_arxiv_prepaint ;;
*)
- printf 'usage: %s {install-dependencies|build-idric|verify-pdf-harvester|exercise-information|exercise-core|exercise-workbench|exercise-scientific-media|exercise-live-arxiv|exercise-live-arxiv-prepaint}\n' "$0" >&2
+ printf 'usage: %s {install-dependencies|build-idric|verify-pdf-harvester|exercise-information|exercise-core|exercise-workbench|exercise-prepaint-network|exercise-scientific-media|exercise-live-arxiv|exercise-live-arxiv-prepaint}\n' "$0" >&2
exit 2
;;
esac
diff --git a/bin/icu_search.grease b/bin/icu_search.grease
new file mode 100755
index 0000000..cd037cd
--- /dev/null
+++ b/bin/icu_search.grease
@@ -0,0 +1,18 @@
+#!/bin/sh
+set -eu
+
+if test "$#" -eq 0; then
+ printf 'usage: %s SEARCH WORDS...\n' "$0" >&2
+ exit 2
+fi
+
+icu_command=${IB_ICU:-icu}
+jq_command=${IB_JQ:-jq}
+
+# With the ordinary space IFS, "$*" is the shell operation the original tiny
+# search command was remembering. PS1 is only the interactive prompt. jq owns
+# UTF-8 form encoding here; replacing %20 with + gives the familiar query form.
+query=$*
+encoded_query=$(printf '%s' "$query" | "$jq_command" -sRr @uri | sed 's/%20/+/g')
+
+exec "$icu_command" get "https://www.google.com/search?q=$encoded_query"
diff --git a/bin/prepaint_predecision.grease b/bin/prepaint_predecision.grease
new file mode 100755
index 0000000..6852304
--- /dev/null
+++ b/bin/prepaint_predecision.grease
@@ -0,0 +1,118 @@
+#!/bin/sh
+set -eu
+
+if test "$#" -ne 1; then
+ printf 'usage: %s URL\n' "$0" >&2
+ exit 2
+fi
+
+input_url=$1
+case $input_url in
+ http://*) authority_and_rest=${input_url#http://} ;;
+ https://*) authority_and_rest=${input_url#https://} ;;
+ *)
+ printf 'predecision: URL must begin with http:// or https://\n' >&2
+ exit 2
+ ;;
+esac
+
+authority=${authority_and_rest%%/*}
+authority=${authority%%\?*}
+authority=${authority%%\#*}
+case $authority in
+ ''|*@*|'['*)
+ printf 'predecision: unsupported or empty URL authority\n' >&2
+ exit 2
+ ;;
+esac
+host=${authority%%:*}
+
+dig_command=${IB_DIG:-dig}
+traceroute_command=${IB_TRACEROUTE:-traceroute}
+timeout_command=${IB_TIMEOUT:-timeout}
+
+work_directory=$(mktemp -d)
+cleanup() {
+ rm -rf "$work_directory"
+}
+trap cleanup EXIT HUP INT TERM
+
+dig_status=unavailable
+if command -v "$dig_command" >/dev/null 2>&1; then
+ if "$timeout_command" 3 "$dig_command" +tries=1 +time=1 +stats \
+ "$host" A "$host" NS > "$work_directory/dig.txt" 2>&1
+ then
+ dig_status=ok
+ else
+ dig_status=failed
+ fi
+else
+ : > "$work_directory/dig.txt"
+fi
+
+route_status=unavailable
+if command -v "$traceroute_command" >/dev/null 2>&1; then
+ if "$timeout_command" 10 "$traceroute_command" -n -m 8 -w 1 -q 1 \
+ "$host" > "$work_directory/traceroute.txt" 2>&1
+ then
+ route_status=ok
+ else
+ route_status=failed
+ fi
+else
+ : > "$work_directory/traceroute.txt"
+fi
+
+dns_milliseconds=$(awk '/Query time:/ { print $(NF - 1); exit }' "$work_directory/dig.txt")
+test -n "$dns_milliseconds" || dns_milliseconds=unknown
+address_records=$(awk '$4 == "A" || $4 == "AAAA" { amount += 1 } END { print amount + 0 }' \
+ "$work_directory/dig.txt")
+namespace_records=$(awk '$4 == "NS" { amount += 1 } END { print amount + 0 }' \
+ "$work_directory/dig.txt")
+route_hops=$(awk '/^[[:space:]]*[0-9]+[[:space:]]/ { hop = $1 } END { if (hop == "") print 0; else print hop }' \
+ "$work_directory/traceroute.txt")
+route_last_milliseconds=$(awk '
+ /^[[:space:]]*[0-9]+[[:space:]]/ {
+ for (field = 1; field <= NF; field += 1) {
+ if ($field == "ms" && field > 1) last = $(field - 1)
+ }
+ }
+ END { if (last == "") print "unknown"; else print last }
+' "$work_directory/traceroute.txt")
+
+known_asset_count=${IB_KNOWN_ASSET_COUNT:-unknown}
+known_asset_bytes=${IB_KNOWN_ASSET_BYTES:-unknown}
+assumed_bytes_per_second=${IB_ASSUMED_BYTES_PER_SECOND:-unknown}
+estimated_transfer_seconds=unknown
+decision=measure-assets-before-heavy-renderer
+
+case $known_asset_bytes:$assumed_bytes_per_second in
+ *[!0-9:]*|:*|*:0) ;;
+ *)
+ estimated_transfer_seconds=$((
+ (known_asset_bytes + assumed_bytes_per_second - 1) / assumed_bytes_per_second
+ ))
+ if test "$estimated_transfer_seconds" -ge 300; then
+ decision=keep-prepaint-unless-user-escalates
+ else
+ decision=prepaint-then-bounded-escalation
+ fi
+ ;;
+esac
+
+printf 'predecision\t1\n'
+printf 'url\t%s\n' "$input_url"
+printf 'host\t%s\n' "$host"
+printf 'dig-status\t%s\n' "$dig_status"
+printf 'dns-ms\t%s\n' "$dns_milliseconds"
+printf 'address-records\t%s\n' "$address_records"
+printf 'namespace-records\t%s\n' "$namespace_records"
+printf 'traceroute-status\t%s\n' "$route_status"
+printf 'route-hops\t%s\n' "$route_hops"
+printf 'route-last-ms\t%s\n' "$route_last_milliseconds"
+printf 'known-asset-count\t%s\n' "$known_asset_count"
+printf 'known-asset-bytes\t%s\n' "$known_asset_bytes"
+printf 'assumed-bytes-per-second\t%s\n' "$assumed_bytes_per_second"
+printf 'estimated-transfer-seconds\t%s\n' "$estimated_transfer_seconds"
+printf 'decision\t%s\n' "$decision"
+printf 'route-warning\t%s\n' 'DNS and traceroute are diagnostics, not page-load predictions.'
diff --git a/bin/termux_prepaint_url.grease b/bin/termux_prepaint_url.grease
new file mode 100755
index 0000000..dc86701
--- /dev/null
+++ b/bin/termux_prepaint_url.grease
@@ -0,0 +1,108 @@
+#!/bin/sh
+set -eu
+
+repository_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
+
+if test "$#" -ne 1; then
+ printf 'usage: %s URL\n' "$0" >&2
+ exit 2
+fi
+
+requested_url=$1
+LC_ALL=C
+export LC_ALL
+
+if test "${#requested_url}" -gt 2048; then
+ printf 'ib-prepaint: URL exceeds 2048-byte request budget\n' >&2
+ exit 2
+fi
+
+if ! printf '%s\n' "$requested_url" |
+ grep -Eq '^([Hh][Tt][Tt][Pp]|[Hh][Tt][Tt][Pp][Ss])://[!-~]+$'
+then
+ printf 'ib-prepaint: URL must be one visible-ASCII absolute HTTP(S) URL\n' >&2
+ exit 2
+fi
+
+authority_and_rest=${requested_url#*://}
+authority=${authority_and_rest%%/*}
+authority=${authority%%\?*}
+authority=${authority%%\#*}
+case $authority in
+ ''|*@*|'['*)
+ printf 'ib-prepaint: unsupported or empty URL authority\n' >&2
+ exit 2
+ ;;
+esac
+
+icu_command=${IB_ICU:-icu}
+renderer=${IB_HTML_PREPAINTER:-$repository_root/src/build/exec/ib-html-prepaint}
+fetch_max_bytes=${IB_FETCH_MAX_BYTES:-4194304}
+artifact_max_bytes=${IB_PREPAINT_MAX_BYTES:-65536}
+
+case $fetch_max_bytes:$artifact_max_bytes in
+ *[!0-9:]*|:*|*:)
+ printf 'ib-prepaint: byte limits must be positive decimal integers\n' >&2
+ exit 2
+ ;;
+esac
+test "$fetch_max_bytes" -gt 0 && test "$artifact_max_bytes" -gt 0 || {
+ printf 'ib-prepaint: byte limits must be positive decimal integers\n' >&2
+ exit 2
+}
+
+command -v "$icu_command" >/dev/null 2>&1 || {
+ printf 'ib-prepaint: ICU executable not found: %s\n' "$icu_command" >&2
+ exit 127
+}
+test -x "$renderer" || {
+ printf 'ib-prepaint: Idriç renderer is not executable: %s\n' "$renderer" >&2
+ exit 127
+}
+
+work_directory=$(mktemp -d)
+cleanup() {
+ rm -rf "$work_directory"
+}
+trap cleanup EXIT HUP INT TERM
+
+html_path=$work_directory/page.html
+artifact_path=$work_directory/page.prepaint
+
+if ! "$icu_command" get "$requested_url" > "$html_path"; then
+ printf 'ib-prepaint: ICU fetch failed\n' >&2
+ exit 1
+fi
+
+html_bytes=$(wc -c < "$html_path" | tr -d ' ')
+test "$html_bytes" -gt 0 || {
+ printf 'ib-prepaint: ICU returned an empty response\n' >&2
+ exit 1
+}
+test "$html_bytes" -le "$fetch_max_bytes" || {
+ printf 'ib-prepaint: response exceeds %s-byte fetch budget\n' "$fetch_max_bytes" >&2
+ exit 1
+}
+
+if ! "$renderer" "$requested_url" "$requested_url" "$html_path" > "$artifact_path"; then
+ printf 'ib-prepaint: Idriç rendering failed\n' >&2
+ exit 1
+fi
+
+artifact_bytes=$(wc -c < "$artifact_path" | tr -d ' ')
+test "$artifact_bytes" -le "$artifact_max_bytes" || {
+ printf 'ib-prepaint: artifact exceeds %s-byte result budget\n' "$artifact_max_bytes" >&2
+ exit 1
+}
+
+tab=$(printf '\t')
+test "$(sed -n '1p' "$artifact_path")" = "ib-prepaint${tab}1" || {
+ printf 'ib-prepaint: renderer returned an invalid header\n' >&2
+ exit 1
+}
+test "$(tail -n 1 "$artifact_path")" = end || {
+ printf 'ib-prepaint: renderer returned an incomplete artifact\n' >&2
+ exit 1
+}
+
+cat "$artifact_path"
diff --git a/docs/prepaint-display-contract.md b/docs/prepaint-display-contract.md
new file mode 100644
index 0000000..47a0dbd
--- /dev/null
+++ b/docs/prepaint-display-contract.md
@@ -0,0 +1,83 @@
+# Prepaint display interchange
+
+`ib-prepaint` is a disposable, renderer-neutral stream for the small Android
+prepaint harness. It is not canonical history, a DOM serialization, or a promise
+that this will be IB's permanent cache encoding.
+
+## Version 1
+
+The file is UTF-8, tab-separated, and begins with:
+
+```text
+ib-prepaint 1
+```
+
+The whitespace between fields above represents one literal tab. Each revision is
+a complete replacement projection, not a patch:
+
+```text
+revision 1 partial
+requested-url https://example.test/requested
+resolved-url https://example.test/resolved
+title Example
+heading 1 First heading
+text Visible paragraph text
+link Link label /target
+row first cell second cell
+form Search /search
+image content://already-fetched-image Alternate text Caption /optional-link
+end
+```
+
+The revision sequence must strictly increase. Its state is `partial` or
+`complete`. A consumer can paint a partial revision immediately and replace it
+with each later revision without retaining or interpreting the original HTML.
+
+Backslash escapes are `\\`, `\t`, `\n`, and `\r`. Heading levels are 1 through 6.
+Rows have one or more cells. An image has an already-fetched source, alternate
+text, caption, and an optional navigation target. The fifth field may be omitted
+for an unlinked image. Keeping the target allows an HTML anchor wrapping an image
+to remain both a painted image and a usable link.
+
+The file/document-picker path bounds one artifact to 4 MiB of UTF-8-decoded text,
+32 revisions, and 4,096 information blocks per revision. The live Termux result
+path has a deliberately smaller 64 KiB UTF-8 bound so Binder truncation is never
+accepted as a page. Those are viewer safety limits, not an invitation for the
+producer to fill them.
+
+The Android harness accepts these local image sources:
+
+- `content:` and `file:` URIs;
+- `data:image/...;base64,...` values;
+- `asset:` and `resource:` names for deterministic bundled fixtures.
+
+It never fetches an image from the network. It preserves the decoded image's
+colors and aspect ratio, fits it within the phone width, and applies no tint,
+inversion, CSS, or crop.
+
+The harness's **Open** control reads a file through Android's document picker. A
+file whose first line starts with `ib-prepaint` is parsed strictly as this
+interchange. Any other bounded UTF-8 text file is painted as plain text: blank
+lines delimit paragraphs and a line consisting of one absolute `http://` or
+`https://` URL becomes a link. This fallback is for actual text, URL lists, and
+already-extracted article bodies; it is not a second HTML parser.
+
+**Replay** reopens a selected document before replaying its revisions, so a
+producer can replace the disposable file and the viewer can display the new
+projection without being rebuilt. If a replacement becomes unreadable, the last
+valid page stays visible.
+
+Version 1 directly covers the present Idriç `InformationView` fields: requested
+URL, resolved URL, title, headings, text blocks, links, table rows, and forms. The
+`image` record is the narrow addition needed to exercise the requested
+text-plus-images paint surface while image extraction is developed independently.
+
+Deleting this stream and every referenced cached image must leave tabs, history,
+labels, snapshots, and other durable browser records intact.
+
+The display harness exposes a search/navigation control, but fetching is not part
+of this format. Android sends the newest exact URL to a fixed Grease command using
+Termux `RUN_COMMAND`; ICU fetches bytes; Idriç emits this format; Android validates
+and paints it. Each boundary remains independently replaceable. DNS, route, and
+resource-cost diagnostics belong to browser policy or the developer inspector,
+never as synthetic page content.
diff --git a/docs/prepaint-policy-boundary.md b/docs/prepaint-policy-boundary.md
new file mode 100644
index 0000000..f800643
--- /dev/null
+++ b/docs/prepaint-policy-boundary.md
@@ -0,0 +1,107 @@
+# Page pre-paint, browser policy, and workbench diagnostics
+
+The page pre-painter and the developer workbench answer different questions.
+They must not be merged into one screen merely because both can be rendered as
+dark text and rows.
+
+## Page pre-paint
+
+The page surface contains information that came from the selected resource:
+
+- article paragraphs and headings;
+- links and their resolved targets;
+- page forms and controls when the browser can represent their action honestly;
+- table/data rows;
+- already-fetched images, unchanged;
+- a small browser-owned search/navigation control outside page content.
+
+The page surface does not contain fixture-universe counts, hot/warm/cold tab
+graphs, deployment topology invented by a test, DNS traces, route hops, cache
+classification, or storage totals. A real page may itself contain a topology
+image; that is ordinary page content. The bundled APK sample should not use such
+an image because it falsely suggests that IB adds the diagnostic to every page.
+
+Opening a plain UTF-8 file is a real pre-paint path. Ordinary prose is paintable
+without an `ib-prepaint` envelope. A file containing one absolute HTTP(S) URL per
+line becomes a link list. A saved HTML file still belongs to the Idriç HTML
+extractor; the Android display harness must not grow a second HTML parser.
+
+The HTML extractor retains links nested inside article paragraphs and retains
+an enclosing anchor on an image as a linked-image item. A remote `src` is only a
+fetch candidate: the background ICU path must validate the response and replace
+it with an already-fetched source before the Android harness paints the image.
+
+## Browser policy and the pre-decision probe
+
+Before starting a heavyweight renderer, browser policy can inspect bounded
+evidence:
+
+- whether a cached pre-paint is already useful;
+- known resource count and known/declared bytes;
+- prior measurements for the site and device;
+- DNS lookup state;
+- a deliberately sampled route diagnostic;
+- whether the page clearly requires JavaScript or another missing capability.
+
+`bin/prepaint_predecision.grease` is a first shell boundary for this evidence.
+It gives `dig` three seconds and asks for A plus NS data. It gives `traceroute`
+ten seconds, eight hops, and one probe per hop. Missing, filtered, or failed
+diagnostics remain data; they do not block the cheap paint.
+
+DNS time and traceroute hop latency are not estimates of total page-load time.
+They do not reveal asset count, transfer bandwidth, server computation, browser
+main-thread work, JavaScript work, decoding, or layout. The probe therefore
+labels them as diagnostics. Its only initial duration estimate is the explicit
+lower bound:
+
+```text
+ceil(known asset bytes / assumed measured bytes per second)
+```
+
+At five minutes or more, the stub keeps the useful pre-paint and requires a user
+or later policy decision before heavyweight escalation. A future policy should
+use observed per-device/site throughput instead of inventing a network rate.
+
+This probe is opt-in or sampled. `dig` and especially `traceroute` must not run on
+every navigation.
+
+## Developer workbench / inspector
+
+The workbench is where browser-owned facts and diagnostic controls belong:
+
+- logical-page universe and hot/warm/cold counts;
+- resident renderer working set and memory estimates;
+- cache/storage classification and bounded inspection;
+- resource graph size and known byte totals;
+- DNS and route probe results;
+- the pre-decision evidence and reason for allowing or deferring escalation;
+- controls for eviction, cache clearing, simulated pressure, and replay.
+
+These facts may explain why a page stayed in pre-paint mode, but they are not the
+page. A small status affordance can link from the page to the inspector without
+injecting the diagnostic graph into page content.
+
+## Search handoff
+
+Search is browser chrome, not a fake form copied out of a workbench fixture:
+
+```text
+query words
+ -> application/x-www-form-urlencoded query (spaces become +)
+ -> https://www.google.com/search?q=...
+ -> icu get URL
+ -> Idriç text/link/image extraction
+ -> disposable pre-paint
+ -> Android display
+```
+
+`bin/icu_search.grease` records the shell boundary. In the old shell idiom,
+`$*` is the expansion that joins positional parameters using the first `IFS`
+character; `PS1` is only the interactive prompt. The current stub uses `jq` for
+UTF-8 percent encoding and then changes encoded spaces to `+`. ICU, not curl or
+WebView, performs the GET.
+
+The standalone display APK has no Internet permission and cannot execute the
+host ICU binary. It may show and copy the exact request so the interaction is
+testable, but it must not pretend that local request construction is a fetched
+result. The integrated browser shell will own the executable ICU/Android bridge.
diff --git a/docs/url-recognition-strategy.txt b/docs/url-recognition-strategy.txt
new file mode 100644
index 0000000..a8b19c6
--- /dev/null
+++ b/docs/url-recognition-strategy.txt
@@ -0,0 +1,114 @@
+IB URL recognition strategy — staged, readable, and replaceable
+================================================================
+
+This file deliberately keeps two recognizers instead of one enormous regular
+expression.
+
+
+1. Cheap foreground recognizer
+------------------------------
+
+Purpose: turn an ordinary text file containing one URL per line into useful
+links without delaying first paint.
+
+Current rule, written as words:
+
+* begin at the start of the trimmed line;
+* find exactly "http://" or "https://";
+* require a nonempty authority before slash, question mark, or fragment;
+* reject whitespace, controls, non-ASCII bytes, and user information;
+* accept the remainder as a candidate, not as proof that the resource exists.
+
+This intentionally mirrors the current ICU input boundary. It will miss valid
+internationalized or unusual URLs. False negatives are acceptable in the first
+paint because the background recognizer can add links later.
+
+
+2. Authoritative recognizer
+---------------------------
+
+Do not validate the full web with a copied mega-regex. Parse components.
+
+The complete generic syntax is the collected ABNF in RFC 3986 Appendix A:
+
+https://www.rfc-editor.org/rfc/rfc3986.html#appendix-A
+
+That grammar covers scheme, authority, user information, host forms including
+IP literals, port, all path variants, query, fragment, percent encoding,
+reserved characters, and unreserved characters. Scheme-specific rules still
+apply after generic parsing. RFC 3986 itself warns that generic syntax is a
+superset; parsing a shape is not the same as proving it is dereferenceable.
+
+IB's current authoritative HTTP subset should call the same Idriç URL parser as
+ICU rather than independently drifting. Today ICU intentionally supports only
+http/https, visible ASCII targets, no user information, and no IPv6 literal.
+When ICU grows IDNA/IPv6/IRI handling, IB should consume that typed result.
+
+
+3. Background enrichment
+------------------------
+
+After plain text is already visible, a low-priority pass may:
+
+* scan URLs embedded inside prose rather than occupying a whole line;
+* trim sentence punctuation using surrounding-text rules;
+* resolve HTML href and src values against the fetched base URL;
+* preserve an anchor's label and absolute target;
+* preserve an image's alternate text, caption, source URL, and link target;
+* ask ICU to fetch the candidate;
+* classify an image from response Content-Type plus decoded magic, not merely a
+ filename extension;
+* replace a remote image reference with an already-fetched local/content source
+ in a later pre-paint revision.
+
+An `` is a link even when it wraps text. An `` wrapping an image
+must retain both behaviors: paint the fetched image as an image and keep its
+navigation target. `InformationItem.Image` now carries source, alternate text,
+caption/title, and the enclosing link target. The sibling Android interchange
+accepts the same optional linked-image field. Until ICU has fetched and checked
+the bytes, the source remains a remote reference; only the later revision may
+replace it with a local/content source and claim that it is paintable.
+
+
+4. Readable-regex references
+----------------------------
+
+The remembered Ruby project is VerbalExpressions. Its URL example literally
+uses names such as start_of_line, find, maybe, anything_but, and end_of_line:
+
+https://github.com/ryan-endacott/verbal_expressions
+
+It is MIT licensed and useful as a readable sketching vocabulary. Its small URL
+example is not a complete URL validator; it essentially accepts http(s), an
+optional www, and then non-space text. Keep it as a strategy/prototyping
+reference, not as IB's source of truth.
+
+Ruby's maintained URI module is a better executable reference for component
+parsing and exposes parser/regular-expression facilities:
+
+https://github.com/ruby/uri
+
+Neither Ruby dependency belongs on the foreground phone path. A slow comparison
+tool or fixture oracle is acceptable while the Idriç/ICU parser is developed.
+
+
+5. Test ladder
+--------------
+
+Keep separate fixtures for:
+
+* cheap obvious http and https lines;
+* relative href resolution against a base URL;
+* punctuation around a URL in prose;
+* query, fragment, percent encoding, and an explicit port;
+* IPv4, IPv6 literals, IDNA, and Unicode IRI input;
+* malformed percent escapes, spaces, controls, and empty hosts;
+* user information that must not leak into painted text;
+* a text URL that resolves to HTML;
+* a text URL that resolves to an image despite having no image extension;
+* an image-looking extension whose response is not an image;
+* an anchor wrapping an image.
+
+The cheap recognizer may deliberately fail advanced positive cases. The
+authoritative/background path may not silently convert malformed or dangerous
+input into a clickable target.
diff --git a/src/HtmlPrepaint.idric b/src/HtmlPrepaint.idric
new file mode 100644
index 0000000..444c1b2
--- /dev/null
+++ b/src/HtmlPrepaint.idric
@@ -0,0 +1,103 @@
+module HtmlPrepaint
+
+import IB.Information
+import System
+import System.File
+
+%default total
+
+program_arguments : List String → List String
+program_arguments [] = []
+program_arguments (_ :: arguments) = arguments
+
+take_first : Nat → List value → List value
+take_first Z values = []
+take_first (S amount) [] = []
+take_first (S amount) (value :: rest) = value :: take_first amount rest
+
+clip_chars : Nat → List Char → List Char
+clip_chars Z values = []
+clip_chars (S amount) [] = []
+clip_chars (S amount) (value :: rest) = value :: clip_chars amount rest
+
+clip_field : Nat → String → String
+clip_field amount value = pack (clip_chars amount (unpack value))
+
+bounded_or_empty : Nat → String → String
+bounded_or_empty amount value =
+ if length (unpack value) <= amount then value else ""
+
+escape_chars : List Char → List Char
+escape_chars [] = []
+escape_chars ('\\' :: rest) = '\\' :: '\\' :: escape_chars rest
+escape_chars ('\t' :: rest) = '\\' :: 't' :: escape_chars rest
+escape_chars ('\n' :: rest) = '\\' :: 'n' :: escape_chars rest
+escape_chars ('\r' :: rest) = '\\' :: 'r' :: escape_chars rest
+escape_chars (value :: rest) = value :: escape_chars rest
+
+escape_field : String → String
+escape_field value = pack (escape_chars (unpack value))
+
+join_with_tabs : List String → String
+join_with_tabs [] = ""
+join_with_tabs [value] = escape_field value
+join_with_tabs (value :: rest) = escape_field value ++ "\t" ++ join_with_tabs rest
+
+put_record : String → List String → IO ()
+put_record name values = putStrLn (join_with_tabs (name :: values))
+
+bounded_content : String → String
+bounded_content = clip_field 192
+
+bounded_target : String → String
+bounded_target = bounded_or_empty 192
+
+bounded_image_field : String → String
+bounded_image_field = bounded_or_empty 64
+
+bounded_cells : List String → List String
+bounded_cells cells = map (clip_field 48) (take_first 4 cells)
+
+render_item : InformationItem → IO ()
+render_item (Heading text) = put_record "heading" ["1", bounded_content text]
+render_item (TextBlock text) = put_record "text" [bounded_content text]
+render_item (Link label target) =
+ put_record "link" [bounded_content label, bounded_target target]
+render_item (Image source alternate caption target) =
+ put_record "image"
+ [ bounded_image_field source
+ , bounded_image_field alternate
+ , bounded_image_field caption
+ , bounded_image_field target
+ ]
+render_item (TableRow cells) = put_record "row" (bounded_cells cells)
+render_item (Form label action) =
+ put_record "form" [bounded_content label, bounded_target action]
+
+render_items : List InformationItem → IO ()
+render_items [] = pure ()
+render_items (item :: rest) = do
+ render_item item
+ render_items rest
+
+render_prepaint : InformationView → IO ()
+render_prepaint (View requested_url resolved_url title items) = do
+ put_record "ib-prepaint" ["1"]
+ put_record "revision" ["1", "complete"]
+ put_record "requested-url" [bounded_or_empty 2048 requested_url]
+ put_record "resolved-url" [bounded_or_empty 2048 resolved_url]
+ put_record "title" [clip_field 256 title]
+ render_items (take_first 48 items)
+ put_record "end" []
+
+covering
+main : IO ()
+main = do
+ arguments <- getArgs
+ case program_arguments arguments of
+ [requested_url, resolved_url, html_path] ⇒ do
+ result <- readFile html_path
+ case result of
+ Left _ ⇒ die ("could not read fetched HTML: " ++ html_path)
+ Right html ⇒ render_prepaint (extract_html requested_url resolved_url html)
+ _ ⇒ die "usage: ib-html-prepaint REQUESTED_URL RESOLVED_URL HTML"
diff --git a/src/IB/Information.idric b/src/IB/Information.idric
index 337d535..ad0cf0e 100644
--- a/src/IB/Information.idric
+++ b/src/IB/Information.idric
@@ -7,6 +7,7 @@ data InformationItem
= Heading String
| TextBlock String
| Link String String
+ | Image String String String String
| TableRow (List String)
| Form String String
@@ -36,7 +37,7 @@ data Capture
| CaptureTitle
| CaptureHeading String
| CaptureText String
- | CaptureLink String
+ | CaptureLink String Bool Capture
| CaptureForm String
| CaptureCell String
@@ -158,6 +159,47 @@ starts_with_chars (_ :: _) [] = False
starts_with_chars (wanted :: wanted_rest) (value :: rest) =
wanted == value && starts_with_chars wanted_rest rest
+strip_prefix_chars : List Char → List Char → Maybe (List Char)
+strip_prefix_chars [] values = Just values
+strip_prefix_chars (_ :: _) [] = Nothing
+strip_prefix_chars (wanted :: wanted_rest) (value :: rest) =
+ if wanted == value
+ then strip_prefix_chars wanted_rest rest
+ else Nothing
+
+visible_http_character : Char → Bool
+visible_http_character value = ord value > 32 && ord value < 127
+
+authority_end : Char → Bool
+authority_end value = value == '/' || value == '?' || value == '#'
+
+safe_authority_tail : List Char → Bool
+safe_authority_tail [] = True
+safe_authority_tail (value :: rest) =
+ if authority_end value
+ then True
+ else visible_http_character value && value /= '@' && safe_authority_tail rest
+
+safe_authority : List Char → Bool
+safe_authority [] = False
+safe_authority ('[' :: _) = False
+safe_authority (value :: rest) =
+ not (authority_end value) && visible_http_character value && value /= '@' &&
+ safe_authority_tail rest
+
+http_tail : String → Maybe (List Char)
+http_tail text =
+ case strip_prefix_chars (unpack "http://") (unpack text) of
+ Just rest ⇒ Just rest
+ Nothing ⇒ strip_prefix_chars (unpack "https://") (unpack text)
+
+public export
+cheap_http_url : String → Bool
+cheap_http_url text =
+ case http_tail text of
+ Nothing ⇒ False
+ Just rest ⇒ safe_authority rest
+
drop_chars : Nat → List Char → List Char
drop_chars Z values = values
drop_chars (S amount) [] = []
@@ -216,7 +258,7 @@ capture_target NoCapture = ""
capture_target CaptureTitle = "title"
capture_target (CaptureHeading name) = name
capture_target (CaptureText name) = name
-capture_target (CaptureLink _) = "a"
+capture_target (CaptureLink _ _ _) = "a"
capture_target (CaptureForm _) = "form"
capture_target (CaptureCell name) = name
@@ -231,8 +273,10 @@ finish_capture (CaptureHeading _) text title items row =
if text == "" then (title, items, row) else (title, Heading text :: items, row)
finish_capture (CaptureText _) text title items row =
if text == "" then (title, items, row) else (title, TextBlock text :: items, row)
-finish_capture (CaptureLink href) text title items row =
- if text == "" && href == "" then (title, items, row) else (title, Link text href :: items, row)
+finish_capture (CaptureLink href has_image parent) text title items row =
+ if text == "" && (href == "" || has_image)
+ then (title, items, row)
+ else (title, Link text href :: items, row)
finish_capture (CaptureForm action) text title items row =
if text == "" && action == "" then (title, items, row) else (title, Form text action :: items, row)
finish_capture (CaptureCell _) text title items Nothing = (title, items, Nothing)
@@ -250,6 +294,32 @@ add_standalone_text text Nothing items =
let cleaned = clean_text text in
if cleaned == "" then items else TextBlock cleaned :: items
+capture_parent : Capture → Capture
+capture_parent (CaptureLink _ _ parent) = parent
+capture_parent _ = NoCapture
+
+capture_link : Capture → String
+capture_link (CaptureLink href _ _) = href
+capture_link _ = ""
+
+mark_link_image : Capture → Capture
+mark_link_image (CaptureLink href _ parent) = CaptureLink href True parent
+mark_link_image capture = capture
+
+image_from_tag : String → String → Maybe InformationItem
+image_from_tag href tag =
+ let source = attribute_or_empty "src" tag in
+ if source == ""
+ then Nothing
+ else Just (Image source (attribute_or_empty "alt" tag)
+ (attribute_or_empty "title" tag) href)
+
+add_image : String → String → List InformationItem → List InformationItem
+add_image href tag items =
+ case image_from_tag href tag of
+ Nothing ⇒ items
+ Just image ⇒ image :: items
+
update_skip : String → Bool → SkipState → SkipState
update_skip name closing NotSkipping = NotSkipping
update_skip name closing (Skipping target depth) =
@@ -291,7 +361,11 @@ collect_information (HtmlTag tag :: rest) skip capture chars row title items =
else if (name == "p" || name == "li") && not closing
then collect_information rest NotSkipping (CaptureText name) [] row title items
else if name == "a" && not closing
- then collect_information rest NotSkipping (CaptureLink (attribute_or_empty "href" tag)) [] row title items
+ then collect_information rest NotSkipping
+ (CaptureLink (attribute_or_empty "href" tag) False NoCapture) [] row title items
+ else if name == "img" && not closing
+ then collect_information rest NotSkipping NoCapture [] row title
+ (add_image "" tag items)
else if name == "form" && not closing
then collect_information rest NotSkipping (CaptureForm (attribute_or_empty "action" tag)) [] row title items
else if name == "tr" && not closing
@@ -307,9 +381,24 @@ collect_information (HtmlTag tag :: rest) skip capture chars row title items =
if closing && name == capture_target capture
then let text = clean_text (pack (reverse chars)) in
let (next_title, next_items, next_row) = finish_capture capture text title items row in
- collect_information rest NotSkipping NoCapture [] next_row next_title next_items
+ collect_information rest NotSkipping (capture_parent capture) [] next_row next_title next_items
else if not closing && skipped_tag name
then collect_information rest (Skipping name Z) capture chars row title items
+ else if name == "a" && not closing
+ then case capture of
+ CaptureLink _ _ _ ⇒ collect_information rest NotSkipping capture chars row title items
+ _ ⇒
+ let text = clean_text (pack (reverse chars)) in
+ let (next_title, next_items, next_row) = finish_capture capture text title items row in
+ collect_information rest NotSkipping
+ (CaptureLink (attribute_or_empty "href" tag) False capture)
+ [] next_row next_title next_items
+ else if name == "img" && not closing
+ then let next_capture = mark_link_image capture in
+ let text = clean_text (pack (reverse chars)) in
+ let (next_title, next_items, next_row) = finish_capture next_capture text title items row in
+ collect_information rest NotSkipping next_capture [] next_row next_title
+ (add_image (capture_link capture) tag next_items)
else collect_information rest NotSkipping capture chars row title items
public export
@@ -318,6 +407,31 @@ extract_html requested_url resolved_url html =
let (title, items) = collect_information (tokenize html) NotSkipping NoCapture [] Nothing "" [] in
View requested_url resolved_url title items
+finish_plain_line : List Char → List InformationItem → List InformationItem
+finish_plain_line chars items =
+ let line = clean_text (pack (reverse chars)) in
+ if line == ""
+ then items
+ else if cheap_http_url line
+ then Link line line :: items
+ else TextBlock line :: items
+
+plain_text_items : List Char → List Char → List InformationItem → List InformationItem
+plain_text_items [] chars items = reverse (finish_plain_line chars items)
+plain_text_items ('\n' :: rest) chars items =
+ plain_text_items rest [] (finish_plain_line chars items)
+plain_text_items (value :: rest) chars items =
+ plain_text_items rest (value :: chars) items
+
+public export
+prepaint_text : String → String → InformationView
+prepaint_text title body = View "" "" title (plain_text_items (unpack body) [] [])
+
+public export
+prepaint_full_text : String → String → String → InformationView
+prepaint_full_text requested_url resolved_url html =
+ extract_html requested_url resolved_url html
+
public export
has_table_row : List String → InformationView → Bool
has_table_row wanted (View _ _ _ items) = go items
@@ -336,6 +450,16 @@ has_link wanted_text wanted_href (View _ _ _ items) = go items
go (Link text href :: rest) = (text == wanted_text && href == wanted_href) || go rest
go (_ :: rest) = go rest
+public export
+has_image : String → String → String → InformationView → Bool
+has_image wanted_alt wanted_source wanted_href (View _ _ _ items) = go items
+ where
+ go : List InformationItem → Bool
+ go [] = False
+ go (Image source alt caption href :: rest) =
+ (alt == wanted_alt && source == wanted_source && href == wanted_href) || go rest
+ go (_ :: rest) = go rest
+
public export
has_form : String → InformationView → Bool
has_form wanted_action (View _ _ _ items) = go items
@@ -354,6 +478,7 @@ item_text : InformationItem → String
item_text (Heading text) = text
item_text (TextBlock text) = text
item_text (Link text href) = text ++ " " ++ href
+item_text (Image source alt caption href) = alt ++ " " ++ source ++ " " ++ caption ++ " " ++ href
item_text (TableRow cells) = join_cells cells
item_text (Form text action) = text ++ " " ++ action
@@ -387,6 +512,9 @@ render_item : Nat → InformationItem → String
render_item width (Heading text) = clip_text width ("# " ++ text)
render_item width (TextBlock text) = clip_text width text
render_item width (Link text href) = clip_text width ("link: " ++ text ++ " -> " ++ href)
+render_item width (Image source alt caption href) =
+ clip_text width ("image: " ++ alt ++ " -> " ++ source ++
+ if href == "" then "" else " [link " ++ href ++ "]")
render_item width (TableRow cells) = clip_text width ("| " ++ join_with " | " cells ++ " |")
render_item width (Form text action) = clip_text width ("form: " ++ text ++ " -> " ++ action)
diff --git a/src/InformationSmoke.idric b/src/InformationSmoke.idric
index 8d04202..5565112 100644
--- a/src/InformationSmoke.idric
+++ b/src/InformationSmoke.idric
@@ -35,6 +35,23 @@ shell_only =
"" ++
"