Skip to content

Commit 37c513d

Browse files
committed
Fix
1 parent c6f571c commit 37c513d

6 files changed

Lines changed: 568 additions & 20 deletions

File tree

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
package org.electerm.electerm;
2+
3+
import android.annotation.SuppressLint;
4+
import android.annotation.TargetApi;
5+
import android.content.ContentResolver;
6+
import android.content.ContentValues;
7+
import android.content.Context;
8+
import android.net.Uri;
9+
import android.os.Build;
10+
import android.os.Environment;
11+
import android.util.Base64;
12+
import android.webkit.JavascriptInterface;
13+
import android.webkit.WebView;
14+
15+
import org.json.JSONObject;
16+
17+
import java.io.BufferedInputStream;
18+
import java.io.ByteArrayInputStream;
19+
import java.io.File;
20+
import java.io.FileOutputStream;
21+
import java.io.IOException;
22+
import java.io.InputStream;
23+
import java.io.OutputStream;
24+
import java.net.HttpURLConnection;
25+
import java.net.URL;
26+
import java.util.concurrent.ExecutorService;
27+
import java.util.concurrent.Executors;
28+
import java.util.regex.Matcher;
29+
import java.util.regex.Pattern;
30+
31+
/**
32+
* electerm native save bridge (WebView -> public Downloads).
33+
*
34+
* Why this exists
35+
* ---------------
36+
* The electerm UI is served by the on-device Node.js backend on
37+
* http://127.0.0.1:5577, which is *not* the Capacitor local-server origin.
38+
* Capacitor only injects its JS runtime (window.Capacitor + PluginHeaders)
39+
* into documents of its own origin (http://localhost), so on the real UI page
40+
* `window.Capacitor.PluginHeaders` is undefined. Every Capacitor plugin call
41+
* therefore silently degrades to its "web" fallback — a blob/anchor download —
42+
* and `<a download>` is a no-op inside a WebView. Result: "download from
43+
* browser" saved nothing, while "upload from browser" worked, because upload
44+
* goes through the native file chooser and never touches JS plugins.
45+
*
46+
* What it does
47+
* ------------
48+
* Exposes two methods to the page as `window.ElectermNative`:
49+
*
50+
* saveUrl(url, token, fallbackName, callbackId)
51+
* Streams an /api/download response straight into the public Downloads
52+
* collection (MediaStore on Android 10+, the public/external Downloads
53+
* dir before that). Streamed, so big files and directory tarballs never
54+
* have to travel through a base64 string.
55+
*
56+
* saveBase64(filename, base64Data, contentType, callbackId)
57+
* Writes in-memory content (theme/quick-command/config exports) the same
58+
* way.
59+
*
60+
* Both report back asynchronously by evaluating
61+
* window.__etNativeSaveResult(callbackId, ok, dataJson, errorString)
62+
* (see web-components/native-file-save.js). Methods run on a single worker
63+
* thread so the WebView thread is never blocked.
64+
*
65+
* Note: the bridge object is reachable from every document loaded in this
66+
* WebView. The WebView only ever loads the packaged loading page and the
67+
* loopback backend (see capacitor.config.ts allowNavigation), so no third
68+
* party page can reach it.
69+
*/
70+
public class ElectermSaveBridge {
71+
72+
/** Version marker: lets the web app feature-detect this bridge. */
73+
private static final String VERSION = "1";
74+
75+
private static final Pattern STAR_FILENAME =
76+
Pattern.compile("filename\\*\\s*=\\s*UTF-8''([^;]+)", Pattern.CASE_INSENSITIVE);
77+
private static final Pattern PLAIN_FILENAME =
78+
Pattern.compile("filename\\s*=\\s*\"([^\"]+)\"|filename\\s*=\\s*([^;]+)", Pattern.CASE_INSENSITIVE);
79+
80+
private static final Uri DOWNLOADS_URI = Uri.parse("content://media/external/downloads");
81+
82+
private final Context context;
83+
private final WebView webView;
84+
private final ExecutorService worker = Executors.newSingleThreadExecutor();
85+
86+
public ElectermSaveBridge(Context context, WebView webView) {
87+
this.context = context.getApplicationContext();
88+
this.webView = webView;
89+
}
90+
91+
@JavascriptInterface
92+
public String getVersion() {
93+
return VERSION;
94+
}
95+
96+
/**
97+
* Fetch `url` (with the electerm token header) and store the response
98+
* body in the public Downloads collection.
99+
*/
100+
@JavascriptInterface
101+
public void saveUrl(final String url, final String token, final String fallbackName, final String callbackId) {
102+
submit(callbackId, new Job() {
103+
@Override
104+
public JSONObject run() throws Exception {
105+
HttpURLConnection conn = null;
106+
try {
107+
conn = (HttpURLConnection) new URL(url).openConnection();
108+
if (token != null && token.length() > 0) {
109+
conn.setRequestProperty("token", token);
110+
}
111+
conn.setRequestMethod("GET");
112+
conn.setConnectTimeout(15000);
113+
conn.setReadTimeout(120000);
114+
int code = conn.getResponseCode();
115+
if (code < 200 || code >= 300) {
116+
throw new IOException("server responded " + code);
117+
}
118+
String contentType = conn.getHeaderField("Content-Type");
119+
String name = resolveFilename(conn.getHeaderField("Content-Disposition"), fallbackName);
120+
// The backend tars directories but only signals it via headers.
121+
if (isGzip(contentType) && !name.toLowerCase().endsWith(".tar.gz")) {
122+
name = name + ".tar.gz";
123+
}
124+
InputStream in = new BufferedInputStream(conn.getInputStream(), 64 * 1024);
125+
try {
126+
return save(name, mimeOf(name, contentType), in);
127+
} finally {
128+
in.close();
129+
}
130+
} finally {
131+
if (conn != null) {
132+
conn.disconnect();
133+
}
134+
}
135+
}
136+
});
137+
}
138+
139+
/** Store in-memory content (base64) in the public Downloads collection. */
140+
@JavascriptInterface
141+
public void saveBase64(final String filename, final String base64Data, final String contentType, final String callbackId) {
142+
submit(callbackId, new Job() {
143+
@Override
144+
public JSONObject run() throws Exception {
145+
byte[] data = Base64.decode(stripDataUrl(base64Data), Base64.DEFAULT);
146+
String name = sanitize(filename);
147+
return save(name, mimeOf(name, contentType), new ByteArrayInputStream(data));
148+
}
149+
});
150+
}
151+
152+
private interface Job {
153+
JSONObject run() throws Exception;
154+
}
155+
156+
private void submit(final String callbackId, final Job job) {
157+
worker.execute(new Runnable() {
158+
@Override
159+
public void run() {
160+
try {
161+
report(callbackId, true, job.run(), null);
162+
} catch (Throwable t) {
163+
String msg = t.getMessage();
164+
if (msg == null || msg.length() == 0) {
165+
msg = t.toString();
166+
}
167+
report(callbackId, false, null, msg);
168+
}
169+
}
170+
});
171+
}
172+
173+
private void report(final String callbackId, final boolean ok, final JSONObject data, final String error) {
174+
final String script = "window.__etNativeSaveResult(" +
175+
JSONObject.quote(callbackId) + "," +
176+
(ok ? "true" : "false") + "," +
177+
(data == null ? "null" : JSONObject.quote(data.toString())) + "," +
178+
(error == null ? "null" : JSONObject.quote(error)) +
179+
");";
180+
webView.post(new Runnable() {
181+
@Override
182+
public void run() {
183+
try {
184+
webView.evaluateJavascript(script, null);
185+
} catch (Throwable ignored) {
186+
// WebView gone (activity destroyed) - nothing to report to.
187+
}
188+
}
189+
});
190+
}
191+
192+
private JSONObject save(String displayName, String mimeType, InputStream in) throws Exception {
193+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
194+
return saveToMediaStore(displayName, mimeType, in);
195+
}
196+
return saveToLegacyDownloads(displayName, in);
197+
}
198+
199+
/** Android 10+ : MediaStore owns the public Downloads collection. */
200+
@TargetApi(Build.VERSION_CODES.Q)
201+
@SuppressLint("NewApi")
202+
private JSONObject saveToMediaStore(String name, String mimeType, InputStream in) throws Exception {
203+
ContentResolver cr = context.getContentResolver();
204+
ContentValues values = new ContentValues();
205+
values.put("_display_name", name);
206+
values.put("mime_type", mimeType);
207+
values.put("relative_path", Environment.DIRECTORY_DOWNLOADS);
208+
values.put("is_pending", 1);
209+
210+
Uri item = cr.insert(DOWNLOADS_URI, values);
211+
if (item == null) {
212+
throw new IOException("could not create a Downloads entry");
213+
}
214+
try (OutputStream out = cr.openOutputStream(item)) {
215+
if (out == null) {
216+
throw new IOException("could not open the Downloads entry");
217+
}
218+
copy(in, out);
219+
} catch (IOException e) {
220+
try {
221+
cr.delete(item, null, null);
222+
} catch (Exception ignored) {
223+
// best effort cleanup
224+
}
225+
throw e;
226+
}
227+
228+
ContentValues done = new ContentValues();
229+
done.put("is_pending", 0);
230+
cr.update(item, done, null, null);
231+
232+
JSONObject res = new JSONObject();
233+
res.put("name", name);
234+
res.put("location", Environment.DIRECTORY_DOWNLOADS + "/" + name);
235+
return res;
236+
}
237+
238+
/**
239+
* Android 9 and older: no MediaStore Downloads collection. Try the public
240+
* Downloads directory (only writable with the storage permission, which
241+
* this app does not request), then fall back to the app's external files
242+
* dir - which file managers can still browse on those releases.
243+
*/
244+
private JSONObject saveToLegacyDownloads(String name, InputStream in) throws Exception {
245+
File dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
246+
if (dir == null || !(dir.isDirectory() || dir.mkdirs()) || !dir.canWrite()) {
247+
dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
248+
}
249+
if (dir == null) {
250+
throw new IOException("no writable Downloads directory");
251+
}
252+
if (!dir.isDirectory() && !dir.mkdirs()) {
253+
throw new IOException("could not create " + dir.getAbsolutePath());
254+
}
255+
File file = uniqueFile(dir, name);
256+
try (FileOutputStream out = new FileOutputStream(file)) {
257+
copy(in, out);
258+
}
259+
JSONObject res = new JSONObject();
260+
res.put("name", file.getName());
261+
res.put("location", file.getAbsolutePath());
262+
return res;
263+
}
264+
265+
private static File uniqueFile(File dir, String name) {
266+
File file = new File(dir, name);
267+
if (!file.exists()) {
268+
return file;
269+
}
270+
int dot = name.lastIndexOf('.');
271+
String base = dot > 0 ? name.substring(0, dot) : name;
272+
String ext = dot > 0 ? name.substring(dot) : "";
273+
for (int i = 1; i < 1000; i++) {
274+
File candidate = new File(dir, base + " (" + i + ")" + ext);
275+
if (!candidate.exists()) {
276+
return candidate;
277+
}
278+
}
279+
return file;
280+
}
281+
282+
private static String resolveFilename(String disposition, String fallback) {
283+
String name = null;
284+
if (disposition != null) {
285+
Matcher star = STAR_FILENAME.matcher(disposition);
286+
if (star.find() && star.group(1) != null) {
287+
name = percentDecode(star.group(1).replace("\"", "").trim());
288+
}
289+
if (name == null || name.length() == 0) {
290+
Matcher plain = PLAIN_FILENAME.matcher(disposition);
291+
if (plain.find()) {
292+
String raw = plain.group(1) != null ? plain.group(1) : plain.group(2);
293+
// RFC 6266: the plain form is a literal, already-decoded name.
294+
if (raw != null) {
295+
name = raw.trim();
296+
}
297+
}
298+
}
299+
}
300+
if (name == null || name.length() == 0) {
301+
name = fallback;
302+
}
303+
return sanitize(name);
304+
}
305+
306+
private static String percentDecode(String value) {
307+
try {
308+
// encodeURIComponent() never emits a bare '+', so URLDecoder's
309+
// plus-to-space rule cannot corrupt the name.
310+
return java.net.URLDecoder.decode(value, "UTF-8");
311+
} catch (Exception e) {
312+
return value;
313+
}
314+
}
315+
316+
private static String sanitize(String name) {
317+
if (name == null) {
318+
return "download";
319+
}
320+
String clean = name.replace('\\', '/');
321+
int slash = clean.lastIndexOf('/');
322+
if (slash >= 0) {
323+
clean = clean.substring(slash + 1);
324+
}
325+
clean = clean.replaceAll("[\\p{Cntrl}*?\"<>|]", "_").trim();
326+
if (clean.length() == 0 || ".".equals(clean) || "..".equals(clean)) {
327+
return "download";
328+
}
329+
return clean;
330+
}
331+
332+
private static boolean isGzip(String contentType) {
333+
return contentType != null && contentType.toLowerCase().contains("gzip");
334+
}
335+
336+
private static String mimeOf(String filename, String contentType) {
337+
if (contentType != null && contentType.length() > 0 && !contentType.startsWith("application/octet-stream")) {
338+
return contentType;
339+
}
340+
String lower = filename.toLowerCase();
341+
if (lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".gz")) return "application/gzip";
342+
if (lower.endsWith(".zip")) return "application/zip";
343+
if (lower.endsWith(".pdf")) return "application/pdf";
344+
if (lower.endsWith(".json")) return "application/json";
345+
if (lower.endsWith(".txt") || lower.endsWith(".log") || lower.endsWith(".md")) return "text/plain";
346+
if (lower.endsWith(".png")) return "image/png";
347+
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
348+
return "application/octet-stream";
349+
}
350+
351+
private static String stripDataUrl(String base64Data) {
352+
if (base64Data == null) {
353+
return "";
354+
}
355+
String trimmed = base64Data.trim();
356+
int comma = trimmed.indexOf(',');
357+
if (comma > -1 && trimmed.substring(0, comma).toLowerCase().contains("base64")) {
358+
return trimmed.substring(comma + 1).replaceAll("\\s", "");
359+
}
360+
return trimmed.replaceAll("\\s", "");
361+
}
362+
363+
private static void copy(InputStream in, OutputStream out) throws IOException {
364+
byte[] buffer = new byte[64 * 1024];
365+
int read;
366+
while ((read = in.read(buffer)) > 0) {
367+
out.write(buffer, 0, read);
368+
}
369+
out.flush();
370+
}
371+
}

0 commit comments

Comments
 (0)