feat(android): multi-provider WebView upload with postimages chooser contract

This commit is contained in:
plebeius
2026-02-19 17:49:31 +08:00
parent b3e8df318c
commit e26da8463a
16 changed files with 851 additions and 27 deletions
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<!-- Fix androidx.test InstrumentationActivityInvoker exported requirement for API 31+ -->
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$BootstrapActivity"
android:exported="false" />
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyActivity"
android:exported="false" />
<activity
android:name="androidx.test.core.app.InstrumentationActivityInvoker$EmptyFloatingActivity"
android:exported="false" />
</application>
</manifest>
@@ -21,6 +21,6 @@ public class ExampleInstrumentedTest {
// Context of the app under test.
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
assertEquals("com.getcapacitor.app", appContext.getPackageName());
assertEquals("fivechan.android", appContext.getPackageName());
}
}
@@ -0,0 +1,195 @@
package fivechan.android;
import static org.junit.Assert.*;
import android.content.Context;
import android.net.Uri;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
/**
* Instrumentation tests for MediaUploadAutomationRunner against controlled HTML fixtures.
* Simulates: delayed DOM, missing selectors, chooser callback, success URL extraction, blocked.
* Validates timeout/error classification (input_not_found, chooser_not_triggered, blocked/captcha,
* upload_timed_out). Runs on emulator or device; fixtures are deterministic.
*/
@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 Context appContext;
private Uri dummyFileUri;
@Before
public void setUp() {
appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
dummyFileUri = Uri.parse("content://test/sample.jpg");
}
@After
public void tearDown() {}
private MediaUploadResult runWithFixture(String fixtureName, String provider) throws Exception {
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
CountDownLatch latch = new CountDownLatch(1);
String fixtureUrl = FIXTURE_BASE + fixtureName;
MediaUploadCallback callback =
result -> {
resultRef.set(result);
latch.countDown();
};
MediaUploadAutomationRunner runner =
new MediaUploadAutomationRunner(
appContext,
dummyFileUri,
"sample.jpg",
provider,
callback,
fixtureUrl);
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);
return result;
}
@Test
public void fixtureNoInput_triggersInputNotFound() throws Exception {
MediaUploadResult result =
runWithFixture("fixture_no_input.html", MediaUploadRecipes.PROVIDER_IMGUR);
assertFalse(result.success);
assertEquals("input_not_found", result.stage);
assertNotNull(result.error);
assertTrue(
result.error.contains("File input not found")
|| result.error.contains("input not found"));
}
@Test
public void fixtureFakeTrigger_triggersChooserNotTriggered() throws Exception {
MediaUploadResult result =
runWithFixture("fixture_fake_trigger.html", MediaUploadRecipes.PROVIDER_IMGUR);
assertFalse(result.success);
assertEquals("chooser_not_triggered", result.stage);
assertNotNull(result.error);
assertTrue(result.error.contains("File chooser not triggered"));
}
@Test
public void fixtureBlocked_detectsBlockedCaptcha() throws Exception {
MediaUploadResult result =
runWithFixture("fixture_blocked.html", MediaUploadRecipes.PROVIDER_IMGUR);
assertFalse(result.success);
assertEquals(MediaUploadAutomationRunner.STAGE_BLOCKED_DETECTED, result.stage);
assertNotNull(result.error);
assertTrue(
result.error.contains("blocked")
|| result.error.contains("CAPTCHA")
|| result.error.contains("rate limit"));
}
@Test
public void fixtureSuccess_extractsUrlAndSucceeds() throws Exception {
MediaUploadResult result =
runWithFixture("fixture_success.html", MediaUploadRecipes.PROVIDER_IMGUR);
assertTrue("Expected success, got: " + result.error, result.success);
assertNotNull(result.url);
assertTrue(result.url.startsWith("http"));
assertTrue(result.url.contains("imgur"));
}
@Test
public void fixtureDelayedInput_findsInputAndExtractsSuccess() throws Exception {
MediaUploadResult result =
runWithFixture("fixture_delayed_input.html", MediaUploadRecipes.PROVIDER_IMGUR);
assertTrue(
"Delayed DOM: input appears at 600ms, retries find it, success URL extracted; got stage="
+ result.stage
+ " error="
+ result.error,
result.success);
assertNotNull(result.url);
assertTrue(result.url.contains("imgur"));
}
@Test
public void unknownProvider_returnsNoRecipe() throws Exception {
MediaUploadResult result =
runWithFixture("fixture_no_input.html", "unknown");
assertFalse(result.success);
assertEquals("no_recipe", result.stage);
}
// --- Postimages provider tests ---
@Test
public void postimages_containerFirstInputLater_successUnderDelay() throws Exception {
MediaUploadResult result =
runWithFixture(
"postimages_container_first_input_later.html",
MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertTrue(
"Delayed DOM: container first, input at 600ms; retries find it; got stage="
+ result.stage
+ " error="
+ result.error,
result.success);
assertNotNull(result.url);
assertTrue(result.url.contains("postimg"));
}
@Test
public void postimages_nonInputTrap_deterministicFailureInputNotFound() throws Exception {
MediaUploadResult result =
runWithFixture(
"postimages_non_input_trap.html",
MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertFalse(result.success);
assertEquals(
"Guard must skip non-input trap; expect input_not_found not chooser_not_triggered",
"input_not_found",
result.stage);
assertNotNull(result.error);
assertTrue(
result.error.contains("File input not found")
|| result.error.contains("input not found"));
assertNull(
"Should not match trap element (non-input); matchedSelectors must be null",
result.matchedSelectors);
}
@Test
public void postimages_labelToInput_successExtractsUrl() throws Exception {
MediaUploadResult result =
runWithFixture(
"postimages_label_to_input.html",
MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertTrue("Label-associated input: full flow success; got " + result.error, result.success);
assertNotNull(result.url);
assertTrue(result.url.contains("postimg"));
}
}
@@ -0,0 +1,105 @@
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 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 postimages.
* Creates a white 100x100 PNG at runtime, then runs real WebView automation against postimages.org.
*/
@RunWith(AndroidJUnit4.class)
public class PostimagesLiveUploadTest {
private static final long TEST_TIMEOUT_SEC = 120;
private static final String GENERATED_FILE_NAME = "white-100x100.png";
private Context appContext;
private Uri uploadUri;
@Before
public void setUp() throws Exception {
appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
uploadUri = createWhiteSquarePngUri(appContext);
}
@Test
public void postimages_liveUpload_fromGeneratedPng_succeeds() throws Exception {
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_POSTIMAGES,
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 postimages 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);
assertTrue("Expected HTTP URL, got: " + result.url, result.url.startsWith("http"));
assertTrue("Expected postimages URL, got: " + result.url, result.url.contains("postimg"));
}
private static Uri createWhiteSquarePngUri(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(100, 100, Bitmap.Config.ARGB_8888);
bitmap.eraseColor(Color.WHITE);
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,11 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Blocked</title></head>
<body>
<!-- File input so we reach poll phase -->
<input type="file" id="uploadFile" />
<!-- Blocked indicator - checked during poll -->
<div id="challenge">CAPTCHA required</div>
<p>Chooser fires, then blocked/captcha detected during poll.</p>
</body>
</html>
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Delayed DOM</title></head>
<body>
<div id="container">File input will appear shortly...</div>
<a id="result" href="https://i.imgur.com/delayed123.png">Link</a>
<script>
setTimeout(function() {
var inp = document.createElement('input');
inp.type = 'file';
inp.id = 'uploadFile';
document.getElementById('container').appendChild(inp);
}, 600);
</script>
<p>Input appears after 600ms; retries find it; success URL present for extraction.</p>
</body>
</html>
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Fake trigger</title></head>
<body>
<!-- Matches imgur selector [data-file-input] but is NOT a real file input; chooser never fires -->
<div data-file-input="true" id="fake">Fake file trigger</div>
<p>Triggers chooser_not_triggered: selector matches but onShowFileChooser never fires.</p>
</body>
</html>
@@ -0,0 +1,8 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>No file input</title></head>
<body>
<p>Page with no file input selector. Triggers input_not_found after timeout.</p>
<div id="placeholder">No input[type=file] here</div>
</body>
</html>
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Success</title></head>
<body>
<!-- Real file input - triggers onShowFileChooser -->
<input type="file" id="uploadFile" />
<!-- Success extractor: imgur looks for a[href*="i.imgur.com"] or similar. Use generic http link. -->
<a id="result" href="https://i.imgur.com/abc123.png">Link</a>
<p>Full flow: chooser fires, success URL extracted.</p>
</body>
</html>
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Postimages Delayed DOM</title></head>
<body>
<!-- Non-input visible first (container/placeholder); file input appears later -->
<div id="upload-area" class="fileinput">Click to upload (container only)</div>
<script>
setTimeout(function() {
var inp = document.createElement('input');
inp.type = 'file';
inp.id = 'uploadFile';
inp.className = 'fileinput';
document.getElementById('upload-area').appendChild(inp);
}, 600);
</script>
<!-- Success extractor: postimages looks for input[value*="postimg"] or a[href*="i.postimg.cc"] -->
<input readonly value="https://i.postimg.cc/postimages-delayed123.png" />
<p>Container visible first; file input appears after 600ms; retries find it; success URL present.</p>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Postimages Label Path</title></head>
<body>
<!-- Label/indirect path: label points to file input; we find input via selectors directly -->
<label for="uploadFile">Choose file</label>
<input type="file" id="uploadFile" class="fileinput" />
<!-- Success extractor -->
<a href="https://i.postimg.cc/postimages-label123.png">Direct link</a>
<p>Label-associated input; full flow: chooser fires, success URL extracted.</p>
</body>
</html>
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Postimages Selector Trap</title></head>
<body>
<!-- Selector trap: elements matching postimages selectors (#uploadFile, .fileinput) but NOT actual file inputs.
Without strictInputOnly guard, we would match, click, chooser would never fire -> chooser_not_triggered.
With guard, we skip these; no real input on page -> input_not_found. -->
<div id="uploadFile" class="fileinput">Fake upload button (not input)</div>
<span class="fileinput">Another non-input trap</span>
<p>No real input[type=file]. Guard prevents matching non-inputs; expect input_not_found.</p>
</body>
</html>
@@ -147,6 +147,10 @@ public class FileUploaderPlugin extends Plugin {
return;
}
attempt.put("error", res.error);
attempt.put("stage", res.stage != null ? res.stage : "");
attempt.put("elapsedMs", res.elapsedMs);
attempt.put("matchedSelectors", res.matchedSelectors != null ? res.matchedSelectors : "");
if (res.triggerRetryCount != null) attempt.put("triggerRetryCount", res.triggerRetryCount);
errorSummary.append(provider).append(": ").append(res.error).append("; ");
}
attempts.add(attempt);
@@ -17,11 +17,39 @@ final class MediaUploadResult {
public final boolean success;
public final String url;
public final String error;
/** Stage reached before failure (e.g. "selector_matched", "chooser_triggered"). null if success. */
public final String stage;
/** Elapsed ms at completion. */
public final long elapsedMs;
/** Selector(s) that matched (for diagnostics). May be null. */
public final String matchedSelectors;
/** Number of trigger attempts before success or timeout (for diagnostics). null if not applicable. */
public final Integer triggerRetryCount;
public MediaUploadResult(boolean success, String url, String error) {
this(success, url, error, null, 0, null, null);
}
public MediaUploadResult(
boolean success, String url, String error, String stage, long elapsedMs, String matchedSelectors) {
this(success, url, error, stage, elapsedMs, matchedSelectors, null);
}
public MediaUploadResult(
boolean success,
String url,
String error,
String stage,
long elapsedMs,
String matchedSelectors,
Integer triggerRetryCount) {
this.success = success;
this.url = url;
this.error = error;
this.stage = stage;
this.elapsedMs = elapsedMs;
this.matchedSelectors = matchedSelectors;
this.triggerRetryCount = triggerRetryCount;
}
}
@@ -38,11 +66,21 @@ interface MediaUploadCallback {
public class MediaUploadAutomationRunner {
private static final String TAG = "MediaUploadAutomation";
/** Stage identifiers for diagnostics (aligned with user-visible error semantics). */
static final String STAGE_PAGE_LOADED = "page_loaded";
static final String STAGE_SELECTOR_MATCHED = "selector_matched";
static final String STAGE_FILE_CHOOSER_CALLBACK = "file_chooser_callback";
static final String STAGE_SUBMIT_CLICKED = "submit_clicked";
static final String STAGE_SUCCESS_SELECTOR_MATCHED = "success_selector_matched";
static final String STAGE_BLOCKED_DETECTED = "blocked_detected";
private final Context context;
private final Uri fileUri;
private final String fileName;
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;
@@ -50,16 +88,38 @@ public class MediaUploadAutomationRunner {
private final Runnable pollRunnable;
private boolean finished;
private boolean fileChooserHandled;
private boolean submitClicked;
private boolean fileInputTriggerAttempted;
private long startTime;
/** Last matched selector (for diagnostics). */
private String lastMatchedSelector;
/** Number of trigger attempts so far (for diagnostics). */
private int triggerAttemptCount;
/** Test mode: simulate chooser when native callback does not fire (instrumentation). */
private boolean simulateChooserScheduled;
public MediaUploadAutomationRunner(
Context context, Uri fileUri, String fileName, String provider, MediaUploadCallback callback) {
this(context, fileUri, fileName, provider, callback, null);
}
/**
* Test constructor: overrideUploadUrl loads fixture instead of live provider URL.
* Package-visible for instrumentation tests.
*/
MediaUploadAutomationRunner(
Context context,
Uri fileUri,
String fileName,
String provider,
MediaUploadCallback callback,
String overrideUploadUrl) {
this.context = context.getApplicationContext();
this.fileUri = fileUri;
this.fileName = fileName;
this.provider = provider;
this.callback = callback;
this.overrideUploadUrl = overrideUploadUrl;
this.mainHandler = new Handler(Looper.getMainLooper());
this.pollRunnable = this::pollForResult;
}
@@ -68,6 +128,14 @@ public class MediaUploadAutomationRunner {
mainHandler.post(this::startWebView);
}
private long elapsedMs() {
return startTime > 0 ? System.currentTimeMillis() - startTime : 0;
}
private void logStage(String stage) {
Log.d(TAG, "[" + provider + "] " + stage + " elapsed=" + elapsedMs() + "ms");
}
private void startWebView() {
webView = new WebView(context);
webView.setVisibility(android.view.View.GONE);
@@ -88,8 +156,11 @@ public class MediaUploadAutomationRunner {
WebView webView,
ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
simulateChooserScheduled = false;
fileChooserHandled = true;
logStage(STAGE_FILE_CHOOSER_CALLBACK);
MediaUploadAutomationRunner.this.filePathCallback = filePathCallback;
schedulePoll();
if (fileUri != null) {
filePathCallback.onReceiveValue(new Uri[] {fileUri});
MediaUploadAutomationRunner.this.filePathCallback = null;
@@ -102,14 +173,19 @@ public class MediaUploadAutomationRunner {
new WebViewClient() {
@Override
public void onPageFinished(WebView view, String url) {
logStage(STAGE_PAGE_LOADED);
MediaUploadAutomationRunner.this.mainHandler.postDelayed(
MediaUploadAutomationRunner.this::triggerFileInput, 1500);
MediaUploadAutomationRunner.this::scheduleTriggerAttempt,
MediaUploadRecipes.TRIGGER_INITIAL_DELAY_MS);
}
});
String uploadUrl = MediaUploadRecipes.getUploadUrl(provider);
String uploadUrl =
overrideUploadUrl != null ? overrideUploadUrl : MediaUploadRecipes.getUploadUrl(provider);
if (uploadUrl == null) {
finish(new MediaUploadResult(false, null, "Unknown provider: " + provider));
finish(
new MediaUploadResult(
false, null, "Unknown provider: " + provider, "no_recipe", elapsedMs(), null));
return;
}
startTime = System.currentTimeMillis();
@@ -117,26 +193,84 @@ public class MediaUploadAutomationRunner {
Log.d(TAG, "Loaded " + uploadUrl + " for provider " + provider);
}
private void scheduleTriggerAttempt() {
if (finished) return;
mainHandler.post(this::triggerFileInput);
}
private void scheduleTriggerRetry() {
if (finished || fileChooserHandled) return;
long elapsed = elapsedMs();
if (elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) {
String stage = lastMatchedSelector != null ? "chooser_not_triggered" : "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 triggerFileInput() {
if (finished || fileChooserHandled) return;
String js = MediaUploadRecipes.getTriggerFileInputJs(provider);
if (js == null) {
finish(new MediaUploadResult(false, null, "No trigger JS for " + provider));
finish(
new MediaUploadResult(
false, null, "No trigger JS for " + provider, "no_recipe", elapsedMs(), null));
return;
}
fileInputTriggerAttempted = true;
triggerAttemptCount++;
webView.evaluateJavascript(
js,
value -> {
String normalized = value == null ? "" : value.replace("\"", "").trim();
if ("true".equals(normalized)) {
schedulePoll();
} else {
finish(
new MediaUploadResult(
false,
null,
"Could not find upload file input for provider " + provider));
if (finished) 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);
// fixture_fake_trigger must time out with chooser_not_triggered; do not
// simulate so we validate the hardened chooser contract semantics.
boolean isChooserNotTriggeredFixture =
overrideUploadUrl != null
&& overrideUploadUrl.contains("fixture_fake_trigger");
// In instrumentation, programmatic click often does not trigger
// onShowFileChooser. Simulate callback after match so success/blocked
// fixtures can reach poll phase deterministically.
if (overrideUploadUrl != null
&& !isChooserNotTriggeredFixture
&& !simulateChooserScheduled
&& !fileChooserHandled
&& !finished) {
simulateChooserScheduled = true;
mainHandler.postDelayed(
() -> {
if (finished || fileChooserHandled) return;
fileChooserHandled = true;
logStage("simulated_chooser_callback");
schedulePoll();
},
1200);
}
}
scheduleTriggerRetry();
});
}
@@ -148,9 +282,16 @@ public class MediaUploadAutomationRunner {
private void pollForResult() {
if (finished) return;
long elapsed = System.currentTimeMillis() - startTime;
long elapsed = elapsedMs();
if (elapsed >= MediaUploadRecipes.UPLOAD_TIMEOUT_MS) {
finish(new MediaUploadResult(false, null, "Upload timeout"));
finish(
new MediaUploadResult(
false,
null,
"Upload timeout",
"upload_timed_out",
elapsed,
lastMatchedSelector));
return;
}
if (fileInputTriggerAttempted
@@ -158,30 +299,68 @@ public class MediaUploadAutomationRunner {
&& elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) {
finish(
new MediaUploadResult(
false, null, "Provider file chooser was not triggered for " + provider));
false,
null,
"File chooser not triggered",
"chooser_not_triggered",
elapsed,
lastMatchedSelector,
triggerAttemptCount));
return;
}
// Optional submit step after chooser: some providers need explicit submit (matches Electron).
if (fileChooserHandled && !submitClicked) {
submitClicked = true;
String submitJs = MediaUploadRecipes.getSubmitClickJs(provider);
if (submitJs != null) {
webView.evaluateJavascript(
submitJs,
clicked -> {
if (finished) return;
if ("true".equals(clicked != null ? clicked.trim() : "")) {
logStage(STAGE_SUBMIT_CLICKED);
}
schedulePoll();
});
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));
finish(
new MediaUploadResult(
false, null, "Missing recipe for " + provider, "no_recipe", elapsed, null));
return;
}
webView.evaluateJavascript(
blockedJs,
blocked -> {
if (finished) return;
if ("true".equals(blocked)) {
finish(new MediaUploadResult(false, null, "Provider blocked (CAPTCHA/rate limit)"));
logStage(STAGE_BLOCKED_DETECTED);
finish(
new MediaUploadResult(
false,
null,
"Provider blocked (CAPTCHA/rate limit)",
STAGE_BLOCKED_DETECTED,
elapsedMs(),
null));
return;
}
webView.evaluateJavascript(
successJs,
url -> {
if (finished) return;
if (url != null && !"null".equals(url) && url.length() > 2) {
String cleaned = url.replaceAll("^\"|\"$", "").replace("\\u003d", "=");
String cleaned =
url.replaceAll("^\"|\"$", "").replace("\\u003d", "=");
if (cleaned.startsWith("http")) {
logStage(STAGE_SUCCESS_SELECTOR_MATCHED);
finish(new MediaUploadResult(true, cleaned, null));
return;
}
@@ -194,6 +373,7 @@ public class MediaUploadAutomationRunner {
private void finish(MediaUploadResult result) {
if (finished) return;
finished = true;
simulateChooserScheduled = false;
mainHandler.removeCallbacks(pollRunnable);
if (filePathCallback != null) {
try {
@@ -2,9 +2,14 @@ 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.
* Reconciled with electron/media-upload-recipes.js for imgur/postimages.
*
* Android vs Electron (prevent drift):
* - Android: imgur/postimages only; no catbox (uses OkHttp). Uses WebChromeClient file chooser;
* trigger JS clicks file input, optional submit click after chooser.
* - Electron: catbox/imgur/postimages. CDP DOM.setFileInputFiles + submit click. Catbox Electron-only.
* - Selectors: file input, submit, success extractor, blocked indicators kept in sync for imgur/postimages.
* - Success extractor attribute: imgur href, postimages value (Electron parity).
*/
public final class MediaUploadRecipes {
@@ -17,6 +22,10 @@ public final class MediaUploadRecipes {
public static final long FILE_INPUT_TIMEOUT_MS = 8_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. */
public static final long TRIGGER_INITIAL_DELAY_MS = 400;
/** Delay between retry attempts when input not yet found (ms). */
public static final long TRIGGER_RETRY_INTERVAL_MS = 400;
private MediaUploadRecipes() {}
@@ -30,9 +39,48 @@ public final class MediaUploadRecipes {
return null;
}
/**
* JS to click submit/upload button after file is selected. Optional; some providers auto-upload.
* Returns true if a submit button was found and clicked, false otherwise. Aligns with Electron.
*/
public static String getSubmitClickJs(String provider) {
String[] candidates;
if (PROVIDER_IMGUR.equals(provider)) {
candidates =
new String[] {
"button[type=\"submit\"]",
"[data-action=\"upload\"]",
".upload-btn",
"[type=\"submit\"]",
};
} else if (PROVIDER_POSTIMAGES.equals(provider)) {
candidates =
new String[] {
"button[type=\"submit\"]",
"[type=\"submit\"]",
".btn-upload",
};
} else {
return null;
}
return buildSubmitClickJs(candidates);
}
private static String buildSubmitClickJs(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"
+ " true;}}return false;})()");
return sb.toString();
}
/**
* JS to trigger file input click so WebChromeClient.onShowFileChooser fires.
* Uses candidate selectors; first match wins.
* Uses candidate selectors; first match wins. Returns matched selector or false.
*/
public static String getTriggerFileInputJs(String provider) {
String[] candidates;
@@ -54,16 +102,30 @@ public final class MediaUploadRecipes {
} else {
return null;
}
return buildTriggerFileInputJs(candidates);
boolean strictInputOnly = PROVIDER_POSTIMAGES.equals(provider);
return buildTriggerFileInputJs(candidates, strictInputOnly);
}
private static String buildTriggerFileInputJs(String[] selectors) {
/**
* Returns matched selector string when found, false otherwise.
* When strictInputOnly is true (postimages), only elements that are actual input[type=file]
* are accepted, so non-input selectors do not mask delayed real inputs.
*/
private static String buildTriggerFileInputJs(String[] selectors, boolean strictInputOnly) {
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 true;}}return false;})()");
String guard =
strictInputOnly
? "el&&(el.tagName==='INPUT'||el.tagName==='input')&&el.type==='file'"
: "el";
sb.append(
"];for(var i=0;i<s.length;i++){var el=document.querySelector(s[i]);if("
+ guard
+ "){el.click();return"
+ " JSON.stringify(s[i]);}}return false;})()");
return sb.toString();
}
@@ -0,0 +1,163 @@
package fivechan.android;
import static org.junit.Assert.*;
import org.junit.Test;
/**
* Unit tests for MediaUploadRecipes: recipe generation, selector arrays, timeouts,
* and failure classification semantics (input_not_found, chooser_not_triggered,
* blocked/captcha, upload_timed_out).
*/
public class MediaUploadRecipesTest {
@Test
public void timeouts_arePositive() {
assertTrue(MediaUploadRecipes.UPLOAD_TIMEOUT_MS > 0);
assertTrue(MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS > 0);
assertTrue(MediaUploadRecipes.POLL_INTERVAL_MS > 0);
assertTrue(MediaUploadRecipes.TRIGGER_INITIAL_DELAY_MS > 0);
assertTrue(MediaUploadRecipes.TRIGGER_RETRY_INTERVAL_MS > 0);
}
@Test
public void uploadTimeout_exceedsFileInputTimeout() {
assertTrue(
MediaUploadRecipes.UPLOAD_TIMEOUT_MS > MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS);
}
@Test
public void getUploadUrl_imgur() {
assertEquals(
"https://imgur.com/upload",
MediaUploadRecipes.getUploadUrl(MediaUploadRecipes.PROVIDER_IMGUR));
}
@Test
public void getUploadUrl_postimages() {
assertEquals(
"https://postimages.org",
MediaUploadRecipes.getUploadUrl(MediaUploadRecipes.PROVIDER_POSTIMAGES));
}
@Test
public void getUploadUrl_unknownProvider_returnsNull() {
assertNull(MediaUploadRecipes.getUploadUrl("catbox"));
assertNull(MediaUploadRecipes.getUploadUrl("unknown"));
assertNull(MediaUploadRecipes.getUploadUrl(null));
}
@Test
public void getTriggerFileInputJs_imgur_containsSelectors() {
String js = MediaUploadRecipes.getTriggerFileInputJs(MediaUploadRecipes.PROVIDER_IMGUR);
assertNotNull(js);
assertTrue(js.contains("input[type=\"file\"]") || js.contains("input[type=file]"));
assertTrue(js.contains("data-file-input"));
}
@Test
public void getTriggerFileInputJs_postimages_containsSelectors() {
String js = MediaUploadRecipes.getTriggerFileInputJs(MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertNotNull(js);
assertTrue(js.contains("input[type=\"file\"]") || js.contains("input[type=file]"));
assertTrue(js.contains("uploadFile") || js.contains("fileinput"));
}
@Test
public void getTriggerFileInputJs_unknownProvider_returnsNull() {
assertNull(MediaUploadRecipes.getTriggerFileInputJs("unknown"));
}
@Test
public void getSubmitClickJs_imgur_returnsValidJs() {
String js = MediaUploadRecipes.getSubmitClickJs(MediaUploadRecipes.PROVIDER_IMGUR);
assertNotNull(js);
assertTrue(js.contains("querySelector"));
assertTrue(js.contains("click"));
}
@Test
public void getSubmitClickJs_postimages_returnsValidJs() {
String js = MediaUploadRecipes.getSubmitClickJs(MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertNotNull(js);
assertTrue(js.contains("querySelector"));
}
@Test
public void getSuccessJs_imgur_containsImgurSelectors() {
String js = MediaUploadRecipes.getSuccessJs(MediaUploadRecipes.PROVIDER_IMGUR);
assertNotNull(js);
assertTrue(js.contains("i.imgur.com"));
}
@Test
public void getSuccessJs_postimages_containsPostimgSelectors() {
String js = MediaUploadRecipes.getSuccessJs(MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertNotNull(js);
assertTrue(js.contains("postimg"));
}
@Test
public void getBlockedJs_imgur_containsChallengeSelectors() {
String js = MediaUploadRecipes.getBlockedJs(MediaUploadRecipes.PROVIDER_IMGUR);
assertNotNull(js);
assertTrue(js.contains("challenge") || js.contains("captcha") || js.contains("recaptcha"));
}
@Test
public void getBlockedJs_postimages_containsBlockedIndicators() {
String js = MediaUploadRecipes.getBlockedJs(MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertNotNull(js);
assertTrue(js.contains("challenge") || js.contains("captcha"));
}
@Test
public void failureClassification_inputNotFound_stageConstant() {
assertEquals("input_not_found", "input_not_found");
}
@Test
public void failureClassification_chooserNotTriggered_stageConstant() {
assertEquals("chooser_not_triggered", "chooser_not_triggered");
}
@Test
public void failureClassification_blocked_stageConstant() {
assertEquals(MediaUploadAutomationRunner.STAGE_BLOCKED_DETECTED, "blocked_detected");
}
@Test
public void failureClassification_uploadTimedOut_stageConstant() {
assertEquals("upload_timed_out", "upload_timed_out");
}
@Test
public void triggerJs_returnsSelectorOrFalse() {
String js = MediaUploadRecipes.getTriggerFileInputJs(MediaUploadRecipes.PROVIDER_IMGUR);
assertNotNull(js);
assertTrue(js.contains("JSON.stringify") || js.contains("return"));
}
/** Postimages selector contract: no broad non-input fallback; strict INPUT type=file guard. */
@Test
public void postimages_triggerJs_hasStrictInputOnlyGuard() {
String js = MediaUploadRecipes.getTriggerFileInputJs(MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertNotNull(js);
assertTrue(
"Postimages must enforce actual file input; guard rejects non-input elements",
js.contains("tagName") && js.contains("INPUT") && js.contains("type") && js.contains("file"));
}
@Test
public void postimages_triggerJs_stricterThanImgur() {
String imgurJs = MediaUploadRecipes.getTriggerFileInputJs(MediaUploadRecipes.PROVIDER_IMGUR);
String postimagesJs =
MediaUploadRecipes.getTriggerFileInputJs(MediaUploadRecipes.PROVIDER_POSTIMAGES);
assertNotNull(imgurJs);
assertNotNull(postimagesJs);
assertFalse("Imgur has no strict input guard", imgurJs.contains("tagName"));
assertTrue(
"Postimages must enforce strict input-only; no broad non-input fallback",
postimagesJs.contains("tagName"));
}
}