aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-02 18:21:26 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-03 14:24:54 +0200
commitd8f5005ba2c9a249ce5ce8976d9c3754a3a7458e (patch)
tree960ece9a79c58fbe60033b91f6ec685795eff8a1 /packages/meshbay-hub
parentede70b0fcc006d3ced8ef0d1d7115593f6627aef (diff)
downloadmeshbay-d8f5005ba2c9a249ce5ce8976d9c3754a3a7458e.tar.gz
feat(android): downloads to disk and uploads through the system picker
Native save over the Storage Access Framework and MediaStore, chunks sent as binary bridge messages, a chosen folder that has gone asks rather than redirects, unfinished files removed on abort and after a killed process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/tests/test_android_downloads.py90
1 files changed, 90 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_android_downloads.py b/packages/meshbay-hub/tests/test_android_downloads.py
new file mode 100644
index 0000000..96da9a4
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_android_downloads.py
@@ -0,0 +1,90 @@
+"""
+Downloads in the Android application, pinned by reading the source.
+
+The page's contract with a native save target is platform.js `nativeSave`: a
+sink with write/close/abort, an `open` only when the target can open the file,
+and nothing that names a path. The Android sink keeps it; what it may open is
+downloads.js `OPENABLE`, held equal here because a second copy of a list of
+safe types is how an `.html` gets opened one day.
+"""
+
+import re
+from pathlib import Path
+
+import pytest
+from spa_source import STATIC
+
+PACKAGES = Path(__file__).resolve().parents[2]
+MAIN = PACKAGES / "meshbay-android" / "app" / "src" / "main"
+SAVE = MAIN / "kotlin" / "org" / "meshbay" / "client" / "save"
+SHIM = MAIN / "assets" / "bridge" / "meshbay-bridge.js"
+
+pytestmark = pytest.mark.skipif(not SAVE.exists(), reason="android sources not present")
+
+
+def _read(path: Path) -> str:
+ return path.read_text(encoding="utf-8")
+
+
+def test_what_may_be_opened_is_the_pages_list():
+ js = _read(STATIC / "downloads.js").split("const OPENABLE = {", 1)[1].split("};", 1)[0]
+ kt = _read(SAVE / "SaveNames.kt").split("val OPENABLE = mapOf(", 1)[1].split("\n )", 1)[0]
+ page = {k: v.split(";")[0] for k, v in re.findall(r"(\w+): '([^']+)'", js)}
+ android = dict(re.findall(r'"(\w+)" to "([^"]+)"', kt))
+ assert page and android == page, set(android.items()) ^ set(page.items())
+
+
+def test_open_is_offered_only_where_the_target_says_so():
+ shim = _read(SHIM)
+ save = shim.split("saveFile: async", 1)[1].split("\n },", 1)[0]
+ assert "if (handle.openable) sink.open" in save
+ sinks = _read(SAVE / "SaveSinks.kt")
+ assert '.put("openable", SaveNames.openableType(shown) != null)' in sinks
+ opening = sinks.split("fun open(id: Long)", 1)[1].split("\n }", 1)[0]
+ assert "SaveNames.openableType(name) ?: throw Refused" in opening
+
+
+def test_the_page_is_told_names_never_uris():
+ sinks = _read(SAVE / "SaveSinks.kt")
+ for fn in ("fun chooseFolder", "fun getFolder", "fun begin"):
+ body = sinks.split(fn, 1)[1].split("\n fun ", 1)[0]
+ returned = re.findall(r'put\("(\w+)"', body)
+ assert not {"uri", "path", "tree"} & set(returned), (fn, returned)
+ assert "return displayName(treeDocument(tree))" in sinks
+
+
+def test_the_save_channels_are_the_desktops():
+ preload = _read(PACKAGES / "meshbay-client" / "src" / "preload.js")
+ shim = _read(SHIM)
+
+ def channels(text: str, call: str) -> set[str]:
+ return set(re.findall(rf"{call}\('((?:save|folder):[\w:-]+)'", text))
+
+ assert channels(shim, "call") == channels(preload, r"ipcRenderer\.invoke")
+ assert "nativeSave: true" in shim
+
+
+def test_an_unfinished_file_never_carries_the_final_name():
+ sinks = _read(SAVE / "SaveSinks.kt")
+ assert '"$wanted.part"' in sinks
+ assert "MediaStore.MediaColumns.IS_PENDING, 1" in sinks
+ assert "MediaStore.MediaColumns.IS_PENDING, 0" in sinks
+ abort = sinks.split("fun abort(id: Long)", 1)[1].split("\n }", 1)[0]
+ assert "delete(sink.uri)" in abort
+ assert "fun cleanUpAfterAKilledProcess" in sinks
+ assert "saves.cleanUpAfterAKilledProcess()" in _read(
+ MAIN / "kotlin" / "org" / "meshbay" / "client" / "MainActivity.kt")
+
+
+def test_a_chosen_folder_that_has_gone_is_not_silently_replaced():
+ begin = _read(SAVE / "SaveSinks.kt").split("fun begin(", 1)[1].split("\n fun ", 1)[0]
+ assert "auto && !(configuredTree != null && tree == null)" in begin
+
+
+def test_writes_are_binary_and_awaited():
+ shim = _read(SHIM)
+ assert "head.setUint32(0, 0x4d424231)" in shim
+ assert "port.postMessage(frame)" in shim
+ bridge = _read(MAIN / "kotlin" / "org" / "meshbay" / "client" / "bridge" / "Bridge.kt")
+ before = bridge.split("TYPE_ARRAY_BUFFER", 1)[0]
+ assert "!isMainFrame" in before, "a binary message must pass the same sender check"