chore(media-upload): remove Postimages provider

Postimages cannot support WebView automation (site blocks automated uploads).
Imgur works reliably. Keep only Catbox and Imgur as supported providers.
This commit is contained in:
plebeius
2026-02-28 19:13:43 +08:00
parent 274a6fb4b1
commit 7fd3e3fd45
28 changed files with 389 additions and 727 deletions
@@ -3,7 +3,6 @@ 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;
@@ -16,9 +15,9 @@ 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.
* 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.
*/
@RunWith(AndroidJUnit4.class)
public class MediaUploadAutomationRunnerTest {
@@ -26,13 +25,25 @@ public class MediaUploadAutomationRunnerTest {
private static final String FIXTURE_BASE = "file:///android_asset/fixtures/";
private static final long TEST_TIMEOUT_SEC = 15;
/** Minimal 1x1 PNG for fixture tests (DataTransfer injection). */
private static final byte[] SAMPLE_FILE_BYTES =
new byte[] {
(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x02, 0x00, 0x00, 0x00, (byte) 0x90, 0x77, 0x53,
(byte) 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54,
0x08, (byte) 0xd7, 0x63, (byte) 0xf8, (byte) 0xff, (byte) 0xff, 0x3f, 0x03,
0x00, 0x05, (byte) 0xfe, 0x02, (byte) 0xfe, (byte) 0xa8, 0x4c, 0x21,
0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44,
(byte) 0xae, 0x42, 0x60, (byte) 0x82
};
private Context appContext;
private Uri dummyFileUri;
@Before
public void setUp() {
appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
dummyFileUri = Uri.parse("content://test/sample.jpg");
}
@After
@@ -52,8 +63,9 @@ public class MediaUploadAutomationRunnerTest {
MediaUploadAutomationRunner runner =
new MediaUploadAutomationRunner(
appContext,
dummyFileUri,
"sample.jpg",
SAMPLE_FILE_BYTES,
"sample.png",
"image/png",
provider,
callback,
fixtureUrl);
@@ -82,14 +94,14 @@ public class MediaUploadAutomationRunnerTest {
}
@Test
public void fixtureFakeTrigger_triggersChooserNotTriggered() throws Exception {
public void fixtureFakeTrigger_noRealInput_triggersInputNotFound() throws Exception {
MediaUploadResult result =
runWithFixture("fixture_fake_trigger.html", MediaUploadRecipes.PROVIDER_IMGUR);
assertFalse(result.success);
assertEquals("chooser_not_triggered", result.stage);
assertEquals("No real input[type=file] to inject into", "input_not_found", result.stage);
assertNotNull(result.error);
assertTrue(result.error.contains("File chooser not triggered"));
assertTrue(result.error.contains("File input not found"));
}
@Test
@@ -141,55 +153,4 @@ public class MediaUploadAutomationRunnerTest {
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"));
}
}
@@ -1,105 +0,0 @@
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);
}
}
@@ -2,8 +2,8 @@
<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 -->
<!-- Matches imgur selector [data-file-input] but is NOT a real file input; injection must skip it -->
<div data-file-input="true" id="fake">Fake file trigger</div>
<p>Triggers chooser_not_triggered: selector matches but onShowFileChooser never fires.</p>
<p>Triggers input_not_found: selector matches non-input element, no real input[type=file] exists.</p>
</body>
</html>
@@ -2,10 +2,19 @@
<html>
<head><meta charset="utf-8"><title>Success</title></head>
<body>
<!-- Real file input - triggers onShowFileChooser -->
<!-- Real file input - DataTransfer injection sets files, change event fires -->
<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>
<!-- Success extractor: imgur looks for a[href*="i.imgur.com"]. Link shown only after input.files change. -->
<span id="result-container" style="display:none">
<a id="result" href="https://i.imgur.com/abc123.png">Link</a>
</span>
<script>
document.getElementById('uploadFile').addEventListener('change', function() {
if (this.files && this.files.length > 0) {
document.getElementById('result-container').style.display = '';
}
});
</script>
<p>Full flow: DataTransfer injection, change fires, success URL extracted.</p>
</body>
</html>
@@ -1,20 +0,0 @@
<!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>
@@ -1,12 +0,0 @@
<!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>
@@ -1,12 +0,0 @@
<!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>
@@ -1,10 +1,14 @@
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;
@@ -63,8 +67,7 @@ public class FileUploaderPlugin extends Plugin {
if (o instanceof String) {
String p = (String) o;
if (PROVIDER_CATBOX.equals(p)
|| MediaUploadRecipes.PROVIDER_IMGUR.equals(p)
|| MediaUploadRecipes.PROVIDER_POSTIMAGES.equals(p)) {
|| MediaUploadRecipes.PROVIDER_IMGUR.equals(p)) {
order.add(p);
}
}
@@ -140,8 +143,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)
|| MediaUploadRecipes.PROVIDER_POSTIMAGES.equals(provider)) {
} else if (MediaUploadRecipes.PROVIDER_IMGUR.equals(provider)) {
MediaUploadResult res = uploadViaWebViewSync(fileUri, provider);
attempt.put("success", res.success);
if (res.success) {
@@ -243,6 +245,32 @@ public class FileUploaderPlugin extends Plugin {
}
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());
}
final byte[] finalFileBytes = fileBytes;
final String finalMimeType = mimeType;
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
@@ -262,12 +290,21 @@ public class FileUploaderPlugin extends Plugin {
() -> {
MediaUploadAutomationRunner runner =
new MediaUploadAutomationRunner(
getContext(), fileUri, fileName, provider, callback);
getContext(),
finalFileBytes,
fileName,
finalMimeType,
provider,
callback,
null);
runner.run();
});
try {
boolean ok = latch.await(MediaUploadRecipes.UPLOAD_TIMEOUT_MS + 5000, TimeUnit.MILLISECONDS);
boolean ok =
latch.await(
MediaUploadRecipes.getUploadTimeoutMs(provider) + 5000,
TimeUnit.MILLISECONDS);
if (!ok) {
return new MediaUploadResult(false, null, "WebView upload timeout");
}
@@ -1,16 +1,20 @@
package fivechan.android;
import android.annotation.SuppressLint;
import android.content.ContentResolver;
import android.content.Context;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.util.Base64;
import android.util.Log;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
/** Result of a WebView-based upload attempt. */
final class MediaUploadResult {
@@ -59,8 +63,9 @@ interface MediaUploadCallback {
}
/**
* Non-interactive WebView automation for imgur/postimages uploads.
* Intercepts file chooser, feeds selected URI, polls for success/blocked, then tears down.
* 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.
*/
@SuppressLint("SetJavaScriptEnabled")
public class MediaUploadAutomationRunner {
@@ -69,46 +74,109 @@ public class MediaUploadAutomationRunner {
/** 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_INJECTED = "file_injected";
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";
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 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 boolean finished;
private boolean fileChooserHandled;
private boolean fileInjected;
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). */
/** Number of injection attempts so far (for diagnostics). */
private int triggerAttemptCount;
/** Test mode: simulate chooser when native callback does not fire (instrumentation). */
private boolean simulateChooserScheduled;
private static final class ResolvedFilePayload {
private final byte[] fileBytes;
private final String mimeType;
private ResolvedFilePayload(byte[] fileBytes, String mimeType) {
this.fileBytes = fileBytes;
this.mimeType = mimeType;
}
}
private static ResolvedFilePayload resolveFilePayload(Context context, Uri uri) {
if (context == null || uri == null) {
return new ResolvedFilePayload(null, "application/octet-stream");
}
ContentResolver resolver = context.getContentResolver();
String resolvedMime = resolver.getType(uri);
String safeMime =
(resolvedMime == null || resolvedMime.isEmpty())
? "application/octet-stream"
: resolvedMime;
try (InputStream input = resolver.openInputStream(uri);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
if (input == null) {
return new ResolvedFilePayload(null, safeMime);
}
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
byte[] bytes = output.toByteArray();
return new ResolvedFilePayload(bytes.length > 0 ? bytes : null, safeMime);
} catch (Exception e) {
Log.w(TAG, "Failed to resolve bytes from URI for DataTransfer injection", e);
return new ResolvedFilePayload(null, safeMime);
}
}
/**
* Primary constructor: file bytes + MIME for DataTransfer injection (no user activation).
* Package-visible for instrumentation tests (overrideUploadUrl).
*/
MediaUploadAutomationRunner(
Context context,
byte[] fileBytes,
String fileName,
String mimeType,
String provider,
MediaUploadCallback callback,
String overrideUploadUrl) {
this.context = context.getApplicationContext();
this.fileName = fileName != null ? fileName : "file";
this.fileBytes = fileBytes;
this.mimeType = mimeType != null ? mimeType : "application/octet-stream";
this.provider = provider;
this.callback = callback;
this.overrideUploadUrl = overrideUploadUrl;
this.mainHandler = new Handler(Looper.getMainLooper());
this.pollRunnable = this::pollForResult;
}
/**
* Legacy constructor (Uri-based). Kept for backward compat; prefer bytes constructor.
* Resolves bytes once so legacy callers still use DataTransfer by default.
*/
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.
* Legacy test constructor: overrideUploadUrl loads fixture instead of live provider URL.
* Package-visible for instrumentation tests. Requires fileBytes to be passed via bytes constructor.
*/
MediaUploadAutomationRunner(
Context context,
@@ -118,8 +186,10 @@ public class MediaUploadAutomationRunner {
MediaUploadCallback callback,
String overrideUploadUrl) {
this.context = context.getApplicationContext();
this.fileUri = fileUri;
this.fileName = fileName;
this.fileName = fileName != null ? fileName : "file";
ResolvedFilePayload payload = resolveFilePayload(this.context, fileUri);
this.fileBytes = payload.fileBytes;
this.mimeType = payload.mimeType;
this.provider = provider;
this.callback = callback;
this.overrideUploadUrl = overrideUploadUrl;
@@ -149,8 +219,6 @@ 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");
webView.setWebChromeClient(
new WebChromeClient() {
@@ -159,16 +227,10 @@ public class MediaUploadAutomationRunner {
WebView webView,
ValueCallback<Uri[]> filePathCallback,
FileChooserParams fileChooserParams) {
simulateChooserScheduled = false;
fileChooserHandled = true;
// Passive fallback only: observe callback, do not drive chooser flow.
Log.d(TAG, "[" + provider + "] chooser callback observed (passive fallback)");
logStage(STAGE_FILE_CHOOSER_CALLBACK);
MediaUploadAutomationRunner.this.filePathCallback = filePathCallback;
schedulePoll();
if (fileUri != null) {
filePathCallback.onReceiveValue(new Uri[] {fileUri});
MediaUploadAutomationRunner.this.filePathCallback = null;
}
return true;
return false;
}
});
@@ -177,8 +239,19 @@ public class MediaUploadAutomationRunner {
@Override
public void onPageFinished(WebView view, String url) {
logStage(STAGE_PAGE_LOADED);
MediaUploadAutomationRunner.this.mainHandler.postDelayed(
MediaUploadAutomationRunner.this::scheduleTriggerAttempt,
if (fileBytes == null || fileBytes.length == 0) {
finish(
new MediaUploadResult(
false,
null,
"File payload unavailable for DataTransfer injection",
STAGE_FILE_PAYLOAD_UNAVAILABLE,
elapsedMs(),
null));
return;
}
mainHandler.postDelayed(
MediaUploadAutomationRunner.this::injectFileViaDataTransfer,
MediaUploadRecipes.TRIGGER_INITIAL_DELAY_MS);
}
});
@@ -196,44 +269,27 @@ 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 ? STAGE_CHOOSER_NOT_TRIGGERED : STAGE_INPUT_NOT_FOUND;
String error =
lastMatchedSelector != null
? "File chooser not triggered"
: "File input not found";
private void injectFileViaDataTransfer() {
if (finished || fileInjected) return;
if (fileBytes == null || fileBytes.length == 0) {
finish(
new MediaUploadResult(
false,
null,
error,
stage,
elapsed,
lastMatchedSelector,
triggerAttemptCount));
"File payload unavailable for DataTransfer injection",
STAGE_FILE_PAYLOAD_UNAVAILABLE,
elapsedMs(),
null));
return;
}
mainHandler.postDelayed(this::triggerFileInput, MediaUploadRecipes.TRIGGER_RETRY_INTERVAL_MS);
}
private void triggerFileInput() {
if (finished || fileChooserHandled) return;
String js = MediaUploadRecipes.getTriggerFileInputJs(provider);
String base64 = Base64.encodeToString(fileBytes, Base64.NO_WRAP);
String js = MediaUploadRecipes.getFileInjectionJs(provider, base64, fileName, mimeType);
if (js == null) {
finish(
new MediaUploadResult(
false, null, "No trigger JS for " + provider, "no_recipe", elapsedMs(), null));
false, null, "No injection JS for " + provider, "no_recipe", elapsedMs(), null));
return;
}
fileInputTriggerAttempted = true;
triggerAttemptCount++;
webView.evaluateJavascript(
js,
@@ -249,34 +305,33 @@ public class MediaUploadAutomationRunner {
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);
}
fileInjected = true;
logStage(STAGE_FILE_INJECTED);
schedulePoll();
return;
}
scheduleTriggerRetry();
scheduleInjectRetry();
});
}
private void scheduleInjectRetry() {
if (finished || fileInjected) return;
long elapsed = elapsedMs();
if (elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) {
finish(
new MediaUploadResult(
false,
null,
"File input not found",
STAGE_INPUT_NOT_FOUND,
elapsed,
null,
triggerAttemptCount));
return;
}
mainHandler.postDelayed(this::injectFileViaDataTransfer, MediaUploadRecipes.TRIGGER_RETRY_INTERVAL_MS);
}
private void schedulePoll() {
mainHandler.removeCallbacks(pollRunnable);
mainHandler.postDelayed(pollRunnable, MediaUploadRecipes.POLL_INTERVAL_MS);
@@ -286,7 +341,7 @@ public class MediaUploadAutomationRunner {
if (finished) return;
long elapsed = elapsedMs();
if (elapsed >= MediaUploadRecipes.UPLOAD_TIMEOUT_MS) {
if (elapsed >= MediaUploadRecipes.getUploadTimeoutMs(provider)) {
finish(
new MediaUploadResult(
false,
@@ -297,24 +352,10 @@ public class MediaUploadAutomationRunner {
lastMatchedSelector));
return;
}
if (fileInputTriggerAttempted
&& !fileChooserHandled
&& elapsed >= MediaUploadRecipes.FILE_INPUT_TIMEOUT_MS) {
finish(
new MediaUploadResult(
false,
null,
"File chooser not triggered",
STAGE_CHOOSER_NOT_TRIGGERED,
elapsed,
lastMatchedSelector,
triggerAttemptCount));
return;
}
// Optional submit step after chooser: some providers need explicit submit (matches Electron).
if (fileChooserHandled && !submitClicked) {
submitClicked = true;
// Optional submit step after DataTransfer 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);
if (submitJs != null) {
webView.evaluateJavascript(
@@ -322,12 +363,16 @@ public class MediaUploadAutomationRunner {
clicked -> {
if (finished) return;
if ("true".equals(clicked != null ? clicked.trim() : "")) {
submitClicked = true;
logStage(STAGE_SUBMIT_CLICKED);
}
schedulePoll();
});
return;
} else {
// No submit recipe for this provider; rely on passive success polling.
submitClicked = true;
}
// Even when submit isn't confirmed, continue success polling:
// some providers auto-upload on input change and never expose a clickable submit.
}
String successJs = MediaUploadRecipes.getSuccessJs(provider);
@@ -376,14 +421,7 @@ public class MediaUploadAutomationRunner {
private void finish(MediaUploadResult result) {
if (finished) return;
finished = true;
simulateChooserScheduled = false;
mainHandler.removeCallbacks(pollRunnable);
if (filePathCallback != null) {
try {
filePathCallback.onReceiveValue(null);
} catch (Exception ignored) {}
filePathCallback = null;
}
tearDown();
callback.onComplete(result);
}
@@ -2,19 +2,17 @@ package fivechan.android;
/**
* Provider-specific recipes for automated media upload via WebView.
* Reconciled with electron/media-upload-recipes.js for imgur/postimages.
* Reconciled with electron/media-upload-recipes.js for imgur.
*
* 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).
* - 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.
*/
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;
@@ -33,37 +31,29 @@ public final class MediaUploadRecipes {
if (PROVIDER_IMGUR.equals(provider)) {
return "https://imgur.com/upload";
}
if (PROVIDER_POSTIMAGES.equals(provider)) {
return "https://postimages.org";
}
return null;
}
/** Returns upload timeout. */
public static long getUploadTimeoutMs(String provider) {
return UPLOAD_TIMEOUT_MS;
}
/**
* 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 =
return buildSubmitClickJs(
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);
return null;
}
private static String buildSubmitClickJs(String[] selectors) {
@@ -74,7 +64,68 @@ public final class MediaUploadRecipes {
}
sb.append(
"];for(var i=0;i<s.length;i++){var el=document.querySelector(s[i]);if(el){el.click();return"
+ " true;}}return false;})()");
+ " true;}}"
+ "var fi=document.querySelector('input[type=\"file\"],input[type=file]');"
+ "var hasFile=!!(fi&&fi.files&&fi.files.length>0&&fi.files[0]);"
+ "if(fi&&fi.form&&hasFile){try{if(typeof fi.form.requestSubmit==='function'){fi.form.requestSubmit();}else{fi.form.submit();}return true;}catch(e){}}"
+ "var nodes=document.querySelectorAll('button,input[type=\"submit\"],input[type=\"button\"],a,[role=\"button\"]');"
+ "for(var j=0;j<nodes.length;j++){var n=nodes[j];if(!n)continue;"
+ "var txt=((n.textContent||n.value||'')+'').toLowerCase();"
+ "if((txt.indexOf('upload')!==-1||txt.indexOf('start')!==-1||txt.indexOf('send')!==-1)&&hasFile){try{n.click();return true;}catch(e){}}}"
+ "return false;})()");
return sb.toString();
}
/**
* JS to inject file via DataTransfer into file input (no user activation required).
* Decodes base64 to Uint8Array, builds File, sets input.files via DataTransfer,
* dispatches change/input events. Returns matched selector (JSON string) if injection
* succeeded, false otherwise.
* Uses same selector candidates as getTriggerFileInputJs; only injects into actual
* input[type=file] elements.
*/
public static String getFileInjectionJs(String provider, String base64Data, String fileName, String mimeType) {
if (PROVIDER_IMGUR.equals(provider)) {
return buildFileInjectionJs(
new String[] {
"#file-input",
".PopUpActions-fileInput",
"input[type=\"file\"]",
"input[type=file]",
"[data-file-input]",
},
false,
base64Data,
fileName,
mimeType);
}
return null;
}
private static String buildFileInjectionJs(
String[] selectors, boolean strictInputOnly, String base64Data, String fileName, String mimeType) {
String escapedBase64 = escapeJs(base64Data != null ? base64Data : "");
String escapedFileName = escapeJs(fileName != null ? fileName : "file");
String escapedMime = escapeJs(mimeType != null ? mimeType : "application/octet-stream");
StringBuilder sb = new StringBuilder();
sb.append("(function(){var s=[");
for (int i = 0; i < selectors.length; i++) {
if (i > 0) sb.append(",");
sb.append("\"").append(escapeJs(selectors[i])).append("\"");
}
String guard =
strictInputOnly
? "el&&(el.tagName==='INPUT'||el.tagName==='input')&&el.type==='file'"
: "el&&(el.tagName==='INPUT'||el.tagName==='input')&&el.type==='file'";
sb.append("];for(var i=0;i<s.length;i++){var el=document.querySelector(s[i]);if(")
.append(guard)
.append("){try{var b=atob(\"")
.append(escapedBase64)
.append("\");var a=new Uint8Array(b.length);for(var j=0;j<b.length;j++)a[j]=b.charCodeAt(j);var f=new File([a],\"")
.append(escapedFileName)
.append("\",{type:\"")
.append(escapedMime)
.append("\"});var dt=new DataTransfer();dt.items.add(f);var assigned=false;try{el.files=dt.files;assigned=true;}catch(e){}if(!assigned){try{Object.defineProperty(el,'files',{configurable:true,value:dt.files});assigned=true;}catch(e){}}if(!assigned){return false;}el.dispatchEvent(new Event('change',{bubbles:true}));el.dispatchEvent(new Event('input',{bubbles:true}));return JSON.stringify(s[i]);}catch(e){return false;}}}return false;})()");
return sb.toString();
}
@@ -83,34 +134,21 @@ public final class MediaUploadRecipes {
* Uses candidate selectors; first match wins. Returns matched selector or false.
*/
public static String getTriggerFileInputJs(String provider) {
String[] candidates;
if (PROVIDER_IMGUR.equals(provider)) {
candidates =
return buildTriggerFileInputJs(
new String[] {
"#file-input",
".PopUpActions-fileInput",
"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;
},
false);
}
boolean strictInputOnly = PROVIDER_POSTIMAGES.equals(provider);
return buildTriggerFileInputJs(candidates, strictInputOnly);
return null;
}
/**
* 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.
*/
/** Returns matched selector string when found, false otherwise. */
private static String buildTriggerFileInputJs(String[] selectors, boolean strictInputOnly) {
StringBuilder sb = new StringBuilder("(function(){var s=[");
for (int i = 0; i < selectors.length; i++) {
@@ -139,21 +177,42 @@ public final class MediaUploadRecipes {
new String[] {
"a[href*=\"i.imgur.com\"]",
"input[value*=\"i.imgur.com\"]",
"[class*=\"copy-link\"] input",
"[data-link]",
};
} else if (PROVIDER_POSTIMAGES.equals(provider)) {
selectorCandidates =
new String[] {
"input[readonly][value*=\"postimg\"]",
"a[href*=\"i.postimg.cc\"]",
"[class*=\"direct-link\"]",
"textarea",
"meta[property=\"og:image\"][content*=\"i.imgur.com\"]",
"meta[name=\"twitter:image\"][content*=\"i.imgur.com\"]",
"meta[property=\"twitter:image\"][content*=\"i.imgur.com\"]",
"img[src*=\"i.imgur.com\"]",
"video source[src*=\"i.imgur.com\"]",
"video[src*=\"i.imgur.com\"]",
};
return buildImgurSuccessJs(selectorCandidates);
} else {
return null;
}
return buildSuccessJs(selectorCandidates);
}
private static String buildImgurSuccessJs(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(
"];"
+ "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 pick(v){var u=norm(v);if(!u)return null;if(isDirectHost(u)&&hasMediaExt(u))return u;return null;}"
+ "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;}"
+ "}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;}"
+ "return null;})()");
return sb.toString();
}
private static String buildSuccessJs(String[] selectors) {
@@ -162,7 +221,8 @@ public final class MediaUploadRecipes {
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})()");
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();var c=(el.getAttribute(\"content\")||\"\").trim();var src=(el.getAttribute(\"src\")||el.src||\"\").trim();if(h&&h.indexOf(\"http\")===0)return h;if(v&&v.indexOf(\"http\")===0)return v;if(c&&c.indexOf(\"http\")===0)return c;if(src&&src.indexOf(\"http\")===0)return src;if(t&&t.indexOf(\"http\")===0)return t}catch(e){}}return null})()");
return sb.toString();
}
@@ -170,21 +230,20 @@ public final class MediaUploadRecipes {
* 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 =
return buildBlockedJs(
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;
"#challenge",
".captcha",
"[data-captcha]",
".g-recaptcha",
"#recaptcha",
".login-form",
".signin",
".login",
});
}
return buildBlockedJs(blockedIndicators);
return null;
}
private static String buildBlockedJs(String[] selectors) {
@@ -33,13 +33,6 @@ public class MediaUploadRecipesTest {
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"));
@@ -55,14 +48,6 @@ public class MediaUploadRecipesTest {
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"));
@@ -76,13 +61,6 @@ public class MediaUploadRecipesTest {
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);
@@ -90,13 +68,6 @@ public class MediaUploadRecipesTest {
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);
@@ -104,13 +75,6 @@ public class MediaUploadRecipesTest {
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(MediaUploadAutomationRunner.STAGE_INPUT_NOT_FOUND, "input_not_found");
@@ -138,26 +102,4 @@ public class MediaUploadRecipesTest {
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"));
}
}