From 083a2e1db871a02fa5d80728f2c84c61b1b67fee Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 7 Oct 2026 23:06:33 +0200 Subject: [PATCH] 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". --- mobile/android/.gitignore | 3 + mobile/android/AndroidManifest.xml | 38 +++ mobile/android/README.md | 39 +++ mobile/android/build.ps1 | 95 ++++++++ .../res/drawable-nodpi/ic_launcher.png | Bin 0 -> 29293 bytes mobile/android/res/values/strings.xml | 5 + mobile/android/res/values/styles.xml | 11 + .../src/st/silentmode/pithos/Bridge.java | 222 ++++++++++++++++++ .../st/silentmode/pithos/MainActivity.java | 202 ++++++++++++++++ web/app.css | 2 + web/app.js | 62 ++++- web/lib/hosted.js | 8 +- 12 files changed, 684 insertions(+), 3 deletions(-) create mode 100644 mobile/android/.gitignore create mode 100644 mobile/android/AndroidManifest.xml create mode 100644 mobile/android/README.md create mode 100644 mobile/android/build.ps1 create mode 100644 mobile/android/res/drawable-nodpi/ic_launcher.png create mode 100644 mobile/android/res/values/strings.xml create mode 100644 mobile/android/res/values/styles.xml create mode 100644 mobile/android/src/st/silentmode/pithos/Bridge.java create mode 100644 mobile/android/src/st/silentmode/pithos/MainActivity.java 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 0000000000000000000000000000000000000000..e08c57ed865989a344cf34b914b7341ba53218bb GIT binary patch literal 29293 zcmdSBWmwbw8#p?0Ajk*>6x`@g8kHUcq?Hg6K}tbFl+M8j0Rf2tBA~=jQjn04E=i?3 zBu94-7R z#2^ZC;LpD2ARYvw1*zXtc;rd6UQ3zAt?#yZk?-PwMg)e^waMz-<`6po<%IEwiuOp} zOP8HPkB4=Oq;#T`QZald16w zhYb_AO{wgyjk>0fYqxS=4vkl9U9PXi8QE>Q*y{x_($NzA^9tyv(;?QW;u)a|_ooO2 zUZ6Bm#A7yMj(=XP!+J-peKOn??pcN3nIr40C6Cj z5%w=f2!0AaB0gpDHEv~qMg-|^t+bu951`xhF+zXYWCL)JMt(E6@)w86v>9bX!Tm=Z z|9u0cji5wKOMExg`O9c45fe!N;fE5wwwGaQv9sy&}A?|I9%rjv4OUMp^kfT?RIR6r&Ng(8IzUlqH zgt#995_MNqb^D7Zn}Ei)X|?QsXb!guNzD&^9^Lv2L>&NPb~n#d<1Y~0J^%!FPwn6@ zkPrfpXX&dS{s*Lo0Hi0v_3dAVZx9$B$w0FIWjL`NIQ&Ek?v(!#s{-J`+{rOj{fmbO z2jJmpRk!|oHfDf=^cr-1O`!YBaadhFy?+iP3ynHH=_keVd~0B_}4V}WCDH> zNclxSaBQMH)8bJ;Szo`2?c3=7WD%cd>SJUJGa0(JFTx?B4k#cG<^60ACL(bX#V&E( z{66zm;vqB#dz3C2R{X(jDawA`Tg)>+!FbOE%?lh+2n%)3PL>!U?0uhcYK z|Mm&h$)ng8063BRPP==2!M9SA$7v$$G0Ba2d_d#~g`(+mN-q*ub?fNN~3L< z1ZRi@IeN)Rv+xuepezk7%-SX5{T1_Xnw)+}uN7K|=PpU&RdGcAz~}!PEF{N_!Zf=7 zw#VkWp-!IOi&$D?9b$f4=Ap(+oX?Pq>dt4wd*15KajN+!&y>4#%d7!krwu6^2FF(3 zn<%g~n|C{Y(7d-&kN4n}GcrxiMlNx{gXM1j{xuFR4Mp_O17}?FARDYrD%r&XxwOJQ zO?9bOHy17m%~H3}+_{#d`Ajv^hvWXny)+BbEEfxI(7@;H9{1FSo8Smc1Lb7 zY-Oa=EFALvc1nnuUJ}JVAK}o(9g*wF{-T|tqXGTF9n15Xs?^xN=O^u5I*O#yO6Eup zePR(gU%YEJrRSh^5xOs`4z3Puc7_|r^)oQ-sIh*sf!xd0Cq;e$WNP|aO74)42(nwC zE=}gEo>JEm)!41s+`?ZPrCC)4oRB;5K<#IG{}uYdB^iyh>wmC%SL6q=6otLvPo6AS zMi9%I9ndNNL_l>J>O-Fq<6^o8Oi7XN0mxIYB;?$**t*a6B!j;p5+0B>dQ1>M>Io25 z4~4Mz3SFWpkoveXu38lZAfY54sLuwFh!0+sR%@^ZO?<81Z)4WXP5mq+nesjJD59$+ ztWs_x3XMsB@PFv`;G$J^q_z)!U$80=+FczjTdgJho9Zk6=snSn=C1}J*4FP3M!Ty4 zQM5L#4*)kD_@$svl=!^oQ0%RXGm~u)}4rZ?>?qwt@%P;bM&SYyo^oL%6AvySwXK<}#w5bgi0{GuZ63-cDB`r3TJd}8N1d~{nR7!w*tl>@Dl6xP`J`aR^= zC}hm`Y5bt6wN^{pkU>|G=@zWefpOZXghxN?EtEy9}Y6(rBk+H3RANT2HodqSvCo8Fyy{OW*w; zOYssGM(?&Teqt1U|p-ce`GvVhqKj^9f&A^n7c0p#)Iu9bc8 z2aDD}J8ko4p0~>c%M5|rnwXdRS2K>HRYhIyi6y={1IXAHAsF3+C(@M-)pCP2@LEbm z00WV3e`U30054gqcgcxSYUYa=8ZP`JBvHs`Ma@nXIfjH(dB{ly`hvTF2$ z%o7<)tj(Nb%Bfs$7k3G+`WfKQkrg}p%UtHHjiRG_eCOM@x+yA5>^}h4wBeEgWHz`4p`rK<{Ta>uH7bA~oZy8fSDkq>6OP}sTCJ-~9m%pK!VSzG}#T_|;dqibbGABkR z2P%qK)#^~3qG6lGA+B+Lg$S^|cm|uv%Wt4y6>9ZmgcQ3u^+}CX(uSBG z6ZgLSS>?+@F}n1TG$SoU z2UEv>i&CC^j@|)+J9FJznWZ9*4}KA`LlmEUQqW&dMKfAfBWZT91Jk3IUsSUgQ@4jT z6dC?tHM*D9LOSl`#SPj>`{rGm+%V4BefpKi?DOGUuvdbWz_Z8EuEGUFB;j^$LrFus zw6GE*XeA1Ssaz~-A&4LE4~Pb3K>1}#iK7dL?N;dn+iASKmc)y*yc|@?v&sLvJ(-jWxztJfP)xJy( zja5yh(wWsr@os>Z;(7<}Dgo|<60k;8e>5`kp82pEj)?7~tnKI#_C(sDn|L)?JDXew zT|BC5fFcTvm92o8d`&?2;O!vtO6eE6oV~hKl%;F^8lZ7yqeEM&fGud*JQ0<<72QC% z?fL0bB43t0gGuwG!%(Ni zB`K0^eQn|hFtq0M&^UNAwk5(oO@#c8hLXO4oZ;X$t^Dc0fvV6s`&S#lsnOa@3j$__ zF54W`A|7j3kJv;v`!xqQev@XH>8tHEd3ZRFIAH$~wZ|dBqdeHs_Hp8j*}bL>QW}P+ zC*4`1F2Sc{U}yO;~bJ3hvvAfQc^_BHZ z0lI+Ae8N0LR(fr36jm$v?M;98{mSC;7BH=D#}N~mdo$xqb>2@Ie`@&aV$}T?9#%Q2 za7C-czL--`JrV_+Wfa|VXQb_6O6B*>o5U#`(Ot>MVgAtpvr+WX^W_{we7C?&!**!T z89TrYa}xrE<>i7AGJDzeL9^@Y&NN=vc9YejU8(Pre2Wkpm2Z5TNZXn)qD4Bcm8K{0 zh`5)3x5`14GAP>Q(p!`-704|1HrKB~HqWp7V%yBjjrB-7JBEN_LJ#2wL?OQb?{5S}{aoT55qMHV2KXHdQfUt$b5F4-l<|meIA}}V zp-mrykYE}E&`02BQ(rPqb_!JlG{esI+-k)~0UoMCX$%mC3aey#)#NThIr^ZYSRH?lu9$c7wBb zjO7;5vVh-YZ6K-2%~`05Orq2Cq`mVAml;?t(&x6Y0Sb#ny!5OXUpw`U^!8{z?A0RG zPy;|yvX~SmQ*_=)MoBcp$On!W(2FiB901OFG?)bcrss9YWqu-ay_Xx*0b0}gl!)x7 zh(iaNfVts<(Tl{S$H|qi+oE71?{?1NIP2)W7~fNl1~*~%U(V$9o~f&pnKO+|(oliI z0q=C*z6ZB_UDpMdFq?N9d53QjXL0~%{iw@iDgo7$nLxwpz4y-GhRfbN;fT(kauF*` zjyB$w96x6kz)n1D(4#0(d116VQTQWmin>K(yg%3_o>Pf}=|w5!c&i_)%iOL(MgGy# zmka^J^vkD1A3&~km-k5pxkN6qTyP6E7cdD}&4JL{{6dlV`fhXa@X;YY!=CJ&LNdOJ z+*H~MMpk@c&;7bZv!CmEPg4)V^TX{;&wa~p!~;2X9U-H2Ncu4${A2rVCRnJ-tL2?S z(n|gAdC|uk%z+6pROug;De>Np8R*=50uUX!)ru>!Iy7)?(NV*|#L9!jqy%A^#VV@Q z``3+>)4q~c%|6!vReip?PyK+iXr}cl7zQw{ZS=ytgh*4ewz?cF*y_V|&6!foI>JMQ zbU{(pefB$~vWB;_{jLk+O006ZkPHQ&Y-obZPj402%VYHzc*oSMZua==jF$8U1NGej z)zi4sTp0Av@}x=D3W_8lkxGqMc;~ZJgtf2$re)Q`+|&HV`;ML)E*)MW4@Yhi53u## zfaw$W-q=XXPUIlc>VsA?;ur9-hmGhg%46rXake>XC#{gNwZ$U79JOJ{$dkG4%!U|)pW8||P-tK$_edLo`p1=es` z-={ZCGP4pjTD$@H_YA=s9C6y=kxX(JkgFqlqUn3>Qf`d9#MAU@B*{%{#u9Q z)U!{?PvNST{G-9L%j)mIl%;(_=S8vlb>+)h3)9A)O~=QW4_fVUZm|@u$$)2yk_dpB zbC5Dlcs9B)O;((CBnQ>Tbe}yDk8-@WepWL0jAv3R6fDW!Y*{pk1D)vQ1wB0)YFJvD zF5Nerq8Yzu{)%`~FPAjUdt6alj6A@iK<03zZK48Oay4SY-l}j%k1tQlR66$;+J%TI zA(gr!BNwUp>+`jx2#V%!%F^aUM`nS{#^u|sd*_CJa0)>aEx^zcBy%$o9+6{5h$)nIYwdf+r8N*Lqq?V6l^2H72+#S)foGiA zht>`0ei3deybz(-c@CO03nrI_LNdn5io>q9DWCJb(IO+wdue}60k>0Lrly{{R8e+o z(NSanjrrICwWDiL1YUGlPlY_LKkO|lpp@;O3)hKMtqk4QzN{r&KBi_i1KMGwDS$fs z;2gQkG*Pjh$r(6{^IyM@-`6ltRAQYa+cbQb9cTo4(o_hpFsNm?wlMqYC}MY+|0I#9 z1fpLY;^(~nUN%jR*$Zt92ltckJJx7Clck5e z31#(vxCcrF&1g~;KJ}%{9Ij*QO%eV~nkMrna|yFLYS)9B>b^!&E>=@}SK6g6M)Oj| zKJ2`bb%?H`v86*FcHJ{g!JzgJWkm%w5DSy>KYayiEM^auCY>k7uWIid_VW;T5h|*& zdv0>R(|%KHsy9g_2KZs~l6)ptQ9;P z(ajnR<-YahksKR5bw8~rgzp7RAF76XR&?oP$S}O%DQ=<3!Q=;1={{}1Ch8GD)1SL? zdzNj+9#f|QH~t)>#bI2eOw<+)7pUQ^&b6 zumeVPF3W#g(XWEmN1I9bnnI{*b3+)rI&Uwdw@8istXs!0wA{Vr+*kWGhX^5n@Mvf( z9Zw(jl+QLMEl)!3E;XjXR8N7bnTi-}sTNDz-J(z#ms%03itV-ZPRqYhKA>VRx%z3( zcSJZpUxvcFVbFuSpL>>0Vu4k20HgAlS&Cx(Ew@w$MmFTxoxpm3Pp!aLfT@^2l&0oP_b%^fa zN_Ph5YAQFE$pJYJ8PWXns9<^lQ07`d>`Ok^1aqP%1xTKhc?2s~iN+pc&%{PMk4($B zo|7cU-*HnysKS2bR$68gY1IV|$OP&mrSU;j!$MDu6&9YuR}DHq2EK6v z@0Ly|sAWUD|8TbzSyc`=tD77g$ab!}?R;H&_P8)yTMkl!L|yVBCvhBmvld^gTkuVs z6gdkdq{z=7q_-oe&+R}b2x|PyXqW2EV7)KU4m($x1@m|r!TDkp1C+mEHyJQRXi@d) zbgs6EMQw|bwEwIZu5lle0X$19FbOlK=F1ei)X4bF1TTDr*E`ySQJrUrOYXs5;M5(b zm{iGjIDJS#33$%Mq}Y6;cpxn^k`y`j7(kz%GQJlVfu|}k!Py3;s7bWaTJO}ga;~s` zR-u`hVuNCzm6Bd3K2+&DkbNvh`J@vJXsbS;FS&dgzB7$}8~uK~!R4)6r(SH=)Ebp) zGXkUK_J7>yjqNd@pNXHq=NoJSZpOeU_d|~%sb7f%e(M;Uzr5ySJU9J-37P&#E{yce zmz;&g+%>EcJywar*hSo_I<55ybvLWOSYf;J+FH{w)rioU%#>js1>it`Mb2DKFqJN9 zP(4@PKFnF@4(%rskWUNk{;HCj+R6#1e%>9B#1gcg{=3P^)#Bormiv!b(70X4@Kj|H z&`7t97%R>=|Q>jHMe>TQ>a^f+cl!!IeDqwb#Iv}ftCYgOm$>j zmGS2o)o|*GsRPIB1wvX{^BR3DZ(K#KHgLY!HywzI6X?o>9k-hu#3y@ow>a~|RfMz} zxdZ)x&ntI|VcF*MgRgyFt(=y1Opyvc;O^CA(j)HG%pYpDUMRhgk@7xEea?mPozdUR zHr_1rk{_xb$l8jMZO)#8wsZ4D#$lVL`n_=h(J18Xc&6}dlbs+tP+W4q+9#7Y#$Up3 zgipQ}r%YR;VJ&nD`pLPXr7Ro4ILl=T(fW+uAWBp)+y0PVD0k?^?HVlTxiD2_nOP1s z(3UYF-m9L2V{yzwu5=c34`OxE3c0bWew$>-kJI)mOeMZ2Y!+rxnlJYTmP!c&tXAhRzlT zhRBShAgf$nVcL+RUbaqWZ#LRokq$SM-jU#lN z=x>Yt)OoRJI+noPi9Pg*+lFp(%J`x1o5&Qh+`}{#TGIIwpeV{2@X|^tz$3lsF-WM% zD_H5A0qm?P6ZYc0ZOZL{sS^OwlwFCs{LX2aOSg5=1%vzMkzs-|{fyFwjF9Dw)1VB_ z6?S!B_zEHp?}KIY4GUc7Z1!~#i+}mI7eFLbJLPxt?P1SFW52|cPn6y7YwH1Y7)Y-fD*wJx&poDxOaBWu}j>rRTjOFV_isu<4c=1;)g)YmDlc z$B0)gjY%6&n(+L)jn<||XPLskXyMnivpuY+tq0$2N@?J8_<+_Ec24SftsHHK%`n5G zts8IMu}_l2^MvxMnD#a0etx69X(qF{>VknzYggA176gQou1cPMdQt!0@Q4FZIIk7f zHy!M{6RMzzqZQ4OU;iP`TI8Z~gw-sBUJr_wqh0QL`A_4!8uju5c1mrsc*dpE#T5U7&!1SPs% zdM?hT{Afn2^#t}{S(WdJ16d`=Pv)y9ZLe0XrZn>jk2G7yg{(ITK938rTPQ)BAFcP< z_4xj%ul;dOSV!<1rw^|yUuSDQ>S>{4LB*+}FlXrQ4mXvHjDfG!-S09O-mK-SV%g!2 z-q9$wu|PbSa@~Rxw~$~ecRQ)gXpLgO;0tM*$De}DUy!qv-p&{eoO0_n3bFLy+FH86 zzRE9gpiqu9zLcU5a6*Pw`E_%LJxE0;xI4EFOCozQ8(_G!S5}JquVq=Xm4p(i&y$I* zYXhIBl04!io}l#dVvm71QUQ}qC>H#wPtu*^1Yx@o=x|=Je}}Vlo)l^LPlLS!gK9j4 zpI?FsMJY_xieJTV&S&IgJ3n%B)RoJXZ4POK=Inx)#=yCu{;%Rdw!^d{J6(5Z1tiSE!s4$vzHU|a^TT=(gQrv%I z9@UGPx_ zTov9%*WGz^6z~xa$#L0|@?Sfq=+%6*btRESifKn35TqKb&Gl7Skj{R%qSh!pRc@_Z zT%J`}r^;lj9*7IjS53#$@=KFG>%r!iqAHHmxS-7`EzS^kMz@O1oBhuVny3V>R&2i1 z-*XmrTnUaVz_Uy2?HwGey^$$UDO8UspJ*-*{nYizx))>H?S@_S_)3k16j-=f3sPb> zZc&o8As@PSeN3rieYQ6QY~?3XJX~N+K>9I@I0`>C#_P;>@ss(~Yo_048H*Y};)vss zlk#SOjDbg4Y$(>5WJBGb`Lazm-cA9(&#O1v4lIGF@W-Efssn?&{wP&bxl#;SGD|Gr zI2Dpy?_o?&qcvrLO@u#|knPO;a}M*?cE^h5t-O2*0?tVHH~Lm756a#BTWr9amj~a^ zhqiih$wj}Bddu~Zni~a-7b^dg@uDP@BF`6^APkvGt#g=s)iq-20Bqtl|S-$^i zog}9$#CA-zVtdT2+J>>zRSpQ7lT-KDpTxhwSi-ySH2A>y8cE#yb2q_dRjXsg^Y`wj z-u}3Q@R?!efZX2RzO)lyK?}pN+;4+#M|inFH|AiZ{bl>VfEGmu%WlqAD(cHeLC0)T zca2tGogP&o_|F$#E2%-o&cY@>6FQcVQj!C(_#xG69E4&^{?r>LM`2X;2?#Jk_Nm7P z)Loenc^NW4-?9hU(7j#JjQY2jZ2Whru%1!lOWt&_9orul%4osMCpIfa#2*S!#j*0Erw%E5_c@;YjFj@<8lY%FPaJc7jOsT% zfWJ{U_R)q)N$vdda$Iaj11_b_Y5t0hFd$t{UcIse{++Mi!k9LaO#Es?0e)Wh#012= zAn)9TtF26hU6X(T*=>~z1y+H&7&7n9=B?)Yx!P02T5>gK%V#~mxBml>=DR*C>AdJ} zLyMNojd{&X394(!lOvFw!R-pNu_i!?0X)T8)dL+h?Ac6^r@Ds`1d{e51@ResRF6)T4&?iscC^{3nQaZ&&3loNdxkvThhdbaSEj7>7}HC_&mUsr};8>-WAV zl#~k**H*UOP3WuCbBN0YGa(Nto*P^XZ<~4S_(*Oi3{w4ca?nUW0d>0%E>N@8n-_w^ z-NJ1S`)L;dCq90=p*vXS#f39d9qFbIQd}LGox}U9323+uKtLZ!=K&dQDC1(*5ohUp zXCxp@@byz_8} zG8$FE4b*sl^xr|gmyXv5ZI{=|Qq2V6UjurtEO`CfZr|;V_UNmqIJ9v;NtW|USl|&C zO>s!G0xnw8wN`FjLOf|Fb-u53iyUzBK&$hJTGel9u5iv{K($%@75RlE$Fsut)Z}W< zUNuXIj^FaeIpWs&AI?JPIJ&rP_}s^s8)VX7U`PRT-)bF&K-~8aK(#5Z&mC##NK;^- z9d__e8hbPeoA>hPb0TvGD)FAQ!ErLeNh)x%9;BepL{GM#LHGHk=s;@ymE!GTfs#39 z`$_T?;gDKa8y=+1%7`iu$1e*e&DIjJL-3XJE}1h1%as`t5evBvGu-e#AxqdVp+R~2 zkX$)e{R_{Z5ogw^4Ltqhgt@--1SrJp?0XaQshRzHK_e!3@6S1}OzRwSGLfT5=OCG# zGNUC%)G6(^l8@pI2vBmdpX4m{*`82rS*W^8EIt0S@bm(nO7Bloo_{&y!a`*!&U$tI zN`!5HIG392n{|b=z)64inY!O+_h8%Rc&FYNgOs!De%!B92(uBFb*I4(O4|8}XCJ^a zY-A{Pr)W7+#2GvJneQTqN^XZYV0liN5&>qBctqYazkk8Dyxu_RD~_Sl?AAv5fAp-l z(GHKjk;u&t4GT1kmFpn!?9(+!Edv=z6Bg}4dY3LdzP%sVP`Y9}zZBBXNv7pdbzdRc zdxaAGURgf6Hp5r3!;$qkbPS?b2^_geS6lCbAN?|Jupa19(v)pXe1z0}ET7^%cDCAe zL+K;2ii$Deo}u8^jRs>R{QKH|<}T+$UmW~S!+iMjZxD7SNqW!XKbZTmq)4BLg$Nl6@Yw_oPY{0zHey*wR>^pDL7>g2Xe}#iZ z;i9-26ZjKRz9MV$=OAD961|$qva$rguyDJri1@Rz>3&sr=)MWk!$T@Ja1I{c*ziyP zD-`{wC8stND7?p1<_lSk~2mjjjzAC^4*13N}dZF*QN z6e%qr`rA=q2-2-(uhGFz1}$h0T#rVH#~?sB_3HLv_)`CjAw&8?iQ zU1KylW@JFSHt>V#e72kCt+owd-u^x1mf^yNuS|*T5wLtSmZNpmDjkpW-fTMSEGnOL zl8Am`dfAhSUs|+64V(hzU~;5`?C4w|RPjw}Ablj64>g#O%OtLbxC;yev&S9PeTf}G9Nuc_j90_)}$YeP72 zV!wdNumbC#a1E2|AdQdvZsXP&^`Fg}q(J;28^f-*6>nC^)_R?Q2e8zU6<-k4Hr*Vs zA^;hV+bJLqPl-D*&Ner5I@{^0Z?!15e;)9duuQuAfEayl!}-IZP6A$YQYMG57dR~> z_&Q-M;C707_UXLRZUu}cy2&+P;1KPImf|z#;6HR(nM-P{M*Pj9cDBQ~j1W?;u zuILbFy7bh->$7)?10W3GKY;WOrKf@2Ph^TiZ8UxL%gbk>lYfrpo}h|8Eh@T$-T$+Y9)AMTXzqcO`wB zR1~&3(z@-p&E13{v}IZ4*P9<_dEX?mQL-~2P6YLikMSzTkX5iMP1hZmZe91enF>2p z7kiPl=)FQ0v^S@4LwhBI<0S2x;{#SpSXiJpjp9loig~LBwdPP!=#?sZYBpsm4nrNq zj2!K~v=an!;rxa0B~#x`I1w&;yQuqfeWj%Yv-Wi3eJQucftq=87|eV77%FF>c+C~^ zF)Hs*>+Iu1&NT5I_Prh=L^L0mdUbf;o$ZX0de8j2 z+$ewBKnTq9%IJeLPmG=ys5x>V56dPqT7p>#s&|~eJA1-$di*(orYk5up0M8gu%@O$ z`$Bw~N#1AdeRDen*qO*%TKk~;ak3@2n8XStH!J*8?e6T7WnTBS3G102zCTs*8&(=1 zqfxAAVAr;F5%T~M(r%GBZf=d4dGz&JD!dR0dOss*>hBpj4SNAY46LVP#Tgpz6U@`uM317^hyeJ1m{kuOM?#w(kj6%)-0znhWQmD=;bDbIxP zBI~*}yR}%G70$?q=CfH&n%Gc(EHQTGX}dQ|ZTaVbZOGD`A#{!NvYIKL|2%KRW5h4CX9`>fYq!5@oWC!GL{oQg}&r%6IYBL z9&hU;!TA3+EeV@EV|~hT=z-|(GJX@@QH4)mG81(L-7>0)-`oA*T<5+vEgiJJP-;8H z*C251j%}(dsTrGEK} z@Gv<#*<%C0?p@0<-#YFgo@AY%?^26P=VS_oUf(ymkQ95;Cjk0Y_8iD6iF13-$CXPw z$1Emt0RP0uj}i|y>8z1|A4mvNo0jhr4&7oAyAIB`gWX$WjS z`}31d$eXQxo)jR5q3yrv%?2JLVb3UIS$|f@N%Qkt_A#|@TKOD9{;sI~EJ427#u)n6Gt#5-f6Rj9!_PFy?uwV8VY#?6P1L7H09^Ek?e#qH;2T>4Bte1vYmu7^Vq=d zO8TRGFLXMk=J`pP^<>-B_@)#frEi2Zba}-T9z_Qi zb_2WB+O>oP3#~rh7Qrh|baM79CUFJcu@Q;Ev`7;@4iJQywhz{d2^!Il5RKU`H&V)4p1+!Ag1z(db7Wp* z#qTFlQ?H9>ON;9yGi93PELNR*Ru#fLD_7UNt`Jswv2$k`MIk^Lm{S5_`DSjuC6(wj z>9d> zsL+pnfy$|Yh3{fla&RTLaETp$K>4v%{pB1mvf9(PxJ z`gO;*8tb1)9Mt&xQZWCz>yb@Z&Ypa0Hs6@?gH_zzBFr_KxH<4Go3{wwxgFY|j6=vI z=E#i>pewN7vSWVKaR`HC} ziEsV0ZhQ~~{P2M`Xw}$*@+vpz!K;1F!lPw&*8Wa$I|?hCUVZKRL{?Hv^Th;DvE~)_ zU#<-=g9QEl!c%2u@(u|6w2CFeF~8M1b>`HBJT!b?uTbN#bSb6l?5Hz~V!&>!#dro_ z=InTbi~BBjO@Km*;kCb(pjA#>a-60@kFanGrw_do4VLIFe21bq#0ETrJyVp$tqa?V zowOQ078@961)SQaCQ;Mi6iMKzU?Fv4ES*{arx{X2o))qbedB(r z=Gcu~Az*n!CwY1cCKqaE+ZCxh9T{JnAf3VKZo0$wxY!@1B9E7pSG?bnZ)rQf7p1&FfOwy2x zjwbwL&7rdqKiAk;W3XYgXJ{Zk5U<2=3ab6Jg}-nJzVXs151tGhq#A2hZI3_Gk@C@v z4^;Mj<5G0dmQyF0C&20+uo)k`&J7f*|MUo+mE%NqZT8FhB5foD0dlf}9rl~Z(}8PB z*QiL!x!xP)WjnK}4+Wx3e00h}ESb`!n|XO6!@>)#dDuteqCZWdQi%6!2PDAT1ZP^>W(n^TpK54DW)^5CB9xx0jsOfmZ3_lq(#h*j)Hd45k6 zkQ<2ne*RmPgjqIJ<(K&)XN>E$5yKhOZN>`wEaak^KG5`YfLI+=9S!b@$vppk(%19feHMUq@b7cRCfO&_n_1_Y7FgSe37ouVNQ zao=43azEG!k126ZzmNq5epy|iZU*OKp$X9C^4yT|rU zy%3Ke^dt{tvvO~&_f18>tQlv%^oH9OD57Jtm47kl!)hD`I&@=I=Q-f|&|=8Aa-9H7znL|cpn5$(m_Q>>vDsskA0wTiY!$1q;7;;Hsv@?LYm2L{L)F4e7#pV21imm% zjn!hH!P^B2mb*tOR4LD2i3*DDTcU5vc$_jp*hu7zQD5CFWd3AV#Ov4|?)UP^uQQQ8v(xZ@5il z-?2TY!%(AOef7&;76W>rb2Obap&_zrl@pA^0t2p%H+)}G+TnwK6VQ2i_ca(b7>WMa zndRaE%vQ|}c;4Uoj!?3+K7c=EISpqs``|lveaTet5VE=_iVK5f*HRIZ5Li3h5|-}! z-j~TJ>tC!KB_w0F6sDVl?lf30y>t`#F|wMgIzQN+*$a%!U!G__V+xK#wZolH66Y~Q zu}Q-wfccD){hGgY^TRs-X0i%{o^PsxS(RkF(8B^=8f-7I-_dO(bN+w3AOqB(iMHS2Ml?>cE`T(X1&LKCRq0JZJq5R;quHh*vR7 zL(~=e;>D1grKWFUk@p$fgp;c@5^(U*>`Aj^*DiSLT@Hd6&FLCJ&wW#8hi1mSCRW!L8 zk(21H!V6~RYd#NyirP}Nn9)FkWT*Xph3vZZden+`!b27N(^s|KTGxp+^|y?hdB*}+ z?g4!SN1AkC)|?u$o}Q{&49T-Rsp_&xk_OaL&zecz*6O6n$taanM|Dup&8WmU*Y}kW z_I(9mmEb4zdgf6m1gvXSDew02nPNRrXp~X4C_xSk9nDlqjJP}Ly3WtLBg9Cna*Y|Q zG>4i5mfjFP>;w>Y83bGtup}kPRL+LWLM`BbgNO-^6Qj{f-O#KwlxThPUqEB!t5%@X z_bU=aUl~;7B6!Gj4}tqA{lJv|_Dj$I0?t1Tp5pPgAS=*B(@U2BI4?>@IheZPF_ZoB zJaS|%fR9eo4^=%iir{K!*-%7Yzd{TC+78cp4rxrg29ij#eFb<5qOD%d5S-Ok{!u7~ zUUwI%dp+Yl$jK4L1}_d76^?e%xljXw4}HkjQKiv62T-WNnms|0E+@a?V^q z$O^Ye9@^_>>U(#lcV80zoEE9)5&TqtiD;;A&t$e0>k160a-mY%8NpAQL-;KHf*Q(R zLS%7exqE3^9>FZ(FXX!e*!Ylx^~ww3;i;B0#VYF_v0eY zDhXRr!GJYB_d_%`K`Nx;N6k+CrAIhM!j`p5fL-pn; zO~M3^u=h*;~F;m@RsXA>Ia7hE-ccH5Nk9_o?Npgv@2hS~Th z)836c38o4wZ@9m6Brs+gBpyk>ZEvEC0S7Bj-n#F@sx+@4Qd!DZ`?I-C^d5AwD+FEn zhqM7>`ezf+p;0-^Kh63@w^}}7%TIJVD8ijS$mF#Yd(H$!CfH8E zdf;TkhQHMHGr_=-sa`QbVoS;Vo&F1WYc2Y9>%PaPKbqJsBzOI;R zADngw1M@645;LU>9$@Tkca+$b)%$8E2=3%Vbm12luv|KxM!lC~PPan+Y&L<*A%YV(u6we1- zi?y+h9Gc;|k-8TeB6CNAT7$`+i7sM4R6*9C0zfHxzaLs$w1x(Ke({iSK$k`ectdnL zL7pkvd*8*Y?^LH*%H3K%Bd{_H1l?~Z4vjIw~G(}vqK$Esc1 zeBPoHZ%z8kTxL47Ho&Jw4Z#V(?bLA3{|#{3q4dCYQW7u&WKZu-9t|(>a{&#OVRuq7 z!6!9)V#96M-}*jsGjd6xX+_xQ@`Ayf@RkPN#Zdd}QOY>>lhE^fXBLNNBo#d;5kI~H z-AxfU_m%fnhm{|S3#%NbA8ZASX3BWT_h_?f0v_C+On8S5^Bo_#4&0s`!-J>Gg^2v) z)d`Q;T2}k8U1yIJB}Zv#wlF*=?@7pqN+tf$a&a!PobVZ`A6V>Lg?Ga(O8{;N8I?`cU8X zB!ZNwfSUoH%p-~(D`Y|`39ysL%R+W;^0=;!GoOUAD%i>5Lau84wg>F)?)Kd1aUOVk zIiF>F=7apt^nJHyZQYb7Z9Av8qR$wIl)-wy771{5|E2u<;1lM#;~Vy&sA1K8E9kzR zTg21)xrD>H&w0KPZ6g=-?abRG!lsf6&@9uRB*NLzS`^qo8}L0kpgvGVABBh{69xoW z1$@n{nSA0Y$A$#{!MIBt!#<`+BN?^OR;m>PL~s5#dA^S zdcChiG={P-ow{X0PV>uHWm1wsC$BnY36HDZZ29r$N0h0}H%^wkqOAJ|cSKqxuV6&e z2Csx^Uycx^X|bl}yQBPIW^dH*b!$|$508AW%-y4o$s_Hpn-KvhE#kZJNG8{VvZLv|JxjrJmbtwl==~UD1c*t&;~< zT+m_27qnwrnnX-UNBke3#9UJGeb#9b^GbsJ zAOjq46q)LG6ZFULJlr_u-yeVQJjG4$_RC+F|!nQGm~v|ca7NQp%Ishws4{l11r zzZNjn8<#tLTOwRXUyJb&sU#i5Trl^qmE4f|K4;HeAO)u+j1L;3ynVx8wTVoi@eW^G zV>jjPewUvel_^0${{P3mlD`tavUrx9`mrh|DtoJ9O#&fHbo?_;LS91=iPc$eNoR}6 z04%)4RQ3|RI*t!*KDt9i6S3Pk;CWuMT`0TJy$_VxtMtNkCMjgP^FiC`b{cBc0HTAW@JeUAl-=sbc6w1eD%;?+`)>CI5TX_xFA0 za1M~NJ9l?>clMc?XU3|T23TR%PQNa+1s6i-I}~g2iPAZsc_kyM}4*|C&in=fkv&@ z1H=|DvghmY1M0rKdKaZ^QYz^{KdOIS0?s${)!mEtHrE+KcT!Y|{V74gOrGIWKv3{Y z%&l24$fh=PZ;dBByzs%X+}rlwyEkK85BX1Ny%7a8@$b!3h-62ZFhj3r5v|;V^@>|}DP1z;hInEIOS|EItaSdWHD!6@e zTY$g;c+H_p%t&z$Dhq=3E~sQ(%o1cL+rwEvx<;+j;#^9H*5ejlNU9DETW9srG;+Qz$Z zl0--?KJYEx)K;yQDAD9n+vT4}oH~XRs(a`KUfv|Y<@`!ZMTX6D8p+mWEB=0>X1zm^ zp6}j&3XykUEx)Uieo(b>Bc?^onbvDF6#q#7v$vW3>isGmwy{Q&_koDe;Q#(2h|?oD z*V-^y?uYTX{IVr*FZT-CqG4Gzuj4Nqghjn`;FbtN7Ityc!nJtGk}ox47i9jId@}vc zzctWxmfPWXwJa6J+*o(_17$?~bmQf*%QgHH!LTQL(lz`JHmvo2yq-+Qhp$_h{@Ic;GEWN+#(5O zV501Lq~NF#DW!0I4?%TNFj}facp=1prs&n5FPo7&V%=G;mY6ra!#UpbcI-74U#{KM zJeGag11cktcII4=3mxgF=!2lcoa)geqj#H}9)C83tSJ zH0~$%u*(#areUSx|H=N@1K(X8my0B48-1jp%$3U72h~dNl3tWw{kkYsiw5!#0hXUs zF$yYJ;ebe2lQpq+>uBcDNe@~dv)zw>jnS+Tt9U~A_*zz-kHb51z9?LLtuC1r`_N$V z4b!Ia_fC(tDYhLH{p`{ckhUl_5{mv+_br(@wj5ia;~u&lr(XmpJ^6#4=n8=?-8r)QpAHG5t# z^gFvldNy}(rDEZ!ZKwOg`A#)|-kIvY14WkJB?5t}I+X88!fkoREM@MQ=T|E_Ng~{A zH&YB5Z^p+@flgMed)c&C`YrBAGg zVT!feyHlJMxtQCycdKdsCsT8HkqvtKVA!!#mzOlv-UJ6*3(42p0ed2Pdf=974`7d4 zoL9M2D(-4n`m%Z7_3>TIz>Jy?>#TI3)seiOAR)#;SXRomZN09 zLDLueg$GXe+uXDHz_Zg=63OjRbO7spkHgaN<2Yy7y{H3vHVxfwt{``ZZr;d=5gSjnPi(P z)T}4P_N{&wkJ!1-ZG9V2m-+5cU-?9T*H)uZIt#jGgs={VEgdE6eOM($#6jFyEvEr0k5J*``z~CV z6Y&Uj8}j@7F=t^eX2sAU-&Jmi&R*C=-7hUFAl@8sG3843G{x0o5|kn8HT!U%ZYA;S z%J|XlCjMXNEnxQW*!#N|;Jn-H-|dp4&mtd-6C_uO)yC3niiR z1EP~7lY$zK3(vddvd$U3)@lrvN_G9k1o&v)%Dt7pLACDaLH%owl<_+fkX5yi{Be+N zr9gtx@icsg(4C)3wndh&D64gS8Bq*ly8JV3lYS`N*H*XP>DOQNaNK$3@@;NZKUePU z$xd*QU`l_ChLEQ4G&h|XwDVz@zhw9Z!jch!Fn)w`?4nP>EtXC`k0gc zOo`taU(3M2ITl|+`#6AUMEKZMidbtGhiJ$E*i|;y3#UfBXMy`dbj6$P&GaWmpIl_@ zuL?59mugn+O9I?5uijqgSxZt)Ew>-)^F+b~1V9bUbOo3d;$fn8_Lh|yK29#TIMdz* zfY%@FpZK-+L-3!g_sgL#^TBv4XX}}0m|4d$mCRNHd?s8%46s%4Iz=6dD1Dw;*dG3JrvAjbUd*$Nh#hDrxyzpxP zQw`MaKE16)2+-OLDZaaqw$E^D_LfKJe}7&?)=Xn09f=!|5p^q7u&FEKPWJwWRD1Pz z$Go)Olg&r!K!Lz)JGk$~A3m*jJegoaurtt~AGGH#971BhrR>Ceg>xJHi0v7E2J9qH zXa|LGdnWXrN!w)2H)x3W5T|?p!vYE?FRI&;r{5=pgdN`KwY%c9rC>k?5U;uNG>92V{B=D=-0cPd<^;Hl$CA6GIgoqc8?FPYEJDSRY`7~FCTkiz$ zTC&t>XJyG(nYRqTT7gLi0!oQCXtNa44a#u*h{&RjyUKc%YVYDF*bBE1daZWFU9#To zw_cF1vhG7jbo?1w%z2*}aS*4cYV{7=AyE|~ni%2!-bV_lF2mnS`j8@p zchS^T{-=Ny)~#Q8vUPe8N6_AmfziV z{h-?4+~sE9dz`xKiAsA2AO=GJA7b#^?66Q$hsIUZ!V1L1^UV!v8ak~P%!jI9`AsFJ zrF`5c<5sO}%F&T0*xzUEM1XlA5vER`(&~@B3 z7%==b5D=;itsybwR1c6&?9NRaLv|QF)e6a~t{27zU4D=v8KODOU~%4tKV9}#X_PkC z84w`?U`6bDDLt`9^QpyUXlkZ9xV}y~*(cBr;atsUuX|m0xFR=?oK_$&hBcZtOZkMU z60i> zG;Dzd*^b%QZ;uR#{pv>ovpw7*J=Vr>eb9mQz=d|6b9(inTh=o4)l_{N8t??D!2#HJ zN)&1`s6f9nhpn912ud;AJ3i`?s>@`x)+xhhHy*UHw(rdhhuDg)c{S@k{k zQxJ53u$G@*I-V2S#LJ9V9dTG2K!(dfg0WZKog2=3%8FRZfSW9Y(fGPd){2a%D2-Sl zIOoheAo}4r5b%f{ThLn%o*VTm|5COK#%vgk_f?pS*bJGjeCP&YS0Ok?~NTt&=uS%h2hU4LGXm-{!qtk_%T`>NdGt zj1o5*b23;>5E0T9S`WMgVC0N|Q{gSk2O#T1TUTl`m6fPesQCr6;)a`#vRY#RbdrnQ zxaQ?UH;j{!(PWbNk0Wl@PcD~7Kgn+U8-&mg?LV1&hnT(P$w^m!6$`vKttkHeq1!C1 z(tr<2cB}X7<@rRiiwwbhaY8?n83e5VR@&h*6A_XDHhkqeAiNyey9Jmq|8viAm0??4 z%%ZUalHXl(4F4n-(C!(oFPA{54xg+h(h|v5O9iEFLV~KFjD@%PEI++VHx2dSe6d>h z*$7zpKqjOi5IP9}vFdYCfnhg^N(0w{nB}Z1k{x<&NVI2i97Tl=w(iZ``lzEJVOBvf z2G1?n{j~V~N;CNu$D9I?;z@q#aPoG@$Q`A_t}Z?FWZuK z87{c!MC7jhu3S}m9CSAFoOc9%zO`U`jX3XkjkzWhiHo*&=}i`HnkTTfnq!RIX%2YJf{snK(1* zF~)wayrJGh$9cU}_70Y^n}=%a9vcvQdC~U^;q=Lyxwb9FuPf5PVyXI~+zrhKROUHn z!#;{Rk)}I<3p*1~mf`w~$}?I{lCl;4HfFsz7`@xIqG8~z?_OrOcXbJ5!AzRNjZ`O` zNtBh&Pq^W^6sjn)Aq3pPWweng0v?MOJ%nY&3ZaPbwRA&0P^{>VNcazuOP!BESe@XB zJb!IC`*#_c*u)d{i<_moHZi=K6y^*LX+&zj(VvakFEoo&ELcEa=(dFsiTbhRaYR%M zDJa4s@j+7(xCizK6b2vKPVgTxnDl2!7EdW81>Bhk1cC;GmzR2c{gl!J0H2l5-RjFu zfam8aLN6b4__|A8RS%2^?dyP!Iv*X3yd4^ei*=MoEJp6vu10q^)s>(Cn>zLM+T=Um zx=r!2pEi0AUL}JIrMwQFGTDiHd~yu&B&Q|$!X_grlrDq5oNTDZx_j=jBWJqowjXaJ zzdhP!Y5wBj_M`L4f42Uo4!exkkB+%)Wk{@y8LtdM>s3#pYq244ihbh@HEw#9Ia5yBb^ z_=bZ=$T}_%-}DdpuTft(vOSL;=QxfSu8Ieq)5az#$h;cgc#hQ8NvIr*9||*UW<*u% zr_Z|OmS7!|mvyu(W*>qvV#~(zzY*v8ns=`u3`i0S1q~D|N!?t)>FJ~-qqcv=y1&z9 zYt}=yDqDm7o6!Bt@T42cEb;_+W2=9yNSaP=! zHUP}i_Ve4BUxUH*kk-7dWVkmUP5-!coPte;uR?2n;yy8ivPc%9?H9HT6MvD=rZ)_{`q?ijr_xuZnxuIwPvm&@1EW|d6w~x7u(wQ;5?lb zaA)k(HhP{b#=__XIKk4qa zpH4zNl)G?N_B>x6FsA@$E)D($%)rP8e6xysj!ne!f_&8|FX`;$tK-!+s-z zt7M1%B-Mo#kZI)A;C=+&n|x5|*)Mb39fVUIDmMaxlI4 z!E`US@o#hQ9oJhjSdvCTGssEDN^RY zeAtDFv6#Fad97wg*}~)WrX(K`+loU$s?9PYp<$6J!|i_qT*w5sO@CjJTARN5xr=V z9eWnWL|qKMuZ4*V=Ys485;iI(5owtlpoGY)D61|buEQZx?!yl+X_{Dm$v z1(5#Ni@FOtWwH~D=p475kZ?QL5%;R%0PZu3%?~Z>MURix^47~#c5gIS1<$C-BafBk z_@Lc9ur2x7+JZM9&(d#QKB9a%wUDUp+4r)Ae$6N5U$XGcnqDHVaxq&T2$VmNQQOd9 z_!<`1&DRnquFn5XI zYB@7sDK9v7N_ZrAnVEIgpixowoALV&-~u>hEqV0&Vduqe^3q)q;Okza6-{|BWtZyP z$**n@e2uT0Y`R<-yJE*oe+z9B=NO`ZQEk)4+qcE`)!3Hgl!p$PpEs1JGgo zecn1z;QL2&W|CMNB+h-kuNl94$L#_~jj^`Ty*_Nm15YN_A*E@pLB;e{CgfwY7Td|s zPfrS&yg=@t6bZYvm3+(s<;GXE8u_>0g-R>l694y{V((iHG?TO4Z*68Mds6#ZL4kW< z{7$hKn)i`wN0v(xM#?xNH&3v?23#(-Bc-!#{kSk?Nd47Wo``)#Zk$f9J@~wxj!jXP8P1-8JfD#fa@F6A zhpdNBg(0twM_s*{JPEtL<-e9zc4q~D^;BUoRN3KiJ`&!w*jMJH^G$U zol-dg@oMB{-Q5$DfKRpaYBJ>Y2xlnuX;k_dD`gJu6PP-fU0&(m$UM=<;i7*Didz* zEqew46A`Eygb%7D+!z!|(0Jl3g9TU&L1OBwG+bvXJYN?w)wkcG(qMNmy|q9UbS&l< z)mfkn*=jZSF{K}~rl+dT^kZ+amOk)Pw^;d@KmgrtHNRB2T$>5mMy29$TupqNdzr92SJJ%jl^4?abn$o zY2BGNw)+$@;ktbXQ?wMIiRzRo_v$<20q(Tp7TFPUb5)`bk)>Nc9w%`kYdSg7j&~)9 zVoQl9m)I6+NGqXp^E`>!b_ud90=Uj?CA-5fs2JOVB6h=-0S1DJ9^HOpi#Ma z8=lnpOZ*mpgW?xj@7|tPfSf^A*-I32=N(s?g<^!Y-xYzM_T3U4I0|ZrN}d zIIkFimr{V)u zMy@=-q|^og+jtzqr6gU3LAxJ6Z1sw#SnYTnlVY{2&>9SZruE!((+sCdb1|RcY;QY(v)5mfNOt&bNqaI9)nq9|&I~$I02Y2T0pA%Rjv>1rfryV}H31h$aM6d`wUWEe z3zrOKj}94zpg1wS_tTEAFr&3)>(uR|j_l7zSFzO=ed#+)WW)_4cLO=Aa>hZstt%;a7(ZsVPFN{_`)3RyPR%__+@`nG9C5gYF6)`y8FI{Y58 zd3cAkaw*q*33Mj9=hajZE#oOZ7n8}t0J!R1ghy`ufxS&?=P4Ebqz}9bvOtF6r#8oQ zxq#J3F8>kwksMMyeW@52AU0pr8+{MmP%T)6$Bb5!F4iK|5aMbnEJ{9;)5;2IEv7#F zfJK%|dA4dfp7ZU!I;cTo!Ta4UtM3HTsMGMLsCDF0WQzrr;kV`A?r(thjI4^_uHzeY zXU^D46Fy!L^|+8uqEg9tWTt%HUmq#T(4MQrp3a5NB0l3~t9+~Uo&neDEBm7krngEv zl(wom0WerwkO2!$z~__dC2|gpn$2b|(O2JmI*HGO(wyoNeN4{7m4P%!-&Nl>J7{ol z+)7g6R&R8{Nt0zBX2rRtaEaXmd@g{#B-#C0N*N&J51nFK40*{Jdm3&Glx}uAoi{Ss z?_(_Q7`8jcsgM6Ww??nP^o%6HnD$)^%@h1m+IcSf_$9zFdksWCK~`oJbUXVsoZQ~{ zaSBZL#8+AVNKh29uiK?ub#w7SG|wH>!y27UfvxTIQa(dZrZZ25ECd7^$_X&(*yQn~a_F zJ$g0C3aeN${SDOWYy8l9j~6_lJKW}XlE-_Wv-!lh_vvYxBI$mm27h$QB z_wSVmPWo?Vvd7E0%vfIQssi!VkHM^R?zZ5)G-0iuRkMS4QYJye_`sU&B%+h3ph83c zBHYq#(>&70u>C{2F2l z&8pnV(djp@!mkhx*SH4Chwr#lY z9qhf7^6Epaj((rvraI2tNyNr`Xu;VgWN){#9z!?-^`!NO$iIZBF(`GH;4dk%B)uF8Goj)^M z6L8^G%_?+K({_yS4vH@IqFTET<7l-qmW<$byo(-7p{hh_2m^K=Pc0!jtIJV8!H62dnCtfl;?bltae3BNABWv*BmTd*DR zoL?qkJ6*rmMNI8t*a>jhm>Y!E-RcsxckTPyz@lL*$%|PuePK#!=l3k=6QK2nFmpPT zzXGX1T+ry)OCrU=*8R{3G;{{^wYrS?3b(?eC9I_#`;4))U7qKQSLHrGV)e(|Y%OU_ zs@aiU3~Pa}@<59VV0qu%@m?^_pS<{)70r`u`%r{xZ z*dk5(E`;x4er%nXKa)F`8b=2Ch|kW61#)5({doabK%i? zi@{!l&$SgcK$GzBWsbz)8DD`gJ&aX5dmbGe04-yivGFgo`^z1mtxb8VP5a@+{Yv2Vi$>DY&|d9tmU0Pvy_!Ad7c8-tqR%f23( zhIncme7|?TDUW2g(79P1{uDt==8~l~uaLA*EH5pUVXWH79_c;Lc#Cc7(93_~gcRN3 zvC~FN#FRXkSgR&eL-$-cmIqE?RO!d@r}rt=PeyL)O8}8{p9RdnxYMPNhLNu(LPqCV zpVCeGF;i;aV+xO26cwC6HzXUe(`rtD^H;g_voB@BsXiOAx7T}#{5hBbsFt7aG8R}n zFFNq~`NhpW{Z519qojtZ_#iuX`HD!M1}^xHA{&v=OVDEb2FGzv9XmUR?g31Hxf%Qe1ld}>PH2_ zN1v!qdKHqO_h?LP7_J$pXeyIs(_W0F!$WLx;=Cj|iUMhOHOIcW_Q{kB8WEza$GuL( z!EERdV6EMz1(?L;e2r`Nz9~rvJSNNugbjQPfSnqNmpoAA##zrZfgEr7}0C`4^B;eg^s*hZ(&a|5l^aoVX;!J zHwLhzT}o{yoCbESl;!N_)PRC3(&vbPdPGC^CmA{H-a z2B@xt*n5vY(q8;*E)^9JXA4rn10vku)F zX-t@EEv?qyR4RrP?+~at7*`z}*ozsAq)ZlCquRD})yj;T))e(w3y432Iv=^|1%G%_ z4iG4VidFDIN2o`gLOi8tTS-6m^9e7rzfSzwZ*dMrY@cNXAeh%QERI2w%sz&SZm4vW z3Eawu*F;0UMtfJui>id+SzS0?26ywUVi^Fw`Il?OAtQv2AB^0 z72t)UZhJT}DtD?qvKy}Ab1=N9p+yu z-P@~$PH?m#=C+0oA~=;t_!rAcwnoMCZL}ZEWY&O$+Gow%dPOteL-pKaX%L-$T9e@FAR_s)kg& zxWX_&w5S^m&PPXmy#?rF+?FG$k(g<=t00_%-@lEXCFBgYQ{BC)M2DqCS&HIL28(B( z(QgV35!b^=F0|WWdd8)0Z6Ac(Q-foY`BGsULgYI<$Anw%9E^*4e;w{2z!g1^<<9e`-WRa;Ku@X1?F?q{^FS54os(lQ7YSAN zX1T;pFi%^-2C+V!hoIXd4z4=e*d@OZsD>fZyifj3u?)SP?& zdm2*DL^~7$A?pNd3W7pmI9ASpU4SFV3JCQ|;UoF~gklF|xU6_>h3nrZQbDIX75DxB z$utMjyKt_3F8^B+kHHso<}BOY``;YIf*$eW($aCAe?q?in#}P{Q~X~aZeSmp`YY=H z^#P9Nxj;HlqCa!=zccG^X+H*Eko=o7AOO^a_ennEI0N80-sja2%KnvtV*o6p5Vlfq zApZ + + 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)))) };