diff --git a/mobile/android/.gitignore b/mobile/android/.gitignore
new file mode 100644
index 0000000..5f9e3f7
--- /dev/null
+++ b/mobile/android/.gitignore
@@ -0,0 +1,3 @@
+build/
+*.keystore
+release-keystore-password.txt
diff --git a/mobile/android/AndroidManifest.xml b/mobile/android/AndroidManifest.xml
new file mode 100644
index 0000000..9440afe
--- /dev/null
+++ b/mobile/android/AndroidManifest.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/mobile/android/README.md b/mobile/android/README.md
new file mode 100644
index 0000000..9629b4a
--- /dev/null
+++ b/mobile/android/README.md
@@ -0,0 +1,39 @@
+# Pithos for Android
+
+The browser app (`Pithos/web`) in a WebView, plus what a phone adds:
+
+- **Save to Pithos** in other apps' share menus: shared files wait in a bar
+ above the file list and go into the open folder with *Upload here*.
+- **Downloads** land in the phone's Downloads folder (MediaStore on Android 10+).
+- **Upload** opens the system file picker (several files at once).
+- Links to other sites open in the phone's browser; Back steps back in the app.
+
+The page's own files ship inside the APK and are answered by the app under
+the gateway's origin (`https://s3.silentmode.st`), so the page is the same
+same-origin client as in a browser: sign-in, the account API and S3 go to
+the network, and files are encrypted and decrypted in the page. The origin
+moves to `https://pithos.silentmode.st` once that name has a DNS record
+(a remembered sign-in is kept per origin, so it is asked for again once).
+
+Sign-in needs Ed25519 in Web Crypto: an up-to-date Android System WebView
+(Chrome 137 or newer).
+
+## Build
+
+No Gradle: JDK 8, build-tools 30.0.3 and platform android-30, like Ariadne.
+
+```powershell
+powershell -File build.ps1 # build\pithos-debug.apk
+powershell -File build.ps1 -Release # build\pithos-v.apk, release key
+```
+
+The release key (`release.keystore` + `release-keystore-password.txt`) is
+made on the first release build and must be kept forever: Android installs
+an update only when it is signed by the same key. Neither file goes in git.
+
+## Not yet
+
+- Your own Sia account (s3d on your computer) is not reachable from a phone;
+ the app opens Drives on Silent Mode, and S3 keys for them.
+- Photo backup, share links from the app, Theseus ID sign-in.
+- iOS (needs a Mac to build).
diff --git a/mobile/android/build.ps1 b/mobile/android/build.ps1
new file mode 100644
index 0000000..abe2b4e
--- /dev/null
+++ b/mobile/android/build.ps1
@@ -0,0 +1,95 @@
+# Builds Pithos for Android with the bare Android SDK: no Gradle, no AGP.
+# Toolchain: JDK 8 + build-tools 30.0.3 + platform android-30.
+# The page is Pithos/web, copied into the APK's assets at build time.
+# Usage: powershell -File build.ps1 -> build\pithos-debug.apk (debug key)
+# powershell -File build.ps1 -Release -> build\pithos-v.apk (release key)
+param([switch]$Release)
+
+$ErrorActionPreference = "Stop"
+$VERSION = "0.1.0"
+$VERSION_CODE = 1
+
+$SDK = "$env:LOCALAPPDATA\Android\Sdk"
+$BT = "$SDK\build-tools\30.0.3"
+$PLATFORM = "$SDK\platforms\android-30\android.jar"
+$JDK = "C:\Program Files\Java\jdk1.8.0_202"
+$ROOT = $PSScriptRoot
+$WEB = Resolve-Path "$ROOT\..\..\web"
+$OUT = "$ROOT\build"
+
+$env:JAVA_HOME = $JDK
+$env:Path = "$JDK\bin;$env:Path"
+
+if (Test-Path $OUT) { Remove-Item -Recurse -Force $OUT }
+New-Item -ItemType Directory -Force "$OUT\classes", "$OUT\dex", "$OUT\gen", "$OUT\assets\web" | Out-Null
+
+Write-Host "[0/6] page + version"
+Copy-Item -Recurse "$WEB\*" "$OUT\assets\web\"
+@"
+package st.silentmode.pithos;
+final class BuildInfo { static final String VERSION = "$VERSION"; private BuildInfo() {} }
+"@ | Set-Content -Encoding ASCII "$OUT\gen\BuildInfo.java"
+$manifest = (Get-Content -Raw "$ROOT\AndroidManifest.xml") `
+ -replace 'android:versionCode="\d+"', "android:versionCode=`"$VERSION_CODE`"" `
+ -replace 'android:versionName="[^"]*"', "android:versionName=`"$VERSION`""
+Set-Content -Encoding UTF8 "$OUT\AndroidManifest.xml" $manifest
+
+Write-Host "[1/6] aapt2 compile+link"
+& "$BT\aapt2.exe" compile --dir "$ROOT\res" -o "$OUT\res.zip"
+if ($LASTEXITCODE -ne 0) { throw "aapt2 compile failed" }
+& "$BT\aapt2.exe" link -o "$OUT\app-unsigned.apk" -I $PLATFORM --manifest "$OUT\AndroidManifest.xml" `
+ --java "$OUT\gen" --min-sdk-version 24 --target-sdk-version 30 -A "$OUT\assets" "$OUT\res.zip"
+if ($LASTEXITCODE -ne 0) { throw "aapt2 link failed" }
+
+Write-Host "[2/6] javac"
+$sources = Get-ChildItem "$ROOT\src","$OUT\gen" -Recurse -Filter *.java | ForEach-Object { $_.FullName }
+& "$JDK\bin\javac.exe" -source 1.8 -target 1.8 -encoding UTF-8 -Xlint:-options `
+ -bootclasspath $PLATFORM -classpath $PLATFORM -d "$OUT\classes" @sources
+if ($LASTEXITCODE -ne 0) { throw "javac failed" }
+
+Write-Host "[3/6] d8"
+$classesDir = "$OUT\classes"
+Push-Location $classesDir
+$classes = Get-ChildItem -Recurse -Filter *.class | ForEach-Object { $_.FullName.Substring($classesDir.Length + 1) }
+& "$BT\d8.bat" --release --min-api 24 --lib $PLATFORM --output "$OUT\dex" @classes
+Pop-Location
+if ($LASTEXITCODE -ne 0) { throw "d8 failed" }
+
+Write-Host "[4/6] add classes.dex"
+Push-Location "$OUT\dex"
+& "$JDK\bin\jar.exe" uf "$OUT\app-unsigned.apk" classes.dex
+Pop-Location
+if ($LASTEXITCODE -ne 0) { throw "jar failed" }
+
+Write-Host "[5/6] zipalign"
+& "$BT\zipalign.exe" -f -p 4 "$OUT\app-unsigned.apk" "$OUT\app-aligned.apk"
+if ($LASTEXITCODE -ne 0) { throw "zipalign failed" }
+
+Write-Host "[6/6] sign"
+if ($Release) {
+ # Generated once, then REUSED FOREVER: Android installs updates only when
+ # they are signed by the same key. Keep both files out of git, backed up.
+ $ks = "$ROOT\release.keystore"
+ $passFile = "$ROOT\release-keystore-password.txt"
+ if (-not (Test-Path $passFile)) { throw "missing $passFile (put the keystore password in it first)" }
+ $pass = (Get-Content $passFile -Raw).Trim()
+ if (-not (Test-Path $ks)) {
+ & "$JDK\bin\keytool.exe" -genkeypair -keystore $ks -storepass $pass -keypass $pass `
+ -alias pithos -keyalg RSA -keysize 4096 -validity 14000 -dname "CN=Pithos,O=Silent Mode"
+ if ($LASTEXITCODE -ne 0) { throw "keytool failed" }
+ Write-Host "NOTE: new release key created. BACK UP $ks and $passFile."
+ }
+ $apk = "$OUT\pithos-v$VERSION.apk"
+ & "$BT\apksigner.bat" sign --ks $ks --ks-key-alias pithos --ks-pass "pass:$pass" --key-pass "pass:$pass" --out $apk "$OUT\app-aligned.apk"
+} else {
+ $ks = "$ROOT\debug.keystore"
+ if (-not (Test-Path $ks)) {
+ & "$JDK\bin\keytool.exe" -genkeypair -keystore $ks -storepass android -keypass android `
+ -alias androiddebugkey -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Android Debug,O=Android,C=US"
+ if ($LASTEXITCODE -ne 0) { throw "keytool failed" }
+ }
+ $apk = "$OUT\pithos-debug.apk"
+ & "$BT\apksigner.bat" sign --ks $ks --ks-pass pass:android --key-pass pass:android --out $apk "$OUT\app-aligned.apk"
+}
+if ($LASTEXITCODE -ne 0) { throw "apksigner failed" }
+Write-Host "OK -> $apk"
diff --git a/mobile/android/res/drawable-nodpi/ic_launcher.png b/mobile/android/res/drawable-nodpi/ic_launcher.png
new file mode 100644
index 0000000..e08c57e
Binary files /dev/null and b/mobile/android/res/drawable-nodpi/ic_launcher.png differ
diff --git a/mobile/android/res/values/strings.xml b/mobile/android/res/values/strings.xml
new file mode 100644
index 0000000..bbfa022
--- /dev/null
+++ b/mobile/android/res/values/strings.xml
@@ -0,0 +1,5 @@
+
+
+ Pithos
+ Save to Pithos
+
diff --git a/mobile/android/res/values/styles.xml b/mobile/android/res/values/styles.xml
new file mode 100644
index 0000000..1050fc8
--- /dev/null
+++ b/mobile/android/res/values/styles.xml
@@ -0,0 +1,11 @@
+
+
+
+ #0b100e
+ #3ddc97
+
diff --git a/mobile/android/src/st/silentmode/pithos/Bridge.java b/mobile/android/src/st/silentmode/pithos/Bridge.java
new file mode 100644
index 0000000..bf6bde3
--- /dev/null
+++ b/mobile/android/src/st/silentmode/pithos/Bridge.java
@@ -0,0 +1,222 @@
+package st.silentmode.pithos;
+
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Intent;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.provider.MediaStore;
+import android.provider.OpenableColumns;
+import android.util.Base64;
+import android.webkit.JavascriptInterface;
+import android.widget.Toast;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * What the page asks of the phone, as window.PithosAndroid.
+ *
+ * - Files shared to Pithos ("Save to Pithos" in another app's share menu):
+ * the page lists them and reads them in pieces to upload them.
+ * - Saving a downloaded file to Downloads, written in pieces.
+ *
+ * Bytes cross as base64 because a JavaScript interface only passes strings.
+ */
+public class Bridge {
+ private final MainActivity act;
+ private final List shared = new ArrayList<>();
+ private final Map reading = new HashMap<>();
+ private final Map saving = new HashMap<>();
+ private int nextId = 1;
+
+ private static class Saving {
+ String name;
+ Uri uri; // MediaStore entry (Android 10+)
+ File file; // Downloads folder (Android 9 and older)
+ OutputStream out;
+ }
+
+ Bridge(MainActivity act) { this.act = act; }
+
+ // ---- shared to Pithos ------------------------------------------------------------
+
+ /** Takes the files of an ACTION_SEND / SEND_MULTIPLE intent; true if any. */
+ boolean takeShare(Intent intent) {
+ if (intent == null) return false;
+ String a = intent.getAction();
+ List got = new ArrayList<>();
+ if (Intent.ACTION_SEND.equals(a)) {
+ Uri u = intent.getParcelableExtra(Intent.EXTRA_STREAM);
+ if (u != null) got.add(u);
+ } else if (Intent.ACTION_SEND_MULTIPLE.equals(a)) {
+ ArrayList list = intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM);
+ if (list != null) got.addAll(list);
+ }
+ if (got.isEmpty()) return false;
+ synchronized (this) { shared.clear(); shared.addAll(got); closeReaders(); }
+ return true;
+ }
+
+ @JavascriptInterface
+ public synchronized int sharedCount() { return shared.size(); }
+
+ @JavascriptInterface
+ public synchronized String sharedInfo(int i) {
+ if (i < 0 || i >= shared.size()) return "{}";
+ Uri u = shared.get(i);
+ String name = "shared-file-" + (i + 1);
+ long size = -1;
+ Cursor c = null;
+ try {
+ c = act.getContentResolver().query(u, new String[] { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE }, null, null, null);
+ if (c != null && c.moveToFirst()) {
+ if (!c.isNull(0)) name = c.getString(0);
+ if (!c.isNull(1)) size = c.getLong(1);
+ }
+ } catch (Exception ignored) {
+ } finally { if (c != null) c.close(); }
+ String type = act.getContentResolver().getType(u);
+ return "{\"name\":" + json(name) + ",\"size\":" + size + ",\"type\":" + json(type == null ? "" : type) + "}";
+ }
+
+ /** The next piece of shared file i (up to max bytes), base64; "" at the end. */
+ @JavascriptInterface
+ public synchronized String sharedRead(int i, int max) {
+ if (i < 0 || i >= shared.size()) return "";
+ String key = "s" + i;
+ try {
+ InputStream in = reading.get(key);
+ if (in == null) {
+ in = act.getContentResolver().openInputStream(shared.get(i));
+ if (in == null) return "";
+ reading.put(key, in);
+ }
+ byte[] buf = new byte[Math.max(1, Math.min(max, 4 << 20))];
+ int n = 0;
+ while (n < buf.length) {
+ int r = in.read(buf, n, buf.length - n);
+ if (r < 0) break;
+ n += r;
+ }
+ if (n == 0) { in.close(); reading.remove(key); return ""; }
+ return Base64.encodeToString(buf, 0, n, Base64.NO_WRAP);
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
+ @JavascriptInterface
+ public synchronized void sharedDone() { shared.clear(); closeReaders(); }
+
+ private void closeReaders() {
+ for (InputStream in : reading.values()) { try { in.close(); } catch (Exception ignored) { } }
+ reading.clear();
+ }
+
+ // ---- saving to Downloads ---------------------------------------------------------------
+
+ @JavascriptInterface
+ public synchronized String saveBegin(String name, String type) {
+ String clean = name == null ? "" : name.replaceAll("[\\\\/:*?\"<>|\\x00-\\x1f]", "_").trim();
+ if (clean.isEmpty() || clean.startsWith(".")) clean = "download" + clean;
+ Saving s = new Saving();
+ s.name = clean;
+ try {
+ if (Build.VERSION.SDK_INT >= 29) {
+ ContentValues v = new ContentValues();
+ v.put(MediaStore.Downloads.DISPLAY_NAME, clean);
+ if (type != null && !type.isEmpty()) v.put(MediaStore.Downloads.MIME_TYPE, type);
+ v.put(MediaStore.Downloads.IS_PENDING, 1);
+ ContentResolver cr = act.getContentResolver();
+ s.uri = cr.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, v);
+ if (s.uri == null) return "";
+ s.out = cr.openOutputStream(s.uri);
+ } else {
+ File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+ if (!dir.isDirectory() && !dir.mkdirs()) return "";
+ File f = new File(dir, clean);
+ String base = clean, ext = "";
+ int dot = clean.lastIndexOf('.');
+ if (dot > 0) { base = clean.substring(0, dot); ext = clean.substring(dot); }
+ for (int k = 1; f.exists(); k++) f = new File(dir, base + " (" + k + ")" + ext);
+ s.file = f;
+ s.out = new FileOutputStream(f);
+ }
+ } catch (Exception e) {
+ return "";
+ }
+ String id = "d" + (nextId++);
+ saving.put(id, s);
+ return id;
+ }
+
+ @JavascriptInterface
+ public synchronized boolean saveChunk(String id, String b64) {
+ Saving s = saving.get(id);
+ if (s == null) return false;
+ try { s.out.write(Base64.decode(b64, Base64.DEFAULT)); return true; }
+ catch (Exception e) { abort(id); return false; }
+ }
+
+ @JavascriptInterface
+ public synchronized boolean saveEnd(String id) {
+ final Saving s = saving.remove(id);
+ if (s == null) return false;
+ try {
+ s.out.close();
+ if (s.uri != null) {
+ ContentValues v = new ContentValues();
+ v.put(MediaStore.Downloads.IS_PENDING, 0);
+ act.getContentResolver().update(s.uri, v, null, null);
+ }
+ act.runOnUiThread(new Runnable() { public void run() { Toast.makeText(act, "Saved to Downloads: " + s.name, Toast.LENGTH_SHORT).show(); } });
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ @JavascriptInterface
+ public synchronized void saveAbort(String id) { abort(id); }
+
+ private void abort(String id) {
+ Saving s = saving.remove(id);
+ if (s == null) return;
+ try { s.out.close(); } catch (Exception ignored) { }
+ if (s.uri != null) { try { act.getContentResolver().delete(s.uri, null, null); } catch (Exception ignored) { } }
+ if (s.file != null) s.file.delete();
+ }
+
+ @JavascriptInterface
+ public void openOutside(String url) {
+ final Uri u = Uri.parse(url);
+ act.runOnUiThread(new Runnable() { public void run() { act.openOutside(u); } });
+ }
+
+ @JavascriptInterface
+ public String version() { return BuildInfo.VERSION; }
+
+ synchronized void close() {
+ closeReaders();
+ for (String id : new ArrayList<>(saving.keySet())) abort(id);
+ }
+
+ private static String json(String s) {
+ StringBuilder b = new StringBuilder("\"");
+ for (char c : s.toCharArray()) {
+ if (c == '"' || c == '\\') b.append('\\').append(c);
+ else if (c < 0x20) b.append(String.format("\\u%04x", (int) c));
+ else b.append(c);
+ }
+ return b.append('"').toString();
+ }
+}
diff --git a/mobile/android/src/st/silentmode/pithos/MainActivity.java b/mobile/android/src/st/silentmode/pithos/MainActivity.java
new file mode 100644
index 0000000..97726b3
--- /dev/null
+++ b/mobile/android/src/st/silentmode/pithos/MainActivity.java
@@ -0,0 +1,202 @@
+package st.silentmode.pithos;
+
+import android.app.Activity;
+import android.content.ActivityNotFoundException;
+import android.content.ClipData;
+import android.content.Intent;
+import android.graphics.Color;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.Window;
+import android.webkit.ValueCallback;
+import android.webkit.WebChromeClient;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebResourceResponse;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Pithos on Android: the browser app (Pithos/web) in a WebView.
+ *
+ * The app's own files ship inside the APK and are answered here, under the
+ * gateway's origin, so the page is the same-origin client the browser app
+ * was designed as: sign-in, the account API and S3 go to the network, the
+ * page itself never does. Files are encrypted and decrypted in the page.
+ */
+public class MainActivity extends Activity {
+ // The gateway the page belongs to. pithos.silentmode.st takes over once
+ // its DNS record exists (a remembered sign-in is kept per origin).
+ static final String ORIGIN = "https://s3.silentmode.st";
+ static final String HOST = Uri.parse(ORIGIN).getHost();
+
+ private static final int PICK_FILES = 1;
+
+ private static final Map TYPES = new HashMap<>();
+ static {
+ TYPES.put("html", "text/html");
+ TYPES.put("js", "text/javascript");
+ TYPES.put("css", "text/css");
+ TYPES.put("svg", "image/svg+xml");
+ TYPES.put("png", "image/png");
+ TYPES.put("json", "application/json");
+ }
+ // Mirrors the gateway's APP_CSP (hosted/server.js).
+ static final String CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' blob: data:; "
+ + "media-src 'self' blob:; connect-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; "
+ + "form-action 'none'; object-src 'none'";
+
+ private WebView web;
+ private Bridge bridge;
+ private ValueCallback pickCallback;
+
+ @Override
+ protected void onCreate(Bundle state) {
+ super.onCreate(state);
+ Window w = getWindow();
+ w.setStatusBarColor(Color.parseColor("#0b100e"));
+ w.setNavigationBarColor(Color.parseColor("#0b100e"));
+
+ web = new WebView(this);
+ web.setBackgroundColor(Color.parseColor("#0b100e"));
+ setContentView(web);
+
+ WebSettings s = web.getSettings();
+ s.setJavaScriptEnabled(true);
+ s.setDomStorageEnabled(true);
+ s.setDatabaseEnabled(true);
+ s.setAllowFileAccess(false);
+ s.setAllowContentAccess(false);
+ s.setMediaPlaybackRequiresUserGesture(false);
+ s.setUserAgentString(s.getUserAgentString() + " PithosAndroid/" + BuildInfo.VERSION);
+
+ bridge = new Bridge(this);
+ web.addJavascriptInterface(bridge, "PithosAndroid");
+
+ web.setWebViewClient(new WebViewClient() {
+ @Override
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest req) {
+ return appFile(req);
+ }
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest req) {
+ Uri u = req.getUrl();
+ if (HOST.equals(u.getHost()) && "https".equals(u.getScheme())) return false;
+ openOutside(u);
+ return true;
+ }
+ });
+ web.setWebChromeClient(new WebChromeClient() {
+ @Override
+ public boolean onShowFileChooser(WebView view, ValueCallback cb, FileChooserParams params) {
+ if (pickCallback != null) pickCallback.onReceiveValue(null);
+ pickCallback = cb;
+ Intent i = new Intent(Intent.ACTION_GET_CONTENT);
+ i.addCategory(Intent.CATEGORY_OPENABLE);
+ i.setType("*/*");
+ String[] accept = params.getAcceptTypes();
+ if (accept != null && accept.length == 1 && accept[0].contains("json")) i.setType("application/json");
+ i.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.getMode() == FileChooserParams.MODE_OPEN_MULTIPLE);
+ try {
+ startActivityForResult(Intent.createChooser(i, null), PICK_FILES);
+ } catch (ActivityNotFoundException e) {
+ pickCallback = null;
+ return false;
+ }
+ return true;
+ }
+ });
+
+ bridge.takeShare(getIntent());
+ if (state != null) web.restoreState(state);
+ else web.loadUrl(ORIGIN + "/");
+ }
+
+ // "/" for the page itself and "/_app/" from assets/web. Anything
+ // else (the API, S3, a fetch of "/" that lists drives) goes to the network.
+ private WebResourceResponse appFile(WebResourceRequest req) {
+ Uri u = req.getUrl();
+ if (!HOST.equals(u.getHost()) || !"GET".equals(req.getMethod())) return null;
+ String p = u.getPath() == null ? "/" : u.getPath();
+ String rel;
+ if (p.equals("/") && req.isForMainFrame()) rel = "index.html";
+ else if (p.startsWith("/_app/")) rel = p.substring(6);
+ else return null;
+ if (rel.isEmpty() || rel.contains("..") || rel.startsWith("/") || rel.contains("/.")) return null;
+ String ext = rel.substring(rel.lastIndexOf('.') + 1);
+ String type = TYPES.get(ext);
+ if (type == null) return null;
+ try {
+ InputStream in = getAssets().open("web/" + rel);
+ Map h = new HashMap<>();
+ h.put("Content-Security-Policy", CSP);
+ h.put("X-Content-Type-Options", "nosniff");
+ h.put("Referrer-Policy", "no-referrer");
+ h.put("Cache-Control", "no-cache");
+ return new WebResourceResponse(type, "utf-8", 200, "OK", h, in);
+ } catch (IOException e) {
+ return new WebResourceResponse("text/plain", "utf-8", 404, "Not Found", new HashMap(), null);
+ }
+ }
+
+ void openOutside(Uri u) {
+ String scheme = u.getScheme();
+ if (!"https".equals(scheme) && !"http".equals(scheme) && !"mailto".equals(scheme)) return;
+ try {
+ startActivity(new Intent(Intent.ACTION_VIEW, u));
+ } catch (ActivityNotFoundException ignored) {
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int code, int result, Intent data) {
+ if (code != PICK_FILES) { super.onActivityResult(code, result, data); return; }
+ ValueCallback cb = pickCallback;
+ pickCallback = null;
+ if (cb == null) return;
+ if (result != RESULT_OK || data == null) { cb.onReceiveValue(null); return; }
+ List uris = new ArrayList<>();
+ ClipData clip = data.getClipData();
+ if (clip != null) for (int i = 0; i < clip.getItemCount(); i++) uris.add(clip.getItemAt(i).getUri());
+ else if (data.getData() != null) uris.add(data.getData());
+ cb.onReceiveValue(uris.toArray(new Uri[0]));
+ }
+
+ // "Save to Pithos" from another app while Pithos is open.
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ if (bridge.takeShare(intent)) web.evaluateJavascript("window.dispatchEvent(new Event('pithos-shared'))", null);
+ }
+
+ @Override
+ public void onBackPressed() {
+ if (web.canGoBack()) web.goBack();
+ else super.onBackPressed();
+ }
+
+ @Override
+ protected void onSaveInstanceState(Bundle out) {
+ super.onSaveInstanceState(out);
+ web.saveState(out);
+ }
+
+ void runJs(final String js) {
+ web.post(new Runnable() { public void run() { web.evaluateJavascript(js, null); } });
+ }
+
+ @Override
+ protected void onDestroy() {
+ bridge.close();
+ web.destroy();
+ super.onDestroy();
+ }
+}
diff --git a/web/app.css b/web/app.css
index 9e4caf0..66614d3 100644
--- a/web/app.css
+++ b/web/app.css
@@ -70,3 +70,5 @@ table.files{width:100%;border-collapse:collapse}
.toast.err{border-color:var(--err);color:var(--err)}
progress{width:100%;height:6px;accent-color:var(--acc)}
@media (max-width:640px){.files .hide-sm{display:none}.files td,.files th{padding:9px 10px}.who{display:none}}
+.shared-bar{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin:0 0 12px;padding:10px 14px;border:1px solid var(--line);border-radius:10px;background:var(--panel)}
+.shared-bar span{flex:1;min-width:12rem}
diff --git a/web/app.js b/web/app.js
index ac723fd..771d9ba 100644
--- a/web/app.js
+++ b/web/app.js
@@ -180,6 +180,7 @@ async function route() {
if (!S.drives) return;
const seq = ++routeSeq;
const { bucket, prefix } = current();
+ paintShared();
if (!bucket) return showDrives(seq);
return showFolder(seq, bucket, prefix);
}
@@ -286,7 +287,8 @@ async function newFolder() {
try { await S.drives.mkdir(bucket, `${prefix}${name}/`); route(); } catch (e) { fail(e); }
}
-function saveBlob(blob, name) {
+async function saveBlob(blob, name) {
+ if (ANDROID) return saveToPhone(blob, name);
const url = URL.createObjectURL(blob);
const a = h('a', { href: url, download: name });
document.body.append(a);
@@ -297,7 +299,7 @@ function saveBlob(blob, name) {
async function download(bucket, key) {
const t = toast(`Preparing ${baseName(key)}…`);
- try { saveBlob(await S.drives.blob(bucket, key), baseName(key)); t.remove(); } catch (e) { t.remove(); fail(e); }
+ try { await saveBlob(await S.drives.blob(bucket, key), baseName(key)); t.remove(); } catch (e) { t.remove(); fail(e); }
}
async function rename(bucket, key) {
@@ -354,6 +356,61 @@ function closeViewer() {
if (viewerUrl) { URL.revokeObjectURL(viewerUrl); viewerUrl = null; }
}
+// ---- Pithos for Android -------------------------------------------------------------------------------
+// The Android app runs this page in a WebView and adds window.PithosAndroid:
+// downloads are written to the phone's Downloads folder, and files shared
+// to Pithos from another app wait here until a folder is open to take them.
+// Bytes cross as base64 in 1 MB pieces (the bridge only passes strings).
+
+const ANDROID = window.PithosAndroid || null;
+const PIECE = 1 << 20;
+
+const blobToB64 = (blob) => new Promise((resolve, reject) => {
+ const r = new FileReader();
+ r.onload = () => resolve(String(r.result).slice(String(r.result).indexOf(',') + 1));
+ r.onerror = () => reject(r.error);
+ r.readAsDataURL(blob);
+});
+
+async function saveToPhone(blob, name) {
+ const id = ANDROID.saveBegin(name, blob.type || '');
+ if (!id) throw new Error('Could not save to Downloads.');
+ try {
+ for (let off = 0; off < blob.size; off += PIECE) {
+ if (!ANDROID.saveChunk(id, await blobToB64(blob.slice(off, off + PIECE)))) throw new Error('Could not write to Downloads.');
+ }
+ if (!ANDROID.saveEnd(id)) throw new Error('Could not finish saving to Downloads.');
+ } catch (e) { ANDROID.saveAbort(id); throw e; }
+}
+
+function readShared() {
+ const files = [];
+ for (let i = 0; i < ANDROID.sharedCount(); i++) {
+ let info = {};
+ try { info = JSON.parse(ANDROID.sharedInfo(i)); } catch {}
+ const parts = [];
+ for (let b64; (b64 = ANDROID.sharedRead(i, PIECE));) parts.push(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)));
+ files.push(new File(parts, info.name || `shared-${i + 1}`, { type: info.type || '' }));
+ }
+ ANDROID.sharedDone();
+ return files;
+}
+
+// A bar above the list while shared files wait: "Upload here" once a folder is open.
+function paintShared() {
+ $('#shared-bar')?.remove();
+ if (!ANDROID || !S.drives) return;
+ const n = ANDROID.sharedCount();
+ if (!n) return;
+ const { bucket } = current();
+ const what = n === 1 ? '1 file' : `${n} files`;
+ const bar = h('div', { class: 'shared-bar', id: 'shared-bar' },
+ h('span', {}, bucket ? `${what} shared to Pithos.` : `${what} shared to Pithos. Open the folder to put them in.`),
+ bucket && h('button', { class: 'btn small primary', onclick: () => { bar.remove(); uploadFiles(readShared()); } }, 'Upload here'),
+ h('button', { class: 'btn small', onclick: () => { ANDROID.sharedDone(); bar.remove(); } }, 'Discard'));
+ $('#list').before(bar);
+}
+
// ---- boot ------------------------------------------------------------------------------------------------
function wireApp() {
@@ -367,6 +424,7 @@ function wireApp() {
$('#viewer-close').addEventListener('click', closeViewer);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('#viewer').hidden) closeViewer(); });
window.addEventListener('hashchange', route);
+ window.addEventListener('pithos-shared', paintShared);
}
wireSignin();
diff --git a/web/lib/hosted.js b/web/lib/hosted.js
index 907de7d..360cda1 100644
--- a/web/lib/hosted.js
+++ b/web/lib/hosted.js
@@ -59,7 +59,13 @@ export async function api(method, path, { token, body } = {}) {
}
async function identity(seed) {
- const key = await subtle.importKey('pkcs8', C.concat(PKCS8_ED25519, seed), { name: 'Ed25519' }, true, ['sign']);
+ let key;
+ try { key = await subtle.importKey('pkcs8', C.concat(PKCS8_ED25519, seed), { name: 'Ed25519' }, true, ['sign']); }
+ catch (e) {
+ // Ed25519 in Web Crypto: Chrome 137+, Firefox 129+, Safari 17+; on Android, an up-to-date Android System WebView
+ if (e?.name === 'NotSupportedError') throw new Error('This browser is too old to sign in (it has no Ed25519). Update it, or on Android update "Android System WebView", and try again.');
+ throw e;
+ }
const jwk = await subtle.exportKey('jwk', key);
const pub = C.unb64u(jwk.x);
return { pubkey: hex(pub), sign: async (msg) => b64(new Uint8Array(await subtle.sign('Ed25519', key, C.utf8(msg)))) };