diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-10-02 18:21:26 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-10-03 14:24:54 +0200 |
| commit | d8f5005ba2c9a249ce5ce8976d9c3754a3a7458e (patch) | |
| tree | 960ece9a79c58fbe60033b91f6ec685795eff8a1 /packages/meshbay-hub | |
| parent | ede70b0fcc006d3ced8ef0d1d7115593f6627aef (diff) | |
| download | meshbay-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.py | 90 |
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" |