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)))) };