Pithos for Android: the browser app on a phone, with Save to Pithos

A first Pithos app for phones. It runs the browser app in a WebView and
answers the page's own files from the APK under the gateway's origin, so
the page stays the same-origin client it was designed as and the files
are still encrypted and decrypted in the page.

What the phone adds: "Save to Pithos" in other apps' share menus (the
files wait in a bar until a folder is open), downloads written to the
Downloads folder, the system file picker for uploads, and outside links
opened in the phone's browser.

The browser app learns the two hooks (window.PithosAndroid) and says
plainly when a browser is too old to sign in, instead of a bare
"NotSupportedError".
This commit is contained in:
Local Dev 2026-10-07 23:06:33 +02:00
parent d03e9ab5aa
commit 083a2e1db8
12 changed files with 684 additions and 3 deletions

3
mobile/android/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
build/
*.keystore
release-keystore-password.txt

View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="st.silentmode.pithos"
android:versionCode="1"
android:versionName="0.1.0">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Saving downloads on Android 9 and older; 10+ uses MediaStore without it. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<application
android:label="@string/app_name"
android:icon="@drawable/ic_launcher"
android:roundIcon="@drawable/ic_launcher"
android:theme="@style/Pithos"
android:allowBackup="false"
android:usesCleartextTraffic="false">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- "Save to Pithos" in other apps' share menus -->
<intent-filter android:label="@string/share_label">
<action android:name="android.intent.action.SEND" />
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
</application>
</manifest>

39
mobile/android/README.md Normal file
View file

@ -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<version>.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).

95
mobile/android/build.ps1 Normal file
View file

@ -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<version>.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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Pithos</string>
<string name="share_label">Save to Pithos</string>
</resources>

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Pithos" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/bg</item>
<item name="android:colorAccent">@color/accent</item>
<item name="android:statusBarColor">@color/bg</item>
<item name="android:navigationBarColor">@color/bg</item>
</style>
<color name="bg">#0b100e</color>
<color name="accent">#3ddc97</color>
</resources>

View file

@ -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<Uri> shared = new ArrayList<>();
private final Map<String, InputStream> reading = new HashMap<>();
private final Map<String, Saving> 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<Uri> 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<Uri> 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();
}
}

View file

@ -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<String, String> 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<Uri[]> 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<Uri[]> 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/<file>" 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<String, String> 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<String, String>(), 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<Uri[]> cb = pickCallback;
pickCallback = null;
if (cb == null) return;
if (result != RESULT_OK || data == null) { cb.onReceiveValue(null); return; }
List<Uri> 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();
}
}

View file

@ -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}

View file

@ -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();

View file

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