feat(media-hosting): add multi-provider configurable upload with fallback

Add Random/Preferred/None modes, preferred provider selection, and provider-order fallback. Catbox/Imgur/PostImages on Android via WebView; Electron CDP automation for Imgur/PostImages. Hide upload controls on web runtime.
This commit is contained in:
plebeius
2026-02-18 18:51:02 +08:00
parent 72b70915fd
commit 96a4aaec0e
63 changed files with 1991 additions and 201 deletions
@@ -3,21 +3,27 @@ package fivechan.android;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.provider.MediaStore;
import android.util.Log;
import androidx.activity.result.ActivityResult;
import androidx.appcompat.app.AppCompatActivity;
import com.getcapacitor.JSArray;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import com.getcapacitor.annotation.ActivityCallback;
import com.getcapacitor.PluginCall;
import androidx.activity.result.ActivityResult;
import com.getcapacitor.annotation.CapacitorPlugin;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.json.JSONArray;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
@@ -30,9 +36,16 @@ import okhttp3.Response;
public class FileUploaderPlugin extends Plugin {
private static final String TAG = "FileUploaderPlugin";
private static final String PROVIDER_CATBOX = "catbox";
private static final long CATBOX_TIMEOUT_SEC = 30;
@PluginMethod
public void pickAndUploadMedia(PluginCall call) {
Log.d(TAG, "pickAndUploadMedia called");
List<String> providerOrder = parseProviderOrder(call);
if (providerOrder.isEmpty()) {
providerOrder.add(PROVIDER_CATBOX);
}
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
String[] mimeTypes = {"image/jpeg", "image/png", "video/mp4", "video/webm"};
@@ -40,6 +53,29 @@ public class FileUploaderPlugin extends Plugin {
startActivityForResult(call, intent, "pickFileResult");
}
private List<String> parseProviderOrder(PluginCall call) {
List<String> order = new ArrayList<>();
JSArray arr = call.getArray("providerOrder");
if (arr != null) {
for (int i = 0; i < arr.length(); i++) {
try {
Object o = arr.get(i);
if (o instanceof String) {
String p = (String) o;
if (PROVIDER_CATBOX.equals(p)
|| MediaUploadRecipes.PROVIDER_IMGUR.equals(p)
|| MediaUploadRecipes.PROVIDER_POSTIMAGES.equals(p)) {
order.add(p);
}
}
} catch (Exception e) {
Log.w(TAG, "Skip invalid provider at " + i, e);
}
}
}
return order;
}
@ActivityCallback
private void pickFileResult(PluginCall call, ActivityResult result) {
Log.d(TAG, "pickFileResult callback received");
@@ -47,64 +83,188 @@ public class FileUploaderPlugin extends Plugin {
return;
}
if (result.getResultCode() == Activity.RESULT_OK) {
Intent data = result.getData();
if (data != null) {
Uri uri = data.getData();
uploadToCatbox(uri, call);
} else {
call.reject("No data received");
}
} else {
if (result.getResultCode() != Activity.RESULT_OK) {
call.reject("File selection cancelled");
return;
}
Intent data = result.getData();
if (data == null) {
call.reject("No data received");
return;
}
Uri uri = data.getData();
if (uri == null) {
call.reject("No URI received");
return;
}
List<String> providerOrder = parseProviderOrder(call);
if (providerOrder.isEmpty()) {
providerOrder.add(PROVIDER_CATBOX);
}
new Thread(
() -> {
try {
tryProvidersSequentially(uri, providerOrder, call);
} catch (Exception e) {
Log.e(TAG, "Upload failed", e);
if (!call.getData().has("_resolved")) {
call.reject("Upload failed: " + e.getMessage());
}
}
})
.start();
}
private void tryProvidersSequentially(Uri fileUri, List<String> providerOrder, PluginCall call) {
List<JSObject> attempts = new ArrayList<>();
StringBuilder errorSummary = new StringBuilder();
for (String provider : providerOrder) {
JSObject attempt = new JSObject();
attempt.put("provider", provider);
if (PROVIDER_CATBOX.equals(provider)) {
MediaUploadResult res = uploadToCatboxSync(fileUri);
attempt.put("success", res.success);
if (res.success) {
attempt.put("url", res.url);
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
return;
}
attempt.put("error", res.error);
errorSummary.append(provider).append(": ").append(res.error).append("; ");
} else if (MediaUploadRecipes.PROVIDER_IMGUR.equals(provider)
|| MediaUploadRecipes.PROVIDER_POSTIMAGES.equals(provider)) {
MediaUploadResult res = uploadViaWebViewSync(fileUri, provider);
attempt.put("success", res.success);
if (res.success) {
attempt.put("url", res.url);
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
return;
}
attempt.put("error", res.error);
errorSummary.append(provider).append(": ").append(res.error).append("; ");
}
attempts.add(attempt);
}
JSObject data = new JSObject();
JSONArray arr = new JSONArray();
for (JSObject a : attempts) {
arr.put(a);
}
data.put("attempts", arr);
call.reject("All providers failed: " + errorSummary.toString(), null, null, data);
}
private void resolveWithSuccess(
PluginCall call, String url, String fileName, String provider, List<JSObject> attempts) {
JSObject ret = new JSObject();
ret.put("url", url);
ret.put("fileName", fileName);
ret.put("provider", provider);
JSONArray arr = new JSONArray();
for (JSObject a : attempts) {
arr.put(a);
}
ret.put("attempts", arr);
call.resolve(ret);
}
private String getFileName(Uri uri) {
try {
File f = FileUtils.getFileFromUri(getContext(), uri);
return f != null ? f.getName() : "unknown";
} catch (Exception e) {
return "unknown";
}
}
private void uploadToCatbox(Uri fileUri, PluginCall call) {
new Thread(() -> {
try {
Log.d(TAG, "Starting file conversion from URI");
File file = FileUtils.getFileFromUri(getContext(), fileUri);
Log.d(TAG, "File name: " + file.getName());
JSObject statusUpdate = new JSObject();
statusUpdate.put("status", "Uploading to catbox.moe...");
notifyListeners("uploadStatus", statusUpdate);
OkHttpClient client = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("reqtype", "fileupload")
.addFormDataPart("fileToUpload", file.getName(),
RequestBody.create(MediaType.parse("application/octet-stream"), file))
.build();
Request request = new Request.Builder()
.url("https://catbox.moe/user/api.php")
.post(requestBody)
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) throw new IOException("Unexpected response " + response);
String url = response.body().string();
Log.d(TAG, "Upload successful. URL: " + url);
JSObject ret = new JSObject();
ret.put("url", url);
ret.put("fileName", file.getName());
ret.put("status", "Upload complete!");
call.resolve(ret);
}
} catch (Exception e) {
Log.e(TAG, "Upload failed", e);
call.reject("Upload failed: " + e.getMessage());
private MediaUploadResult uploadToCatboxSync(Uri fileUri) {
try {
File file = FileUtils.getFileFromUri(getContext(), fileUri);
if (file == null) {
return new MediaUploadResult(false, null, "Could not resolve file");
}
}).start();
JSObject statusUpdate = new JSObject();
statusUpdate.put("status", "Uploading to catbox.moe...");
notifyListeners("uploadStatus", statusUpdate);
OkHttpClient client =
new OkHttpClient.Builder()
.connectTimeout(CATBOX_TIMEOUT_SEC, TimeUnit.SECONDS)
.writeTimeout(CATBOX_TIMEOUT_SEC, TimeUnit.SECONDS)
.readTimeout(CATBOX_TIMEOUT_SEC, TimeUnit.SECONDS)
.build();
RequestBody requestBody =
new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("reqtype", "fileupload")
.addFormDataPart(
"fileToUpload",
file.getName(),
RequestBody.create(
MediaType.parse("application/octet-stream"), file))
.build();
Request request =
new Request.Builder().url("https://catbox.moe/user/api.php").post(requestBody).build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
return new MediaUploadResult(
false, null, "Unexpected response " + response.code());
}
String url = response.body().string();
Log.d(TAG, "Catbox upload successful. URL: " + url);
return new MediaUploadResult(true, url.trim(), null);
}
} catch (Exception e) {
Log.e(TAG, "Catbox upload failed", e);
return new MediaUploadResult(false, null, e.getMessage());
}
}
}
private MediaUploadResult uploadViaWebViewSync(Uri fileUri, String provider) {
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
AppCompatActivity activity = getActivity();
if (activity == null) {
return new MediaUploadResult(false, null, "Activity unavailable");
}
String fileName = getFileName(fileUri);
MediaUploadCallback callback =
res -> {
resultRef.set(res);
latch.countDown();
};
activity.runOnUiThread(
() -> {
MediaUploadAutomationRunner runner =
new MediaUploadAutomationRunner(
getContext(), fileUri, fileName, provider, callback);
runner.run();
});
try {
boolean ok = latch.await(MediaUploadRecipes.UPLOAD_TIMEOUT_MS + 5000, TimeUnit.MILLISECONDS);
if (!ok) {
return new MediaUploadResult(false, null, "WebView upload timeout");
}
MediaUploadResult r = resultRef.get();
return r != null ? r : new MediaUploadResult(false, null, "No result");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new MediaUploadResult(false, null, "Interrupted");
}
}
}
@@ -0,0 +1,200 @@
package fivechan.android;
import android.annotation.SuppressLint;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.util.Log;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
/** Result of a WebView-based upload attempt. */
final class MediaUploadResult {
public final boolean success;
public final String url;
public final String error;
public MediaUploadResult(boolean success, String url, String error) {
this.success = success;
this.url = url;
this.error = error;
}
}
/** Callback for MediaUploadAutomationRunner. */
interface MediaUploadCallback {
void onComplete(MediaUploadResult result);
}
/**
* Non-interactive WebView automation for imgur/postimages uploads.
* Intercepts file chooser, feeds selected URI, polls for success/blocked, then tears down.
*/
@SuppressLint("SetJavaScriptEnabled")
public class MediaUploadAutomationRunner {
private static final String TAG = "MediaUploadAutomation";
private final Context context;
private final Uri fileUri;
private final String fileName;
private final String provider;
private final MediaUploadCallback callback;
private WebView webView;
private ValueCallback<Uri[]> filePathCallback;
private final Handler mainHandler;
private final Runnable pollRunnable;
private boolean finished;
private long startTime;
public MediaUploadAutomationRunner(
Context context, Uri fileUri, String fileName, String provider, MediaUploadCallback callback) {
this.context = context.getApplicationContext();
this.fileUri = fileUri;
this.fileName = fileName;
this.provider = provider;
this.callback = callback;
this.mainHandler = new Handler(Looper.getMainLooper());
this.pollRunnable = this::pollForResult;
}
public void run() {
mainHandler.post(this::startWebView);
}
private void startWebView() {
webView = new WebView(context);
webView.setVisibility(android.view.View.GONE);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(true);
settings.setAllowContentAccess(true);
settings.setLoadWithOverviewMode(true);
settings.setUseWideViewPort(true);
settings.setUserAgentString(
"Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.120 Mobile Safari/537.36");
webView.setWebChromeClient(
new WebChromeClient() {
@Override
public boolean onShowFileChooser(
WebView webView,
ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
MediaUploadAutomationRunner.this.filePathCallback = filePathCallback;
if (fileUri != null) {
filePathCallback.onReceiveValue(new Uri[] {fileUri});
MediaUploadAutomationRunner.this.filePathCallback = null;
}
return true;
}
});
webView.setWebViewClient(
new WebViewClient() {
@Override
public void onPageFinished(WebView view, String url) {
MediaUploadAutomationRunner.this.mainHandler.postDelayed(
MediaUploadAutomationRunner.this::triggerFileInput, 1500);
}
});
String uploadUrl = MediaUploadRecipes.getUploadUrl(provider);
if (uploadUrl == null) {
finish(new MediaUploadResult(false, null, "Unknown provider: " + provider));
return;
}
startTime = System.currentTimeMillis();
webView.loadUrl(uploadUrl);
Log.d(TAG, "Loaded " + uploadUrl + " for provider " + provider);
}
private void triggerFileInput() {
String js = MediaUploadRecipes.getTriggerFileInputJs(provider);
if (js == null) {
finish(new MediaUploadResult(false, null, "No trigger JS for " + provider));
return;
}
webView.evaluateJavascript(js, value -> schedulePoll());
}
private void schedulePoll() {
mainHandler.removeCallbacks(pollRunnable);
mainHandler.postDelayed(pollRunnable, MediaUploadRecipes.POLL_INTERVAL_MS);
}
private void pollForResult() {
if (finished) return;
long elapsed = System.currentTimeMillis() - startTime;
if (elapsed >= MediaUploadRecipes.UPLOAD_TIMEOUT_MS) {
finish(new MediaUploadResult(false, null, "Upload timeout"));
return;
}
String successJs = MediaUploadRecipes.getSuccessJs(provider);
String blockedJs = MediaUploadRecipes.getBlockedJs(provider);
if (successJs == null || blockedJs == null) {
finish(new MediaUploadResult(false, null, "Missing recipe for " + provider));
return;
}
webView.evaluateJavascript(
blockedJs,
blocked -> {
if ("true".equals(blocked)) {
finish(new MediaUploadResult(false, null, "Provider blocked (CAPTCHA/rate limit)"));
return;
}
webView.evaluateJavascript(
successJs,
url -> {
if (url != null && !"null".equals(url) && url.length() > 2) {
String cleaned = url.replaceAll("^\"|\"$", "").replace("\\u003d", "=");
if (cleaned.startsWith("http")) {
finish(new MediaUploadResult(true, cleaned, null));
return;
}
}
schedulePoll();
});
});
}
private void finish(MediaUploadResult result) {
if (finished) return;
finished = true;
mainHandler.removeCallbacks(pollRunnable);
if (filePathCallback != null) {
try {
filePathCallback.onReceiveValue(null);
} catch (Exception ignored) {}
filePathCallback = null;
}
tearDown();
callback.onComplete(result);
}
private void tearDown() {
mainHandler.post(
() -> {
if (webView != null) {
try {
webView.stopLoading();
webView.clearHistory();
webView.clearCache(true);
webView.clearSslPreferences();
webView.destroy();
} catch (Exception e) {
Log.w(TAG, "Teardown warning", e);
}
webView = null;
}
});
}
}
@@ -0,0 +1,143 @@
package fivechan.android;
/**
* Provider-specific recipes for automated media upload via WebView.
* Mirrors electron/media-upload-recipes.js for imgur/postimages.
* Non-interactive automation; detects blocked states (captcha/login/challenge);
* extracts direct media URL candidates via selector candidate arrays.
*/
public final class MediaUploadRecipes {
public static final String PROVIDER_IMGUR = "imgur";
public static final String PROVIDER_POSTIMAGES = "postimages";
/** Max time to wait for upload completion (ms). */
public static final long UPLOAD_TIMEOUT_MS = 45_000;
/** Poll interval for success/blocked checks (ms). */
public static final long POLL_INTERVAL_MS = 500;
private MediaUploadRecipes() {}
public static String getUploadUrl(String provider) {
if (PROVIDER_IMGUR.equals(provider)) {
return "https://imgur.com/upload";
}
if (PROVIDER_POSTIMAGES.equals(provider)) {
return "https://postimages.org";
}
return null;
}
/**
* JS to trigger file input click so WebChromeClient.onShowFileChooser fires.
* Uses candidate selectors; first match wins.
*/
public static String getTriggerFileInputJs(String provider) {
String[] candidates;
if (PROVIDER_IMGUR.equals(provider)) {
candidates =
new String[] {
"input[type=\"file\"]",
"input[type=file]",
"[data-file-input]",
};
} else if (PROVIDER_POSTIMAGES.equals(provider)) {
candidates =
new String[] {
"input[type=\"file\"]",
"input[type=file]",
"#uploadFile",
".fileinput",
};
} else {
return null;
}
return buildTriggerFileInputJs(candidates);
}
private static String buildTriggerFileInputJs(String[] selectors) {
StringBuilder sb = new StringBuilder("(function(){var s=[");
for (int i = 0; i < selectors.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(escapeJs(selectors[i])).append("\"");
}
sb.append("];for(var i=0;i<s.length;i++){var el=document.querySelector(s[i]);if(el){el.click();return;}}})();");
return sb.toString();
}
/**
* JS to extract direct media URL from page. Returns URL string or null.
*/
public static String getSuccessJs(String provider) {
String[] selectorCandidates;
String attribute;
if (PROVIDER_IMGUR.equals(provider)) {
selectorCandidates =
new String[] {
"a[href*=\"i.imgur.com\"]",
"input[value*=\"i.imgur.com\"]",
"[class*=\"copy-link\"] input",
"[data-link]",
};
attribute = "href";
} else if (PROVIDER_POSTIMAGES.equals(provider)) {
selectorCandidates =
new String[] {
"input[readonly][value*=\"postimg\"]",
"a[href*=\"i.postimg.cc\"]",
"[class*=\"direct-link\"]",
"textarea",
};
attribute = "value";
} else {
return null;
}
return buildSuccessJs(selectorCandidates, attribute);
}
private static String buildSuccessJs(String[] selectors, String attr) {
StringBuilder sb = new StringBuilder("(function(){var s=[");
for (int i = 0; i < selectors.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(escapeJs(selectors[i])).append("\"");
}
sb.append("];for(var i=0;i<s.length;i++){try{var el=document.querySelector(s[i]);if(!el)continue;var v=(el.value||el.getAttribute(\"value\")||\"\").trim();var h=(el.getAttribute(\"href\")||el.href||\"\").trim();var t=(el.textContent||\"\").trim();if(h&&h.indexOf(\"http\")===0)return h;if(v&&v.indexOf(\"http\")===0)return v;if(t&&t.indexOf(\"http\")===0)return t}catch(e){}}return null})()");
return sb.toString();
}
/**
* JS to detect blocked state (captcha/login/challenge). Returns true if blocked.
*/
public static String getBlockedJs(String provider) {
String[] blockedIndicators;
if (PROVIDER_IMGUR.equals(provider)) {
blockedIndicators =
new String[] {
"#challenge", ".captcha", ".g-recaptcha", "#recaptcha", ".signin", ".login",
};
} else if (PROVIDER_POSTIMAGES.equals(provider)) {
blockedIndicators =
new String[] {
"#challenge", ".captcha", ".g-recaptcha", "#recaptcha",
};
} else {
return null;
}
return buildBlockedJs(blockedIndicators);
}
private static String buildBlockedJs(String[] selectors) {
StringBuilder sb = new StringBuilder("(function(){var s=[");
for (int i = 0; i < selectors.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(escapeJs(selectors[i])).append("\"");
}
sb.append("];for(var i=0;i<s.length;i++){if(document.querySelector(s[i]))return true}return false})()");
return sb.toString();
}
private static String escapeJs(String s) {
if (s == null) return "";
return s.replace("\\", "\\\\").replace("\"", "\\\"");
}
}