fix(android upload): restore provider uploads

This commit is contained in:
Tommaso Casaburi
2026-05-01 22:52:54 +07:00
parent bc213ecea2
commit e2154b3edf
9 changed files with 696 additions and 144 deletions
@@ -0,0 +1,76 @@
package fivechan.android;
import static org.junit.Assert.*;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.net.Uri;
import androidx.core.content.FileProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Live provider integration test (emulator/device) for Android's native catbox uploader. */
@RunWith(AndroidJUnit4.class)
public class CatboxLiveUploadTest {
private static final long TEST_TIMEOUT_SEC = 30;
private static final String GENERATED_FILE_NAME = "catbox-gradient-320x320.png";
private Context appContext;
private Uri uploadUri;
@Before
public void setUp() throws Exception {
appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
uploadUri = createGradientPngUri(appContext);
}
@Test
public void catbox_liveUpload_fromGeneratedPng_succeeds() {
MediaUploadResult result = CatboxUploader.upload(appContext, uploadUri, TEST_TIMEOUT_SEC);
assertNotNull("Expected catbox result", result);
assertTrue("Expected catbox upload success, got: " + result.error, result.success);
assertNotNull("Expected uploaded URL", result.url);
String normalizedUrl = result.url.toLowerCase();
assertTrue(
"Expected catbox file URL, got: " + result.url,
normalizedUrl.matches("https?://files\\.catbox\\.moe/.+\\.(png|jpg|jpeg|webp|gif)(?:[?#].*)?"));
}
private static Uri createGradientPngUri(Context context) throws IOException {
File dir = new File(context.getCacheDir(), "live-upload-test");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("Unable to create cache directory: " + dir.getAbsolutePath());
}
File imageFile = new File(dir, GENERATED_FILE_NAME);
Bitmap bitmap = Bitmap.createBitmap(320, 320, Bitmap.Config.ARGB_8888);
for (int y = 0; y < 320; y++) {
for (int x = 0; x < 320; x++) {
bitmap.setPixel(
x,
y,
Color.rgb((x * 7) % 256, (y * 5) % 256, ((x + y) * 3) % 256));
}
}
try (FileOutputStream fos = new FileOutputStream(imageFile)) {
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)) {
throw new IOException("Failed to encode generated PNG");
}
} finally {
bitmap.recycle();
}
return FileProvider.getUriForFile(
context,
context.getPackageName() + ".fileprovider",
imageFile);
}
}
@@ -0,0 +1,123 @@
package fivechan.android;
import static org.junit.Assert.*;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.net.Uri;
import androidx.core.content.FileProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Live provider integration test (emulator/device) for Android's ImgBB WebView uploader. */
@RunWith(AndroidJUnit4.class)
public class ImgbbLiveUploadTest {
private static final long TEST_TIMEOUT_SEC = 120;
private static final String GENERATED_FILE_NAME = "imgbb-gradient-320x320.png";
private Context appContext;
private Uri uploadUri;
@Before
public void setUp() throws Exception {
appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
uploadUri = createGradientPngUri(appContext);
}
@Test
public void imgbb_liveUpload_fromGeneratedPng_succeeds() throws Exception {
Intent launchIntent = new Intent(appContext, MainActivity.class);
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
appContext.startActivity(launchIntent);
Thread.sleep(1_000);
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
MediaUploadCallback callback =
result -> {
resultRef.set(result);
latch.countDown();
};
MediaUploadAutomationRunner runner =
new MediaUploadAutomationRunner(
appContext,
uploadUri,
GENERATED_FILE_NAME,
MediaUploadRecipes.PROVIDER_IMGBB,
callback);
runner.run();
assertTrue(
"Runner did not complete within " + TEST_TIMEOUT_SEC + "s",
latch.await(TEST_TIMEOUT_SEC, TimeUnit.SECONDS));
MediaUploadResult result = resultRef.get();
assertNotNull("Callback did not receive result", result);
assertTrue(
"Expected live imgbb upload success, got error="
+ result.error
+ " stage="
+ result.stage
+ " elapsedMs="
+ result.elapsedMs
+ " selector="
+ result.matchedSelectors
+ " retries="
+ result.triggerRetryCount,
result.success);
assertNotNull("Expected uploaded URL", result.url);
String normalizedUrl = result.url.toLowerCase();
assertTrue(
"Expected direct i.ibb.co URL, got: " + result.url,
normalizedUrl.contains("://i.ibb.co/"));
assertTrue(
"Expected direct image URL with extension, got: " + result.url,
normalizedUrl.matches(
"https?://i\\.ibb\\.co/.+\\.(jpg|jpeg|png|gif|webp|bmp|avif)(?:[?#].*)?"));
}
private static Uri createGradientPngUri(Context context) throws IOException {
File dir = new File(context.getCacheDir(), "live-upload-test");
if (!dir.exists() && !dir.mkdirs()) {
throw new IOException("Unable to create cache directory: " + dir.getAbsolutePath());
}
File imageFile = new File(dir, GENERATED_FILE_NAME);
Bitmap bitmap = Bitmap.createBitmap(320, 320, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
Paint paint = new Paint();
for (int y = 0; y < 320; y++) {
int red = Math.round((255f * y) / 319f);
int blue = 255 - red;
paint.setColor(Color.rgb(red, 64, blue));
canvas.drawLine(0, y, 319, y, paint);
}
try (FileOutputStream fos = new FileOutputStream(imageFile)) {
if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)) {
throw new IOException("Failed to encode generated PNG");
}
} finally {
bitmap.recycle();
}
return FileProvider.getUriForFile(
context,
context.getPackageName() + ".fileprovider",
imageFile);
}
}
@@ -3,6 +3,7 @@ package fivechan.android;
import static org.junit.Assert.*;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.net.Uri;
@@ -16,6 +17,7 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -38,7 +40,13 @@ public class ImgurLiveUploadTest {
}
@Test
@Ignore("Imgur upload is no longer supported on Android WebView; keep for manual diagnostics.")
public void imgur_liveUpload_fromGeneratedPng_succeeds() throws Exception {
Intent launchIntent = new Intent(appContext, MainActivity.class);
launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
appContext.startActivity(launchIntent);
Thread.sleep(1_000);
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
MediaUploadCallback callback =
@@ -10,6 +10,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.After;
import org.junit.Before;
import org.junit.Ignore;
import org.junit.Test;
import org.junit.runner.RunWith;
@@ -17,13 +18,15 @@ import org.junit.runner.RunWith;
* Instrumentation tests for MediaUploadAutomationRunner against controlled HTML fixtures.
* Uses DataTransfer injection (no chooser). Simulates: delayed DOM, missing selectors,
* success URL extraction, blocked. Validates timeout/error classification (input_not_found,
* blocked/captcha, upload_timed_out). Runs on emulator or device; fixtures are deterministic.
* blocked/captcha, upload_timed_out). Kept for opt-in diagnostics because Android app uploads no
* longer use this WebView path.
*/
@Ignore("Imgur WebView upload is no longer supported on Android; keep for manual diagnostics.")
@RunWith(AndroidJUnit4.class)
public class MediaUploadAutomationRunnerTest {
private static final String FIXTURE_BASE = "file:///android_asset/fixtures/";
private static final long TEST_TIMEOUT_SEC = 15;
private static final long TEST_TIMEOUT_SEC = 25;
/** Minimal 1x1 PNG for fixture tests (DataTransfer injection). */
private static final byte[] SAMPLE_FILE_BYTES =
@@ -0,0 +1,142 @@
package fivechan.android;
import android.content.Context;
import android.net.Uri;
import android.util.Log;
import java.io.File;
import java.io.IOException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import okhttp3.Call;
import okhttp3.Callback;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
final class CatboxUploader {
private static final String TAG = "CatboxUploader";
private static final String CATBOX_API_URL = "https://catbox.moe/user/api.php";
private static final String CATBOX_FILE_URL_PREFIX = "https://files.catbox.moe/";
private static final String CATBOX_FILE_URL_PREFIX_HTTP = "http://files.catbox.moe/";
private static final int MAX_UPLOAD_ATTEMPTS = 2;
private CatboxUploader() {}
static MediaUploadResult upload(Context context, Uri fileUri, long timeoutSec) {
try {
Log.d(TAG, "Resolving file for catbox upload");
File file = FileUtils.getFileFromUri(context, fileUri);
if (file == null) {
return new MediaUploadResult(false, null, "Could not resolve file");
}
Log.d(TAG, "Resolved catbox upload file: " + file.getName() + " (" + file.length() + " bytes)");
OkHttpClient client =
new OkHttpClient.Builder()
.callTimeout(timeoutSec, TimeUnit.SECONDS)
.connectTimeout(timeoutSec, TimeUnit.SECONDS)
.writeTimeout(timeoutSec, TimeUnit.SECONDS)
.readTimeout(timeoutSec, TimeUnit.SECONDS)
.build();
MediaUploadResult lastResult = null;
for (int attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt++) {
Request request = buildRequest(file);
Log.d(TAG, "Starting catbox upload request attempt " + attempt);
lastResult = executeRequest(client, request, timeoutSec);
if (lastResult.success || !isRetryable(lastResult) || attempt == MAX_UPLOAD_ATTEMPTS) {
return lastResult;
}
Log.w(TAG, "Retrying catbox upload after: " + lastResult.error);
}
return lastResult != null ? lastResult : new MediaUploadResult(false, null, "No result");
} catch (Exception e) {
Log.e(TAG, "Catbox upload failed", e);
return new MediaUploadResult(false, null, e.getMessage());
}
}
private static Request buildRequest(File file) {
RequestBody requestBody =
new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("reqtype", "fileupload")
.addFormDataPart(
"fileToUpload",
file.getName(),
RequestBody.create(
file, MediaType.parse("application/octet-stream")))
.build();
return new Request.Builder().url(CATBOX_API_URL).post(requestBody).build();
}
private static MediaUploadResult executeRequest(
OkHttpClient client, Request request, long timeoutSec) throws InterruptedException {
Call call = client.newCall(request);
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
call.enqueue(
new Callback() {
@Override
public void onFailure(Call call, IOException e) {
resultRef.set(new MediaUploadResult(false, null, e.getMessage()));
latch.countDown();
}
@Override
public void onResponse(Call call, Response response) {
try {
resultRef.set(parseResponse(response));
} catch (Exception e) {
resultRef.set(new MediaUploadResult(false, null, e.getMessage()));
} finally {
response.close();
latch.countDown();
}
}
});
boolean completed = latch.await(timeoutSec, TimeUnit.SECONDS);
if (!completed) {
call.cancel();
return new MediaUploadResult(false, null, "Upload timeout");
}
MediaUploadResult result = resultRef.get();
return result != null ? result : new MediaUploadResult(false, null, "No result");
}
private static boolean isRetryable(MediaUploadResult result) {
if (result == null || result.success || result.error == null) {
return false;
}
return result.error.equals("Empty response body")
|| result.error.startsWith("Unexpected response body:")
|| result.error.equals("Upload timeout");
}
private static MediaUploadResult parseResponse(Response response) throws IOException {
if (!response.isSuccessful()) {
return new MediaUploadResult(false, null, "Unexpected response " + response.code());
}
if (response.body() == null) {
return new MediaUploadResult(false, null, "Empty response body");
}
String trimmedUrl = response.body().string().trim();
if (trimmedUrl.isEmpty()) {
return new MediaUploadResult(false, null, "Empty response body");
}
if (!trimmedUrl.startsWith(CATBOX_FILE_URL_PREFIX)
&& !trimmedUrl.startsWith(CATBOX_FILE_URL_PREFIX_HTTP)) {
return new MediaUploadResult(false, null, "Unexpected response body: " + trimmedUrl);
}
Log.d(TAG, "Catbox upload successful. URL: " + trimmedUrl);
return new MediaUploadResult(true, trimmedUrl, null);
}
}
@@ -1,14 +1,10 @@
package fivechan.android;
import android.app.Activity;
import android.content.ContentResolver;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import androidx.activity.result.ActivityResult;
import androidx.appcompat.app.AppCompatActivity;
@@ -29,13 +25,6 @@ import java.util.concurrent.atomic.AtomicReference;
import org.json.JSONArray;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
@CapacitorPlugin(name = "FileUploader")
public class FileUploaderPlugin extends Plugin {
private static final String TAG = "FileUploaderPlugin";
@@ -46,9 +35,10 @@ public class FileUploaderPlugin extends Plugin {
@PluginMethod
public void pickAndUploadMedia(PluginCall call) {
Log.d(TAG, "pickAndUploadMedia called");
List<String> providerOrder = parseProviderOrder(call);
List<String> providerOrder = getProviderOrder(call);
if (providerOrder.isEmpty()) {
providerOrder.add(PROVIDER_CATBOX);
call.reject("No supported upload providers selected");
return;
}
Intent intent = new Intent(Intent.ACTION_GET_CONTENT);
intent.setType("*/*");
@@ -66,9 +56,12 @@ public class FileUploaderPlugin extends Plugin {
Object o = arr.get(i);
if (o instanceof String) {
String p = (String) o;
if (PROVIDER_CATBOX.equals(p)
|| MediaUploadRecipes.PROVIDER_IMGUR.equals(p)) {
if (PROVIDER_CATBOX.equals(p)) {
order.add(p);
} else if (MediaUploadRecipes.isAndroidUploadProvider(p)) {
order.add(p);
} else if (MediaUploadRecipes.PROVIDER_IMGUR.equals(p)) {
Log.d(TAG, "Skipping unsupported Android upload provider: " + p);
}
}
} catch (Exception e) {
@@ -79,6 +72,14 @@ public class FileUploaderPlugin extends Plugin {
return order;
}
private List<String> getProviderOrder(PluginCall call) {
List<String> providerOrder = parseProviderOrder(call);
if (providerOrder.isEmpty() && call.getArray("providerOrder") == null) {
providerOrder.add(PROVIDER_CATBOX);
}
return providerOrder;
}
@ActivityCallback
private void pickFileResult(PluginCall call, ActivityResult result) {
Log.d(TAG, "pickFileResult callback received");
@@ -103,9 +104,10 @@ public class FileUploaderPlugin extends Plugin {
return;
}
List<String> providerOrder = parseProviderOrder(call);
List<String> providerOrder = getProviderOrder(call);
if (providerOrder.isEmpty()) {
providerOrder.add(PROVIDER_CATBOX);
call.reject("No supported upload providers selected");
return;
}
new Thread(
@@ -143,7 +145,7 @@ public class FileUploaderPlugin extends Plugin {
}
attempt.put("error", res.error);
errorSummary.append(provider).append(": ").append(res.error).append("; ");
} else if (MediaUploadRecipes.PROVIDER_IMGUR.equals(provider)) {
} else if (MediaUploadRecipes.isWebViewProvider(provider)) {
MediaUploadResult res = uploadViaWebViewSync(fileUri, provider);
attempt.put("success", res.success);
if (res.success) {
@@ -195,82 +197,18 @@ public class FileUploaderPlugin extends Plugin {
}
private MediaUploadResult uploadToCatboxSync(Uri fileUri) {
try {
File file = FileUtils.getFileFromUri(getContext(), fileUri);
if (file == null) {
return new MediaUploadResult(false, null, "Could not resolve file");
}
JSObject statusUpdate = new JSObject();
statusUpdate.put("status", "Uploading to catbox.moe...");
notifyListeners("uploadStatus", statusUpdate);
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());
}
if (response.body() == null) {
return new MediaUploadResult(false, null, "Empty response body");
}
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());
}
return CatboxUploader.upload(getContext(), fileUri, CATBOX_TIMEOUT_SEC);
}
private MediaUploadResult uploadViaWebViewSync(Uri fileUri, String provider) {
ContentResolver resolver = getContext().getContentResolver();
byte[] fileBytes;
String mimeType;
try {
try (InputStream is = resolver.openInputStream(fileUri)) {
if (is == null) {
return new MediaUploadResult(false, null, "Could not open file stream");
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = is.read(buf)) != -1) {
baos.write(buf, 0, n);
}
fileBytes = baos.toByteArray();
}
String resolvedMime = resolver.getType(fileUri);
mimeType = (resolvedMime == null || resolvedMime.isEmpty())
? "application/octet-stream" : resolvedMime;
} catch (Exception e) {
Log.e(TAG, "Failed to read file", e);
return new MediaUploadResult(false, null, "Could not read file: " + e.getMessage());
}
JSObject statusUpdate = new JSObject();
statusUpdate.put("status", "Uploading to " + provider + "...");
notifyListeners("uploadStatus", statusUpdate);
final byte[] finalFileBytes = fileBytes;
final String finalMimeType = mimeType;
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
@@ -291,12 +229,10 @@ public class FileUploaderPlugin extends Plugin {
MediaUploadAutomationRunner runner =
new MediaUploadAutomationRunner(
getContext(),
finalFileBytes,
fileUri,
fileName,
finalMimeType,
provider,
callback,
null);
callback);
runner.run();
});
@@ -4,10 +4,12 @@ import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.Build;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.webkit.CookieManager;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
@@ -63,9 +65,9 @@ interface MediaUploadCallback {
}
/**
* Non-interactive WebView automation for imgur uploads.
* Primary path injects file bytes with DataTransfer, then polls for success/blocked.
* Legacy Uri chooser interception is retained only as passive fallback.
* Non-interactive WebView automation for provider uploads.
* Android uploads use the WebView file chooser when providers support it. ImgBB and fixture
* tests use DataTransfer byte injection, then poll for success/blocked.
*/
@SuppressLint("SetJavaScriptEnabled")
public class MediaUploadAutomationRunner {
@@ -80,28 +82,33 @@ public class MediaUploadAutomationRunner {
static final String STAGE_SUCCESS_SELECTOR_MATCHED = "success_selector_matched";
static final String STAGE_BLOCKED_DETECTED = "blocked_detected";
static final String STAGE_INPUT_NOT_FOUND = "input_not_found";
static final String STAGE_CHOOSER_NOT_TRIGGERED = "chooser_not_triggered";
static final String STAGE_FILE_PAYLOAD_UNAVAILABLE = "file_payload_unavailable";
static final String STAGE_UPLOAD_TIMED_OUT = "upload_timed_out";
private final Context context;
private final Uri fileUri;
private final String fileName;
private final byte[] fileBytes;
private final String mimeType;
private byte[] fileBytes;
private String mimeType;
private final String provider;
private final MediaUploadCallback callback;
/** When non-null (test fixtures), use instead of getUploadUrl(provider). */
private final String overrideUploadUrl;
private WebView webView;
private ValueCallback<Uri[]> filePathCallback;
private final Handler mainHandler;
private final Runnable pollRunnable;
private final Runnable overallTimeoutRunnable;
private boolean finished;
private boolean fileInjected;
private boolean dataTransferFallbackAttempted;
private boolean submitClicked;
private long startTime;
/** Last matched selector (for diagnostics). */
private String lastMatchedSelector;
/** Number of injection attempts so far (for diagnostics). */
/** Number of trigger or injection attempts so far (for diagnostics). */
private int triggerAttemptCount;
private static final class ResolvedFilePayload {
@@ -155,6 +162,7 @@ public class MediaUploadAutomationRunner {
MediaUploadCallback callback,
String overrideUploadUrl) {
this.context = context.getApplicationContext();
this.fileUri = null;
this.fileName = fileName != null ? fileName : "file";
this.fileBytes = fileBytes;
this.mimeType = mimeType != null ? mimeType : "application/octet-stream";
@@ -163,20 +171,18 @@ public class MediaUploadAutomationRunner {
this.overrideUploadUrl = overrideUploadUrl;
this.mainHandler = new Handler(Looper.getMainLooper());
this.pollRunnable = this::pollForResult;
this.overallTimeoutRunnable = this::handleOverallTimeout;
}
/**
* Legacy constructor (Uri-based). Kept for backward compat; prefer bytes constructor.
* Resolves bytes once so legacy callers still use DataTransfer by default.
*/
/** Real Android upload constructor: prefer native WebView file chooser with this Uri. */
public MediaUploadAutomationRunner(
Context context, Uri fileUri, String fileName, String provider, MediaUploadCallback callback) {
this(context, fileUri, fileName, provider, callback, null);
}
/**
* Legacy test constructor: overrideUploadUrl loads fixture instead of live provider URL.
* Package-visible for instrumentation tests. Requires fileBytes to be passed via bytes constructor.
* Uri constructor with optional fixture URL.
* Package-visible for instrumentation tests, though fixture tests usually use the bytes constructor.
*/
MediaUploadAutomationRunner(
Context context,
@@ -186,15 +192,21 @@ public class MediaUploadAutomationRunner {
MediaUploadCallback callback,
String overrideUploadUrl) {
this.context = context.getApplicationContext();
this.fileUri = fileUri;
this.fileName = fileName != null ? fileName : "file";
ResolvedFilePayload payload = resolveFilePayload(this.context, fileUri);
this.fileBytes = payload.fileBytes;
this.mimeType = payload.mimeType;
this.fileBytes = null;
ContentResolver resolver = this.context.getContentResolver();
String resolvedMime = fileUri != null ? resolver.getType(fileUri) : null;
this.mimeType =
(resolvedMime == null || resolvedMime.isEmpty())
? "application/octet-stream"
: resolvedMime;
this.provider = provider;
this.callback = callback;
this.overrideUploadUrl = overrideUploadUrl;
this.mainHandler = new Handler(Looper.getMainLooper());
this.pollRunnable = this::pollForResult;
this.overallTimeoutRunnable = this::handleOverallTimeout;
}
public void run() {
@@ -209,6 +221,38 @@ public class MediaUploadAutomationRunner {
Log.d(TAG, "[" + provider + "] " + stage + " elapsed=" + elapsedMs() + "ms");
}
private boolean shouldUseFileChooser() {
return fileUri != null
&& overrideUploadUrl == null
&& !MediaUploadRecipes.PROVIDER_IMGBB.equals(provider);
}
private boolean ensureFilePayload() {
if (fileBytes != null && fileBytes.length > 0) {
return true;
}
if (fileUri == null) {
return false;
}
ResolvedFilePayload payload = resolveFilePayload(context, fileUri);
fileBytes = payload.fileBytes;
mimeType = payload.mimeType;
return fileBytes != null && fileBytes.length > 0;
}
private void handleOverallTimeout() {
if (finished) return;
finish(
new MediaUploadResult(
false,
null,
"Upload timeout",
STAGE_UPLOAD_TIMED_OUT,
elapsedMs(),
lastMatchedSelector,
triggerAttemptCount));
}
private void startWebView() {
webView = new WebView(context);
webView.setVisibility(android.view.View.GONE);
@@ -219,6 +263,13 @@ public class MediaUploadAutomationRunner {
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");
CookieManager cookieManager = CookieManager.getInstance();
cookieManager.setAcceptCookie(true);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
cookieManager.setAcceptThirdPartyCookies(webView, true);
}
webView.setWebChromeClient(
new WebChromeClient() {
@@ -227,10 +278,18 @@ public class MediaUploadAutomationRunner {
WebView webView,
ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
// Passive fallback only: observe callback, do not drive chooser flow.
Log.d(TAG, "[" + provider + "] chooser callback observed (passive fallback)");
if (!shouldUseFileChooser()) {
return false;
}
logStage(STAGE_FILE_CHOOSER_CALLBACK);
return false;
MediaUploadAutomationRunner.this.filePathCallback = filePathCallback;
fileInjected = true;
schedulePoll();
if (fileUri != null) {
filePathCallback.onReceiveValue(new Uri[] {fileUri});
MediaUploadAutomationRunner.this.filePathCallback = null;
}
return true;
}
});
@@ -239,7 +298,13 @@ public class MediaUploadAutomationRunner {
@Override
public void onPageFinished(WebView view, String url) {
logStage(STAGE_PAGE_LOADED);
if (fileBytes == null || fileBytes.length == 0) {
if (shouldUseFileChooser()) {
mainHandler.postDelayed(
MediaUploadAutomationRunner.this::triggerFileInput,
MediaUploadRecipes.TRIGGER_INITIAL_DELAY_MS);
return;
}
if (!ensureFilePayload()) {
finish(
new MediaUploadResult(
false,
@@ -266,12 +331,70 @@ public class MediaUploadAutomationRunner {
}
startTime = System.currentTimeMillis();
webView.loadUrl(uploadUrl);
mainHandler.postDelayed(overallTimeoutRunnable, MediaUploadRecipes.getUploadTimeoutMs(provider));
Log.d(TAG, "Loaded " + uploadUrl + " for provider " + provider);
}
private void triggerFileInput() {
if (finished || fileInjected) return;
String js = MediaUploadRecipes.getTriggerFileInputJs(provider);
if (js == null) {
finish(
new MediaUploadResult(
false, null, "No trigger JS for " + provider, "no_recipe", elapsedMs(), null));
return;
}
triggerAttemptCount++;
webView.evaluateJavascript(
js,
value -> {
if (finished || fileInjected) return;
String raw = value == null ? "" : value.trim();
String unquoted =
raw.replaceAll("^\"|\"$", "")
.replace("\\u003d", "=")
.replace("\\\"", "\"")
.trim();
boolean matched = !"false".equals(raw) && unquoted.length() > 0;
if (matched) {
lastMatchedSelector = unquoted;
logStage(STAGE_SELECTOR_MATCHED);
}
scheduleTriggerRetry();
});
}
private void scheduleTriggerRetry() {
if (finished || fileInjected) return;
long elapsed = elapsedMs();
if (elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) {
if (lastMatchedSelector != null && !dataTransferFallbackAttempted) {
dataTransferFallbackAttempted = true;
injectFileViaDataTransfer();
return;
}
String stage = lastMatchedSelector != null ? STAGE_CHOOSER_NOT_TRIGGERED : STAGE_INPUT_NOT_FOUND;
String error =
lastMatchedSelector != null
? "File chooser not triggered"
: "File input not found";
finish(
new MediaUploadResult(
false,
null,
error,
stage,
elapsed,
lastMatchedSelector,
triggerAttemptCount));
return;
}
mainHandler.postDelayed(this::triggerFileInput, MediaUploadRecipes.TRIGGER_RETRY_INTERVAL_MS);
}
private void injectFileViaDataTransfer() {
if (finished || fileInjected) return;
if (fileBytes == null || fileBytes.length == 0) {
if (!ensureFilePayload()) {
finish(
new MediaUploadResult(
false,
@@ -353,7 +476,7 @@ public class MediaUploadAutomationRunner {
return;
}
// Optional submit step after DataTransfer injection: some providers need explicit submit.
// Optional submit step after file selection/injection: some providers need explicit submit.
// Retry submit attempts until one actually clicks; some pages render/enable controls late.
if (fileInjected && !submitClicked) {
String submitJs = MediaUploadRecipes.getSubmitClickJs(provider);
@@ -422,25 +545,38 @@ public class MediaUploadAutomationRunner {
if (finished) return;
finished = true;
mainHandler.removeCallbacks(pollRunnable);
mainHandler.removeCallbacks(overallTimeoutRunnable);
if (filePathCallback != null) {
try {
filePathCallback.onReceiveValue(null);
} catch (Exception ignored) {}
filePathCallback = null;
}
tearDown();
callback.onComplete(result);
}
private void tearDown() {
mainHandler.post(
Runnable cleanup =
() -> {
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;
if (webView == null) {
return;
}
});
try {
webView.stopLoading();
webView.clearHistory();
webView.clearCache(true);
webView.clearSslPreferences();
webView.destroy();
} catch (Exception e) {
Log.w(TAG, "Teardown warning", e);
}
webView = null;
};
if (Looper.myLooper() == Looper.getMainLooper()) {
cleanup.run();
return;
}
mainHandler.post(cleanup);
}
}
@@ -2,22 +2,26 @@ package fivechan.android;
/**
* Provider-specific recipes for automated media upload via WebView.
* Reconciled with electron/media-upload-recipes.js for imgur.
* Reconciled with electron/media-upload-recipes.js for imgbb and imgur.
*
* Android vs Electron (prevent drift):
* - Android: imgur only; no catbox (uses OkHttp). Uses DataTransfer JS injection
* (no user activation); optional submit click after injection. onShowFileChooser kept as passive fallback.
* - Electron: catbox/imgur. CDP DOM.setFileInputFiles + submit click. Catbox Electron-only.
* - Selectors: file input, submit, success extractor, blocked indicators kept in sync for imgur.
* - Android app uploads use native catbox and WebView automation for imgbb. The imgur runner
* is retained for diagnostics/tests only. Real Uri attempts use the WebView file chooser
* callback; fixtures/fallback use DataTransfer JS injection.
* - Electron: catbox/imgur/imgbb. CDP DOM.setFileInputFiles + submit click.
* - Selectors: file input, submit, success extractor, blocked indicators kept in sync for imgbb
* and imgur.
*/
public final class MediaUploadRecipes {
public static final String PROVIDER_IMGUR = "imgur";
public static final String PROVIDER_IMGBB = "imgbb";
/** Max time to wait for upload completion (ms). */
public static final long UPLOAD_TIMEOUT_MS = 45_000;
public static final long IMGBB_UPLOAD_TIMEOUT_MS = 60_000;
/** Max time to wait for provider file input to be found/triggered (ms). */
public static final long FILE_INPUT_TIMEOUT_MS = 8_000;
public static final long FILE_INPUT_TIMEOUT_MS = 15_000;
/** Poll interval for success/blocked checks (ms). */
public static final long POLL_INTERVAL_MS = 500;
/** Initial delay before first file-input trigger attempt (ms). SPAs may need settle time. */
@@ -27,15 +31,29 @@ public final class MediaUploadRecipes {
private MediaUploadRecipes() {}
public static boolean isWebViewProvider(String provider) {
return PROVIDER_IMGUR.equals(provider) || PROVIDER_IMGBB.equals(provider);
}
public static boolean isAndroidUploadProvider(String provider) {
return PROVIDER_IMGBB.equals(provider);
}
public static String getUploadUrl(String provider) {
if (PROVIDER_IMGUR.equals(provider)) {
return "https://imgur.com/upload";
}
if (PROVIDER_IMGBB.equals(provider)) {
return "https://imgbb.com/";
}
return null;
}
/** Returns upload timeout. */
public static long getUploadTimeoutMs(String provider) {
if (PROVIDER_IMGBB.equals(provider)) {
return IMGBB_UPLOAD_TIMEOUT_MS;
}
return UPLOAD_TIMEOUT_MS;
}
@@ -53,9 +71,25 @@ public final class MediaUploadRecipes {
"[type=\"submit\"]",
});
}
if (PROVIDER_IMGBB.equals(provider)) {
return buildImgbbSubmitClickJs();
}
return null;
}
private static String buildImgbbSubmitClickJs() {
return "(function(){"
+ "var select=document.querySelector('#upload-expiration,select[name=\"upload-expiration\"]');"
+ "if(select){select.value='';select.dispatchEvent(new Event('change',{bubbles:true}));}"
+ "function visible(el){if(!el)return false;var r=el.getBoundingClientRect();var s=getComputedStyle(el);"
+ "return r.width>0&&r.height>0&&s.display!=='none'&&s.visibility!=='hidden'&&!el.disabled&&!(el.classList&&el.classList.contains('disabled'));}"
+ "var selectors=['button[data-action=\"upload\"]','button.btn.green','button[type=\"submit\"]','[data-action=\"upload\"]'];"
+ "for(var i=0;i<selectors.length;i++){var nodes=document.querySelectorAll(selectors[i]);"
+ "for(var j=0;j<nodes.length;j++){var n=nodes[j];var txt=((n.textContent||n.value||'')+'').toLowerCase();"
+ "if(visible(n)&&(txt.indexOf('upload')!==-1||n.getAttribute('data-action')==='upload')){n.click();return true;}}}"
+ "return false;})()";
}
private static String buildSubmitClickJs(String[] selectors) {
StringBuilder sb = new StringBuilder("(function(){var s=[");
for (int i = 0; i < selectors.length; i++) {
@@ -99,6 +133,19 @@ public final class MediaUploadRecipes {
fileName,
mimeType);
}
if (PROVIDER_IMGBB.equals(provider)) {
return buildFileInjectionJs(
new String[] {
"#anywhere-upload-input",
"input[data-action=\"anywhere-upload-input\"]",
"input[type=\"file\"]",
"input[type=file]",
},
false,
base64Data,
fileName,
mimeType);
}
return null;
}
@@ -145,6 +192,18 @@ public final class MediaUploadRecipes {
},
false);
}
if (PROVIDER_IMGBB.equals(provider)) {
return buildTriggerFileInputJs(
new String[] {
".btn.btn-big.blue",
"[data-action=\"top-bar-upload\"]",
"#anywhere-upload-input",
"input[data-action=\"anywhere-upload-input\"]",
"input[type=\"file\"]",
"input[type=file]",
},
false);
}
return null;
}
@@ -184,13 +243,24 @@ public final class MediaUploadRecipes {
"video source[src*=\"i.imgur.com\"]",
"video[src*=\"i.imgur.com\"]",
};
return buildImgurSuccessJs(selectorCandidates);
} else {
return null;
return buildDirectHostSuccessJs(selectorCandidates, "i.imgur.com");
} else if (PROVIDER_IMGBB.equals(provider)) {
selectorCandidates =
new String[] {
"input[name=\"html-embed-medium\"]",
"textarea[name=\"html-embed-medium\"]",
"#uploaded-embed-code-1",
"input[value*=\"i.ibb.co\"]",
"textarea",
"img[src*=\"i.ibb.co\"]",
};
return buildDirectHostSuccessJs(selectorCandidates, "i.ibb.co");
}
return null;
}
private static String buildImgurSuccessJs(String[] selectors) {
private static String buildDirectHostSuccessJs(String[] selectors, String directHost) {
String escapedDirectHost = escapeJs(directHost);
StringBuilder sb = new StringBuilder("(function(){var s=[");
for (int i = 0; i < selectors.length; i++) {
if (i > 0) sb.append(",");
@@ -201,16 +271,26 @@ public final class MediaUploadRecipes {
+ "function norm(v){if(!v)return null;v=String(v).trim();if(!v)return null;"
+ "if(v.indexOf('//')===0)v='https:'+v;if(v.indexOf('http')!==0)return null;return v;}"
+ "function hasMediaExt(u){return /\\.(?:jpe?g|png|gif|webp|bmp|avif|mp4|webm)(?:[?#].*)?$/i.test(u);}"
+ "function isDirectHost(u){try{var p=new URL(u,location.href);return p.hostname==='i.imgur.com';}catch(e){return false;}}"
+ "function isDirectHost(u){try{var p=new URL(u,location.href);return p.hostname==='"
+ escapedDirectHost
+ "';}catch(e){return false;}}"
+ "function pick(v){var u=norm(v);if(!u)return null;if(isDirectHost(u)&&hasMediaExt(u))return u;return null;}"
+ "function pickFromText(v){var t=String(v||'');var m=t.match(/https?:\\/\\/[^\\s\"'<>\\[\\]]+/g)||[];"
+ "for(var k=0;k<m.length;k++){var r=pick(m[k]);if(r)return r;}return pick(t);}"
+ "for(var i=0;i<s.length;i++){try{var el=document.querySelector(s[i]);if(!el)continue;"
+ "var c=[el.value,el.getAttribute('value'),el.getAttribute('href'),el.href,el.getAttribute('content'),el.getAttribute('src'),el.src,el.getAttribute('data-src'),el.getAttribute('data-link'),el.getAttribute('data-clipboard-text'),el.textContent];"
+ "for(var j=0;j<c.length;j++){var r=pick(c[j]);if(r)return r;}"
+ "for(var j=0;j<c.length;j++){var r=pickFromText(c[j]);if(r)return r;}"
+ "}catch(e){}}"
+ "var og=document.querySelector('meta[property=\"og:image\"],meta[name=\"og:image\"],meta[name=\"twitter:image\"],meta[property=\"twitter:image\"]');"
+ "if(og){var ro=pick(og.getAttribute('content'));if(ro)return ro;}"
+ "var media=document.querySelector('img[src*=\"i.imgur.com\"],video source[src*=\"i.imgur.com\"],video[src*=\"i.imgur.com\"]');"
+ "if(media){var rm=pick(media.getAttribute('src')||media.src);if(rm)return rm;}"
+ "if(og){var ro=pickFromText(og.getAttribute('content'));if(ro)return ro;}"
+ "var media=document.querySelector('img[src*=\""
+ escapedDirectHost
+ "\"],video source[src*=\""
+ escapedDirectHost
+ "\"],video[src*=\""
+ escapedDirectHost
+ "\"]');"
+ "if(media){var rm=pickFromText(media.getAttribute('src')||media.src);if(rm)return rm;}"
+ "return null;})()");
return sb.toString();
}
@@ -243,6 +323,19 @@ public final class MediaUploadRecipes {
".login",
});
}
if (PROVIDER_IMGBB.equals(provider)) {
return buildBlockedJs(
new String[] {
"#challenge",
".captcha",
"[data-captcha]",
".g-recaptcha",
"#recaptcha",
".login-form",
".signin",
".login",
});
}
return null;
}
@@ -24,6 +24,9 @@ public class MediaUploadRecipesTest {
public void uploadTimeout_exceedsFileInputTimeout() {
assertTrue(
MediaUploadRecipes.UPLOAD_TIMEOUT_MS > MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS);
assertTrue(
MediaUploadRecipes.IMGBB_UPLOAD_TIMEOUT_MS
> MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS);
}
@Test
@@ -33,6 +36,13 @@ public class MediaUploadRecipesTest {
MediaUploadRecipes.getUploadUrl(MediaUploadRecipes.PROVIDER_IMGUR));
}
@Test
public void getUploadUrl_imgbb() {
assertEquals(
"https://imgbb.com/",
MediaUploadRecipes.getUploadUrl(MediaUploadRecipes.PROVIDER_IMGBB));
}
@Test
public void getUploadUrl_unknownProvider_returnsNull() {
assertNull(MediaUploadRecipes.getUploadUrl("catbox"));
@@ -48,6 +58,14 @@ public class MediaUploadRecipesTest {
assertTrue(js.contains("data-file-input"));
}
@Test
public void getTriggerFileInputJs_imgbb_containsSelectors() {
String js = MediaUploadRecipes.getTriggerFileInputJs(MediaUploadRecipes.PROVIDER_IMGBB);
assertNotNull(js);
assertTrue(js.contains("anywhere-upload-input"));
assertTrue(js.contains("btn-big"));
}
@Test
public void getTriggerFileInputJs_unknownProvider_returnsNull() {
assertNull(MediaUploadRecipes.getTriggerFileInputJs("unknown"));
@@ -61,6 +79,15 @@ public class MediaUploadRecipesTest {
assertTrue(js.contains("click"));
}
@Test
public void getSubmitClickJs_imgbb_setsNoAutodeleteAndClicksUpload() {
String js = MediaUploadRecipes.getSubmitClickJs(MediaUploadRecipes.PROVIDER_IMGBB);
assertNotNull(js);
assertTrue(js.contains("upload-expiration"));
assertTrue(js.contains("data-action"));
assertTrue(js.contains("click"));
}
@Test
public void getSuccessJs_imgur_containsImgurSelectors() {
String js = MediaUploadRecipes.getSuccessJs(MediaUploadRecipes.PROVIDER_IMGUR);
@@ -68,6 +95,14 @@ public class MediaUploadRecipesTest {
assertTrue(js.contains("i.imgur.com"));
}
@Test
public void getSuccessJs_imgbb_containsImgbbSelectors() {
String js = MediaUploadRecipes.getSuccessJs(MediaUploadRecipes.PROVIDER_IMGBB);
assertNotNull(js);
assertTrue(js.contains("i.ibb.co"));
assertTrue(js.contains("html-embed-medium"));
}
@Test
public void getBlockedJs_imgur_containsChallengeSelectors() {
String js = MediaUploadRecipes.getBlockedJs(MediaUploadRecipes.PROVIDER_IMGUR);