mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
+14
-7
@@ -20,11 +20,11 @@ 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.
|
||||
* Live provider integration test (emulator/device) for imgur.
|
||||
* Creates a white 100x100 PNG at runtime, then runs real WebView automation against imgur.com.
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class PostimagesLiveUploadTest {
|
||||
public class ImgurLiveUploadTest {
|
||||
private static final long TEST_TIMEOUT_SEC = 120;
|
||||
private static final String GENERATED_FILE_NAME = "white-100x100.png";
|
||||
|
||||
@@ -38,7 +38,7 @@ public class PostimagesLiveUploadTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
public void postimages_liveUpload_fromGeneratedPng_succeeds() throws Exception {
|
||||
public void imgur_liveUpload_fromGeneratedPng_succeeds() throws Exception {
|
||||
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
|
||||
CountDownLatch latch = new CountDownLatch(1);
|
||||
MediaUploadCallback callback =
|
||||
@@ -52,7 +52,7 @@ public class PostimagesLiveUploadTest {
|
||||
appContext,
|
||||
uploadUri,
|
||||
GENERATED_FILE_NAME,
|
||||
MediaUploadRecipes.PROVIDER_POSTIMAGES,
|
||||
MediaUploadRecipes.PROVIDER_IMGUR,
|
||||
callback);
|
||||
|
||||
runner.run();
|
||||
@@ -64,7 +64,7 @@ public class PostimagesLiveUploadTest {
|
||||
MediaUploadResult result = resultRef.get();
|
||||
assertNotNull("Callback did not receive result", result);
|
||||
assertTrue(
|
||||
"Expected live postimages upload success, got error="
|
||||
"Expected live imgur upload success, got error="
|
||||
+ result.error
|
||||
+ " stage="
|
||||
+ result.stage
|
||||
@@ -77,7 +77,14 @@ public class PostimagesLiveUploadTest {
|
||||
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"));
|
||||
String normalizedUrl = result.url.toLowerCase();
|
||||
assertTrue(
|
||||
"Expected direct i.imgur.com URL, got: " + result.url,
|
||||
normalizedUrl.contains("://i.imgur.com/"));
|
||||
assertTrue(
|
||||
"Expected direct image URL with extension, got: " + result.url,
|
||||
normalizedUrl.matches(
|
||||
"https?://i\\.imgur\\.com/.+\\.(jpg|jpeg|png|gif|webp|bmp|avif|mp4|webm)(?:[?#].*)?"));
|
||||
}
|
||||
|
||||
private static Uri createWhiteSquarePngUri(Context context) throws IOException {
|
||||
+23
-62
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,34 +22,16 @@ yarn retest:quality
|
||||
|
||||
---
|
||||
|
||||
## Postimages chooser contract (required local gate)
|
||||
## Imgur chooser contract (required local gate)
|
||||
|
||||
Runs postimages-specific emulator contract tests against deterministic fixtures. **Emulator required.** Must pass before merge.
|
||||
Runs imgur-specific emulator contract tests against deterministic fixtures. **Emulator required.** Must pass before merge.
|
||||
|
||||
```bash
|
||||
yarn contract:postimages
|
||||
yarn contract:imgur
|
||||
```
|
||||
|
||||
- **Prereq:** Android emulator running or USB device connected.
|
||||
- **Interpretation:** Exit 0 = contract tests pass. Runs `MediaUploadAutomationRunnerTest` (postimages chooser contract + imgur/generic fixtures). Uses deterministic fixtures in `android/app/src/main/assets/fixtures/`.
|
||||
|
||||
---
|
||||
|
||||
## Live postimages test (fully automated, real provider)
|
||||
|
||||
Runs a real provider upload test with no manual taps. The script will:
|
||||
- install missing Android SDK components when `sdkmanager` is available
|
||||
- create/start an emulator AVD when needed
|
||||
- run `PostimagesLiveUploadTest` on a booted emulator/device
|
||||
- generate a blank white `100x100` PNG at runtime inside the instrumentation test
|
||||
|
||||
```bash
|
||||
yarn live:postimages:auto
|
||||
```
|
||||
|
||||
- **Prereq:** Android command-line tools installed (`sdkmanager`, `avdmanager`, `adb`, `emulator`) or available in `ANDROID_SDK_ROOT` / `ANDROID_HOME`.
|
||||
- **Interpretation:** Exit 0 = real upload to `postimages.org` succeeded end-to-end.
|
||||
- **Note:** This is a live-provider check (network/captcha/rate-limit dependent), so it can be flaky compared to fixture contracts.
|
||||
- **Interpretation:** Exit 0 = contract tests pass. Runs `MediaUploadAutomationRunnerTest` (imgur chooser contract + generic fixtures). Uses deterministic fixtures in `android/app/src/main/assets/fixtures/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +81,7 @@ androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0") {
|
||||
|
||||
## Upload Selector Smoke Run (report-only, non-blocking)
|
||||
|
||||
Probes live upload sites (Imgur, PostImages) to verify selectors in `media-upload-recipes` still match. **Always exits 0**; intended for CI report-only and local triage. **Not a merge gate**—check report for selector drift but do not block.
|
||||
Probes live upload sites (Imgur) to verify selectors in `media-upload-recipes` still match. **Always exits 0**; intended for CI report-only and local triage. **Not a merge gate**—check report for selector drift but do not block.
|
||||
|
||||
```bash
|
||||
yarn smoke:upload-selectors
|
||||
@@ -133,8 +115,7 @@ node electron/media-upload-recipes.test.js
|
||||
| Check | Blocking? | Command |
|
||||
|-------|-----------|---------|
|
||||
| Quality gate | Yes | `yarn retest:quality` |
|
||||
| Postimages chooser contract | Yes | `yarn contract:postimages` |
|
||||
| Postimages live provider automation | No (recommended local) | `yarn live:postimages:auto` |
|
||||
| Imgur chooser contract | Yes | `yarn contract:imgur` |
|
||||
| Android instrumentation | No (report-only) | `yarn android:connectedTest` |
|
||||
| Upload selector smoke | No (report-only) | `yarn smoke:upload-selectors` |
|
||||
| Electron unit tests | No (report-only) | `node electron/media-upload-*.test.js` |
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*
|
||||
* Diagnostics (parity with Android): selector match/timeout info included in errors
|
||||
* for debugging. Poll interval 500ms, timeout per recipe matches Android where
|
||||
* providers overlap (imgur/postimages: 45s).
|
||||
* providers overlap (imgur: 45s).
|
||||
*/
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
|
||||
@@ -29,7 +29,7 @@ export function isDirectMediaUrl(url) {
|
||||
/**
|
||||
* Run automated upload for a provider.
|
||||
* @param {Object} options
|
||||
* @param {string} options.provider - Provider id (catbox, imgur, postimages)
|
||||
* @param {string} options.provider - Provider id (catbox, imgur)
|
||||
* @param {string} options.filePath - Absolute path to the file to upload
|
||||
* @returns {Promise<{ url: string; provider: string }>}
|
||||
* @throws {Error} On missing recipe, blocked indicators, timeout, or invalid URL
|
||||
|
||||
@@ -29,7 +29,6 @@ describe('media-upload-automation', () => {
|
||||
it('returns false for non-direct URLs (guards against non-media pages)', () => {
|
||||
expect(isDirectMediaUrl('https://imgur.com/abc123')).toBe(false);
|
||||
expect(isDirectMediaUrl('https://imgur.com/upload')).toBe(false);
|
||||
expect(isDirectMediaUrl('https://postimages.org')).toBe(false);
|
||||
expect(isDirectMediaUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
@@ -41,12 +40,9 @@ describe('media-upload-automation', () => {
|
||||
});
|
||||
|
||||
describe('media-upload-automation + recipes integration', () => {
|
||||
it('imgur and postimages success extractors target direct-media domains', () => {
|
||||
it('imgur success extractor targets direct-media domain', () => {
|
||||
const imgurSelectors = MEDIA_UPLOAD_RECIPES.imgur.successExtractor.selectorCandidates;
|
||||
expect(imgurSelectors.some((s) => s.includes('i.imgur.com'))).toBe(true);
|
||||
|
||||
const postimagesSelectors = MEDIA_UPLOAD_RECIPES.postimages.successExtractor.selectorCandidates;
|
||||
expect(postimagesSelectors.some((s) => s.includes('postimg') || s.includes('i.postimg'))).toBe(true);
|
||||
});
|
||||
|
||||
it('all providers have fallback selector chains for file input and submit', () => {
|
||||
|
||||
@@ -4,13 +4,12 @@
|
||||
* Selectors are candidate-based: try each until one matches. Fail fast on blocked indicators.
|
||||
*
|
||||
* Android vs Electron differences (documented to prevent drift):
|
||||
* - Android (MediaUploadRecipes.java): imgur/postimages only; no catbox (uses different flow).
|
||||
* Uses WebChromeClient file chooser interception; trigger JS clicks file input.
|
||||
* - Electron: catbox/imgur/postimages. Uses CDP DOM.setFileInputFiles + submit button click.
|
||||
* - Android (MediaUploadRecipes.java): imgur only; no catbox (uses different flow).
|
||||
* Uses DataTransfer JS injection.
|
||||
* - Electron: catbox/imgur. Uses CDP DOM.setFileInputFiles + submit button click.
|
||||
* Catbox: Electron-only; keep behavior unchanged.
|
||||
* - Blocked/success selectors: reconciled with Android for imgur/postimages; Electron may add
|
||||
* extra candidates (e.g. [data-captcha], .login-form) where DOM differs.
|
||||
* - Timeouts: imgur/postimages 45s (parity); catbox 30s (Electron-only).
|
||||
* - Blocked/success selectors: reconciled with Android for imgur.
|
||||
* - Timeouts: imgur 45s; catbox 30s (Electron-only).
|
||||
*
|
||||
* @typedef {Object} ProviderRecipe
|
||||
* @property {string} uploadUrl - Full URL of the provider's upload page
|
||||
@@ -49,18 +48,6 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({
|
||||
blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']),
|
||||
timeoutMs: 45_000,
|
||||
}),
|
||||
/* Reconciled with Android MediaUploadRecipes (postimages): file input, success extractors, blocked indicators. */
|
||||
postimages: Object.freeze({
|
||||
uploadUrl: 'https://postimages.org',
|
||||
fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '#uploadFile', '.fileinput']),
|
||||
submitSelectorCandidates: Object.freeze(['button[type="submit"]', '[type="submit"]', '.btn-upload']),
|
||||
successExtractor: Object.freeze({
|
||||
selectorCandidates: Object.freeze(['input[readonly][value*="postimg"]', 'a[href*="i.postimg.cc"]', '[class*="direct-link"]', 'textarea']),
|
||||
attribute: 'value',
|
||||
}),
|
||||
blockedIndicators: Object.freeze(['#challenge', '.captcha', '.g-recaptcha', '#recaptcha']),
|
||||
timeoutMs: 45_000,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,6 @@ describe('media-upload-recipes', () => {
|
||||
it('exports MEDIA_UPLOAD_RECIPES with expected providers', () => {
|
||||
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('catbox');
|
||||
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgur');
|
||||
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('postimages');
|
||||
});
|
||||
|
||||
it('validateRecipes passes for current recipes', () => {
|
||||
@@ -28,9 +27,8 @@ describe('media-upload-recipes', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('imgur and postimages have 45s timeout (parity with Android)', () => {
|
||||
it('imgur has 45s timeout (parity with Android)', () => {
|
||||
expect(MEDIA_UPLOAD_RECIPES.imgur.timeoutMs).toBe(45_000);
|
||||
expect(MEDIA_UPLOAD_RECIPES.postimages.timeoutMs).toBe(45_000);
|
||||
});
|
||||
|
||||
it('selector fallback order: generic file input before provider-specific', () => {
|
||||
|
||||
+1
-2
@@ -91,8 +91,7 @@
|
||||
"doctor": "react-doctor . -y",
|
||||
"doctor:score": "react-doctor . --score -y",
|
||||
"doctor:verbose": "react-doctor . --verbose -y",
|
||||
"contract:postimages": "cd android && ./gradlew :app:connectedDebugAndroidTest -Pandroid.experimental.androidTest.useUnifiedTestPlatform=false -Pandroid.testInstrumentationRunnerArguments.class=fivechan.android.MediaUploadAutomationRunnerTest",
|
||||
"live:postimages:auto": "bash scripts/run-postimages-live-emulator-test.sh",
|
||||
"contract:imgur": "cd android && ./gradlew :app:connectedDebugAndroidTest -Pandroid.experimental.androidTest.useUnifiedTestPlatform=false -Pandroid.testInstrumentationRunnerArguments.class=fivechan.android.MediaUploadAutomationRunnerTest",
|
||||
"smoke:upload-selectors": "node scripts/smoke-upload-selectors.js",
|
||||
"retest:quality": "yarn test && yarn build && yarn lint && yarn type-check && yarn doctor",
|
||||
"blotter": "node scripts/update-blotter.js",
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "تحميل النسخة الاحتياطية",
|
||||
"boards_you_moderate_nav": "المنتديات التي تقوم بالإشراف عليها",
|
||||
"link_to_file": "رابط للملف",
|
||||
"support_5chan": "دعم 5chan"
|
||||
"support_5chan": "دعم 5chan",
|
||||
"images": "صور"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "ব্যাকআপ ডাউনলোড করুন",
|
||||
"boards_you_moderate_nav": "আপনি যা মডারেট করেন এমন বোর্ড",
|
||||
"link_to_file": "ফাইলের লিংক",
|
||||
"support_5chan": "5chan সমর্থন করুন"
|
||||
"support_5chan": "5chan সমর্থন করুন",
|
||||
"images": "ছবি"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Stáhnout zálohu",
|
||||
"boards_you_moderate_nav": "Fóra, která moderujete",
|
||||
"link_to_file": "Odkaz na soubor",
|
||||
"support_5chan": "Podpořte 5chan"
|
||||
"support_5chan": "Podpořte 5chan",
|
||||
"images": "obrázky"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Download sikkerhedskopi",
|
||||
"boards_you_moderate_nav": "Boards du modererer",
|
||||
"link_to_file": "Link til fil",
|
||||
"support_5chan": "Støt 5chan"
|
||||
"support_5chan": "Støt 5chan",
|
||||
"images": "billeder"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Sicherung herunterladen",
|
||||
"boards_you_moderate_nav": "Von dir moderierte Boards",
|
||||
"link_to_file": "Link zur Datei",
|
||||
"support_5chan": "5chan unterstützen"
|
||||
"support_5chan": "5chan unterstützen",
|
||||
"images": "Bilder"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Λήψη αντιγράφου ασφαλείας",
|
||||
"boards_you_moderate_nav": "Πίνακες που διαχειρίζεστε",
|
||||
"link_to_file": "Σύνδεσμος προς αρχείο",
|
||||
"support_5chan": "Υποστηρίξτε το 5chan"
|
||||
"support_5chan": "Υποστηρίξτε το 5chan",
|
||||
"images": "εικόνες"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Descargar copia de seguridad",
|
||||
"boards_you_moderate_nav": "Tableros que moderas",
|
||||
"link_to_file": "Enlace al archivo",
|
||||
"support_5chan": "Apoyar 5chan"
|
||||
"support_5chan": "Apoyar 5chan",
|
||||
"images": "imágenes"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "دانلود پشتیبان",
|
||||
"boards_you_moderate_nav": "تابلوهایی که مدیریت میکنید",
|
||||
"link_to_file": "لینک به فایل",
|
||||
"support_5chan": "از 5chan پشتیبانی کنید"
|
||||
"support_5chan": "از 5chan پشتیبانی کنید",
|
||||
"images": "تصاویر"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Lataa varmuuskopio",
|
||||
"boards_you_moderate_nav": "Laudat, joita moderoit",
|
||||
"link_to_file": "Linkki tiedostoon",
|
||||
"support_5chan": "Tue 5chan"
|
||||
"support_5chan": "Tue 5chan",
|
||||
"images": "kuvat"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "I-download ang Backup",
|
||||
"boards_you_moderate_nav": "Mga board na minomodera mo",
|
||||
"link_to_file": "Link sa File",
|
||||
"support_5chan": "Suportahan ang 5chan"
|
||||
"support_5chan": "Suportahan ang 5chan",
|
||||
"images": "mga larawan"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Télécharger la sauvegarde",
|
||||
"boards_you_moderate_nav": "Tableaux que vous modérez",
|
||||
"link_to_file": "Lien vers le fichier",
|
||||
"support_5chan": "Soutenir 5chan"
|
||||
"support_5chan": "Soutenir 5chan",
|
||||
"images": "images"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "הורד גיבוי",
|
||||
"boards_you_moderate_nav": "לוחות שאתה מפקח עליהם",
|
||||
"link_to_file": "קישור לקובץ",
|
||||
"support_5chan": "תמוך ב-5chan"
|
||||
"support_5chan": "תמוך ב-5chan",
|
||||
"images": "תמונות"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "बैकअप डाउनलोड करें",
|
||||
"boards_you_moderate_nav": "बोर्ड जिन्हें आप मॉडरेट करते हैं",
|
||||
"link_to_file": "फ़ाइल का लिंक",
|
||||
"support_5chan": "5chan का समर्थन करें"
|
||||
"support_5chan": "5chan का समर्थन करें",
|
||||
"images": "छवियाँ"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Biztonsági mentés letöltése",
|
||||
"boards_you_moderate_nav": "A táblák, amelyeket moderálsz",
|
||||
"link_to_file": "Link a fájlhoz",
|
||||
"support_5chan": "5chan támogatása"
|
||||
"support_5chan": "5chan támogatása",
|
||||
"images": "képek"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Unduh cadangan",
|
||||
"boards_you_moderate_nav": "Papan yang Anda moderasi",
|
||||
"link_to_file": "Tautan ke File",
|
||||
"support_5chan": "Dukung 5chan"
|
||||
"support_5chan": "Dukung 5chan",
|
||||
"images": "gambar"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Scarica backup",
|
||||
"boards_you_moderate_nav": "Board che moderi",
|
||||
"link_to_file": "Link al file",
|
||||
"support_5chan": "Supporta 5chan"
|
||||
"support_5chan": "Supporta 5chan",
|
||||
"images": "immagini"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "バックアップをダウンロード",
|
||||
"boards_you_moderate_nav": "あなたがモデレートする板",
|
||||
"link_to_file": "ファイルへのリンク",
|
||||
"support_5chan": "5chanをサポート"
|
||||
"support_5chan": "5chanをサポート",
|
||||
"images": "画像"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "백업 다운로드",
|
||||
"boards_you_moderate_nav": "당신이 관리하는 게시판",
|
||||
"link_to_file": "파일 링크",
|
||||
"support_5chan": "5chan 지원"
|
||||
"support_5chan": "5chan 지원",
|
||||
"images": "이미지"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "बॅकअप डाउनलोड करा",
|
||||
"boards_you_moderate_nav": "तुम्ही मॉडरेट करता ते बोर्ड",
|
||||
"link_to_file": "फाइलची लिंक",
|
||||
"support_5chan": "5chan चे समर्थन करा"
|
||||
"support_5chan": "5chan चे समर्थन करा",
|
||||
"images": "प्रतिमा"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Back-up downloaden",
|
||||
"boards_you_moderate_nav": "Boards die je beheert",
|
||||
"link_to_file": "Link naar bestand",
|
||||
"support_5chan": "Steun 5chan"
|
||||
"support_5chan": "Steun 5chan",
|
||||
"images": "afbeeldingen"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Last ned sikkerhetskopi",
|
||||
"boards_you_moderate_nav": "Boards du modererer",
|
||||
"link_to_file": "Lenke til fil",
|
||||
"support_5chan": "Støtt 5chan"
|
||||
"support_5chan": "Støtt 5chan",
|
||||
"images": "bilder"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Pobierz kopię zapasową",
|
||||
"boards_you_moderate_nav": "Tablice, które moderujesz",
|
||||
"link_to_file": "Link do pliku",
|
||||
"support_5chan": "Wspieraj 5chan"
|
||||
"support_5chan": "Wspieraj 5chan",
|
||||
"images": "obrazy"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Baixar backup",
|
||||
"boards_you_moderate_nav": "Quadros que você modera",
|
||||
"link_to_file": "Link para o arquivo",
|
||||
"support_5chan": "Apoiar 5chan"
|
||||
"support_5chan": "Apoiar 5chan",
|
||||
"images": "imagens"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Descarcă backup",
|
||||
"boards_you_moderate_nav": "Forumuri pe care le moderezi",
|
||||
"link_to_file": "Link către fișier",
|
||||
"support_5chan": "Sprijiniți 5chan"
|
||||
"support_5chan": "Sprijiniți 5chan",
|
||||
"images": "imagini"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Скачать резервную копию",
|
||||
"boards_you_moderate_nav": "Доски, которые вы модерируете",
|
||||
"link_to_file": "Ссылка на файл",
|
||||
"support_5chan": "Поддержать 5chan"
|
||||
"support_5chan": "Поддержать 5chan",
|
||||
"images": "изображения"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Shkarko kopjen e rezervë",
|
||||
"boards_you_moderate_nav": "Bordet që moderoni",
|
||||
"link_to_file": "Lidhje me skedarin",
|
||||
"support_5chan": "Mbështetni 5chan"
|
||||
"support_5chan": "Mbështetni 5chan",
|
||||
"images": "imazhe"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Ladda ner säkerhetskopia",
|
||||
"boards_you_moderate_nav": "Boards du modererar",
|
||||
"link_to_file": "Länk till fil",
|
||||
"support_5chan": "Stöd 5chan"
|
||||
"support_5chan": "Stöd 5chan",
|
||||
"images": "bilder"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "బ్యాకప్ డౌన్లోడ్ చేయండి",
|
||||
"boards_you_moderate_nav": "మీరు మోడరేట్ చేసే బోర్డ్లు",
|
||||
"link_to_file": "ఫైల్కు లింక్",
|
||||
"support_5chan": "5chanకు మద్దతు ఇవ్వండి"
|
||||
"support_5chan": "5chanకు మద్దతు ఇవ్వండి",
|
||||
"images": "చిత్రాలు"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "ดาวน์โหลดสำรอง",
|
||||
"boards_you_moderate_nav": "บอร์ดที่คุณดูแล",
|
||||
"link_to_file": "ลิงก์ไปยังไฟล์",
|
||||
"support_5chan": "สนับสนุน 5chan"
|
||||
"support_5chan": "สนับสนุน 5chan",
|
||||
"images": "รูปภาพ"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Yedek indir",
|
||||
"boards_you_moderate_nav": "Yönettiğiniz panolar",
|
||||
"link_to_file": "Dosyaya bağlantı",
|
||||
"support_5chan": "5chan'ı Destekle"
|
||||
"support_5chan": "5chan'ı Destekle",
|
||||
"images": "görseller"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Завантажити резервну копію",
|
||||
"boards_you_moderate_nav": "Дошки, які ви модеруєте",
|
||||
"link_to_file": "Посилання на файл",
|
||||
"support_5chan": "Підтримати 5chan"
|
||||
"support_5chan": "Підтримати 5chan",
|
||||
"images": "зображення"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "بیک اپ ڈاؤن لوڈ کریں",
|
||||
"boards_you_moderate_nav": "بورڈز جنہیں آپ ماڈریٹ کرتے ہیں",
|
||||
"link_to_file": "فائل کا لنک",
|
||||
"support_5chan": "5chan کی حمایت کریں"
|
||||
"support_5chan": "5chan کی حمایت کریں",
|
||||
"images": "تصاویر"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "Tải xuống bản sao lưu",
|
||||
"boards_you_moderate_nav": "Các bảng bạn điều hành",
|
||||
"link_to_file": "Liên kết đến tệp",
|
||||
"support_5chan": "Hỗ trợ 5chan"
|
||||
"support_5chan": "Hỗ trợ 5chan",
|
||||
"images": "hình ảnh"
|
||||
}
|
||||
|
||||
@@ -295,5 +295,6 @@
|
||||
"download_backup": "下载备份",
|
||||
"boards_you_moderate_nav": "您管理的板块",
|
||||
"link_to_file": "链接到文件",
|
||||
"support_5chan": "支持 5chan"
|
||||
"support_5chan": "支持 5chan",
|
||||
"images": "图片"
|
||||
}
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
ANDROID_DIR="$ROOT_DIR/android"
|
||||
|
||||
API_LEVEL="${ANDROID_API_LEVEL:-35}"
|
||||
HOST_ARCH="$(uname -m)"
|
||||
DEFAULT_ABI="x86_64"
|
||||
if [[ "$HOST_ARCH" == "arm64" || "$HOST_ARCH" == "aarch64" ]]; then
|
||||
DEFAULT_ABI="arm64-v8a"
|
||||
fi
|
||||
ABI="${ANDROID_ABI:-$DEFAULT_ABI}"
|
||||
TAG="${ANDROID_TAG:-google_apis}"
|
||||
SYSTEM_IMAGE_PACKAGE="system-images;android-${API_LEVEL};${TAG};${ABI}"
|
||||
SYSTEM_IMAGE_DIR="system-images/android-${API_LEVEL}/${TAG}/${ABI}"
|
||||
AVD_NAME="${ANDROID_AVD_NAME:-fivechan-postimages-api${API_LEVEL}}"
|
||||
DEVICE_PROFILE="${ANDROID_DEVICE_PROFILE:-pixel_6}"
|
||||
TEST_CLASS="${ANDROID_TEST_CLASS:-fivechan.android.PostimagesLiveUploadTest}"
|
||||
KEEP_EMULATOR="${KEEP_EMULATOR:-0}"
|
||||
|
||||
if [[ -n "${ANDROID_SDK_ROOT:-}" ]]; then
|
||||
SDK_ROOT="$ANDROID_SDK_ROOT"
|
||||
elif [[ -n "${ANDROID_HOME:-}" ]]; then
|
||||
SDK_ROOT="$ANDROID_HOME"
|
||||
elif [[ -d "$HOME/Library/Android/sdk" ]]; then
|
||||
SDK_ROOT="$HOME/Library/Android/sdk"
|
||||
else
|
||||
SDK_ROOT=""
|
||||
fi
|
||||
|
||||
resolve_tool() {
|
||||
local tool="$1"
|
||||
shift || true
|
||||
local candidate=""
|
||||
for dir in "$@"; do
|
||||
if [[ -n "$dir" && -x "$dir/$tool" ]]; then
|
||||
candidate="$dir/$tool"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ -z "$candidate" ]] && command -v "$tool" >/dev/null 2>&1; then
|
||||
candidate="$(command -v "$tool")"
|
||||
fi
|
||||
printf '%s' "$candidate"
|
||||
}
|
||||
|
||||
sdk_tool_dir() {
|
||||
local relative="$1"
|
||||
if [[ -z "$SDK_ROOT" ]]; then
|
||||
printf ''
|
||||
return
|
||||
fi
|
||||
printf '%s/%s' "$SDK_ROOT" "$relative"
|
||||
}
|
||||
|
||||
SDKMANAGER_BIN="$(resolve_tool "sdkmanager" "$(sdk_tool_dir "cmdline-tools/latest/bin")" "$(sdk_tool_dir "cmdline-tools/bin")" "$(sdk_tool_dir "tools/bin")")"
|
||||
AVDMANAGER_BIN="$(resolve_tool "avdmanager" "$(sdk_tool_dir "cmdline-tools/latest/bin")" "$(sdk_tool_dir "cmdline-tools/bin")" "$(sdk_tool_dir "tools/bin")")"
|
||||
ADB_BIN="$(resolve_tool "adb" "$(sdk_tool_dir "platform-tools")")"
|
||||
EMULATOR_BIN="$(resolve_tool "emulator" "$(sdk_tool_dir "emulator")")"
|
||||
|
||||
missing_sdk_component=0
|
||||
if [[ -z "$SDK_ROOT" || ! -d "$SDK_ROOT" ]]; then
|
||||
missing_sdk_component=1
|
||||
fi
|
||||
if [[ -z "$ADB_BIN" || -z "$EMULATOR_BIN" || -z "$AVDMANAGER_BIN" ]]; then
|
||||
missing_sdk_component=1
|
||||
fi
|
||||
if [[ -n "$SDK_ROOT" ]]; then
|
||||
if [[ ! -d "$SDK_ROOT/platforms/android-${API_LEVEL}" ]]; then
|
||||
missing_sdk_component=1
|
||||
fi
|
||||
if [[ ! -d "$SDK_ROOT/$SYSTEM_IMAGE_DIR" ]]; then
|
||||
missing_sdk_component=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$missing_sdk_component" -eq 1 ]]; then
|
||||
if [[ -z "$SDKMANAGER_BIN" ]]; then
|
||||
echo "Android SDK components are missing and sdkmanager was not found."
|
||||
echo "Install Android command-line tools, then rerun this script."
|
||||
exit 1
|
||||
fi
|
||||
echo "Installing/updating Android SDK components for API ${API_LEVEL}..."
|
||||
yes | "$SDKMANAGER_BIN" --licenses >/dev/null 2>&1 || true
|
||||
"$SDKMANAGER_BIN" --install \
|
||||
"platform-tools" \
|
||||
"emulator" \
|
||||
"platforms;android-${API_LEVEL}" \
|
||||
"$SYSTEM_IMAGE_PACKAGE"
|
||||
|
||||
# Re-resolve after installation
|
||||
if [[ -z "$SDK_ROOT" ]]; then
|
||||
SDK_ROOT="$(dirname "$(dirname "$(dirname "$SDKMANAGER_BIN")")")"
|
||||
fi
|
||||
ADB_BIN="$(resolve_tool "adb" "$(sdk_tool_dir "platform-tools")")"
|
||||
EMULATOR_BIN="$(resolve_tool "emulator" "$(sdk_tool_dir "emulator")")"
|
||||
AVDMANAGER_BIN="$(resolve_tool "avdmanager" "$(sdk_tool_dir "cmdline-tools/latest/bin")" "$(sdk_tool_dir "cmdline-tools/bin")" "$(sdk_tool_dir "tools/bin")")"
|
||||
fi
|
||||
|
||||
if [[ -z "$ADB_BIN" || -z "$EMULATOR_BIN" || -z "$AVDMANAGER_BIN" ]]; then
|
||||
echo "Could not locate required Android tools (adb/emulator/avdmanager)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
first_online_emulator() {
|
||||
"$ADB_BIN" devices | awk '$1 ~ /^emulator-[0-9]+$/ && $2 == "device" { print $1; exit }'
|
||||
}
|
||||
|
||||
if ! "$EMULATOR_BIN" -list-avds | awk -v avd="$AVD_NAME" '$0 == avd { found = 1 } END { exit found ? 0 : 1 }'; then
|
||||
echo "Creating AVD: $AVD_NAME ($SYSTEM_IMAGE_PACKAGE)"
|
||||
echo "no" | "$AVDMANAGER_BIN" create avd --name "$AVD_NAME" --package "$SYSTEM_IMAGE_PACKAGE" --device "$DEVICE_PROFILE" --force
|
||||
fi
|
||||
|
||||
avd_config="$HOME/.android/avd/${AVD_NAME}.avd/config.ini"
|
||||
if [[ -f "$avd_config" ]] && ! awk -v expected="abi.type=${ABI}" '$0 == expected { found = 1 } END { exit found ? 0 : 1 }' "$avd_config"; then
|
||||
echo "Recreating AVD $AVD_NAME for ABI $ABI"
|
||||
"$AVDMANAGER_BIN" delete avd --name "$AVD_NAME" >/dev/null 2>&1 || true
|
||||
echo "no" | "$AVDMANAGER_BIN" create avd --name "$AVD_NAME" --package "$SYSTEM_IMAGE_PACKAGE" --device "$DEVICE_PROFILE" --force
|
||||
fi
|
||||
|
||||
started_emulator=0
|
||||
serial="${ANDROID_SERIAL:-}"
|
||||
if [[ -z "$serial" ]]; then
|
||||
serial="$(first_online_emulator)"
|
||||
fi
|
||||
|
||||
if [[ -z "$serial" ]]; then
|
||||
echo "Starting emulator AVD: $AVD_NAME"
|
||||
"$EMULATOR_BIN" -avd "$AVD_NAME" -no-boot-anim -no-snapshot-save -netdelay none -netspeed full >/tmp/postimages-live-emulator.log 2>&1 &
|
||||
emulator_pid=$!
|
||||
started_emulator=1
|
||||
|
||||
for _ in {1..90}; do
|
||||
if ! kill -0 "$emulator_pid" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
serial="$(first_online_emulator)"
|
||||
if [[ -n "$serial" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "$serial" ]]; then
|
||||
echo "Failed to detect running emulator serial. Check /tmp/postimages-live-emulator.log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
if [[ "$started_emulator" -eq 1 && "$KEEP_EMULATOR" != "1" ]]; then
|
||||
"$ADB_BIN" -s "$serial" emu kill >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo "Using emulator serial: $serial"
|
||||
"$ADB_BIN" -s "$serial" wait-for-device
|
||||
|
||||
boot_completed=""
|
||||
for _ in {1..180}; do
|
||||
boot_completed="$("$ADB_BIN" -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')"
|
||||
if [[ "$boot_completed" == "1" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "$boot_completed" != "1" ]]; then
|
||||
echo "Emulator did not finish booting in time."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$ADB_BIN" -s "$serial" shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true
|
||||
"$ADB_BIN" -s "$serial" shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true
|
||||
"$ADB_BIN" -s "$serial" shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true
|
||||
|
||||
echo "Running live postimages upload instrumentation test..."
|
||||
pushd "$ANDROID_DIR" >/dev/null
|
||||
ANDROID_SERIAL="$serial" ./gradlew \
|
||||
:app:connectedDebugAndroidTest \
|
||||
-Pandroid.experimental.androidTest.useUnifiedTestPlatform=false \
|
||||
-Pandroid.testInstrumentationRunnerArguments.class="$TEST_CLASS"
|
||||
popd >/dev/null
|
||||
|
||||
echo "Live postimages upload test completed."
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Live smoke probe for imgur/postimages upload selectors.
|
||||
* Live smoke probe for imgur upload selectors.
|
||||
* Probes current DOM and reports which configured selectors still match.
|
||||
* Non-blocking: always exits 0. Intended for CI report-only and local triage.
|
||||
*
|
||||
@@ -20,7 +20,7 @@ const REPO_ROOT = join(__dirname, '..');
|
||||
const REPORT_PATH = join(REPO_ROOT, 'scripts', 'upload-selectors-smoke-report.json');
|
||||
const SNAPSHOTS_DIR = join(REPO_ROOT, 'scripts', 'upload-selectors-smoke-snapshots');
|
||||
|
||||
const PROVIDERS = ['imgur', 'postimages'];
|
||||
const PROVIDERS = ['imgur'];
|
||||
|
||||
/** Selectors to probe (from recipes). Loaded dynamically to avoid circular deps. */
|
||||
async function loadRecipes() {
|
||||
|
||||
@@ -226,7 +226,7 @@ const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, i
|
||||
return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink;
|
||||
};
|
||||
|
||||
const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP }: ReplyQuotePreviewProps) => {
|
||||
const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQuotelinkReply, isOP, showTrailingBreak = true }: ReplyQuotePreviewProps) => {
|
||||
const [hoveredCid, setHoveredCid] = useState<string | null>(null);
|
||||
const [outOfViewCid, setOutOfViewCid] = useState<string | null>(null);
|
||||
const directories = useDirectories();
|
||||
@@ -348,6 +348,7 @@ const MobileQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, is
|
||||
</Link>
|
||||
);
|
||||
})()}
|
||||
{showTrailingBreak && <br />}
|
||||
{hoveredCid === quotelinkReply?.cid &&
|
||||
outOfViewCid === quotelinkReply?.cid &&
|
||||
createPortal(
|
||||
@@ -366,7 +367,14 @@ const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, isBacklinkReply, isQ
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return isMobile ? (
|
||||
<MobileQuotePreview backlinkReply={backlinkReply} quotelinkReply={quotelinkReply} isBacklinkReply={isBacklinkReply} isQuotelinkReply={isQuotelinkReply} isOP={isOP} />
|
||||
<MobileQuotePreview
|
||||
backlinkReply={backlinkReply}
|
||||
quotelinkReply={quotelinkReply}
|
||||
isBacklinkReply={isBacklinkReply}
|
||||
isQuotelinkReply={isQuotelinkReply}
|
||||
isOP={isOP}
|
||||
showTrailingBreak={showTrailingBreak}
|
||||
/>
|
||||
) : (
|
||||
<DesktopQuotePreview
|
||||
backlinkReply={backlinkReply}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ const mockSetUploadMode = vi.fn();
|
||||
const mockSetPreferredProvider = vi.fn();
|
||||
const uploadModeRef = vi.hoisted(() => ({ value: 'random' as 'random' | 'preferred' | 'none' }));
|
||||
const preferredProviderRef = vi.hoisted(() => ({
|
||||
value: 'catbox' as 'catbox' | 'imgur' | 'postimages',
|
||||
value: 'catbox' as 'catbox' | 'imgur',
|
||||
}));
|
||||
vi.mock('../../../../stores/use-media-hosting-store', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('../../../../stores/use-media-hosting-store')>();
|
||||
|
||||
@@ -35,12 +35,12 @@ vi.mock('../../lib/media-hosting/provider-order', () => ({
|
||||
getProviderOrder: vi.fn((opts: { mode: string; preferredProvider: string; runtime: string }) => {
|
||||
if (opts.mode === 'none') return [];
|
||||
if (opts.runtime === 'web') return opts.preferredProvider === 'catbox' ? ['catbox'] : [];
|
||||
return opts.mode === 'preferred' ? [opts.preferredProvider] : ['catbox', 'imgur', 'postimages'];
|
||||
return opts.mode === 'preferred' ? [opts.preferredProvider] : ['catbox', 'imgur'];
|
||||
}),
|
||||
}));
|
||||
|
||||
const uploadModeRef = vi.hoisted(() => ({ value: 'random' as 'random' | 'preferred' | 'none' }));
|
||||
const preferredProviderRef = vi.hoisted(() => ({ value: 'catbox' as 'catbox' | 'imgur' | 'postimages' }));
|
||||
const preferredProviderRef = vi.hoisted(() => ({ value: 'catbox' as 'catbox' | 'imgur' }));
|
||||
vi.mock('../../stores/use-media-hosting-store', () => ({
|
||||
default: (selector: (s: { uploadMode: string; preferredProvider: string }) => unknown) =>
|
||||
selector({ uploadMode: uploadModeRef.value, preferredProvider: preferredProviderRef.value }),
|
||||
|
||||
@@ -26,7 +26,7 @@ const ANDROID_STAGE_MAP: Record<string, UploadAttemptStage> = {
|
||||
|
||||
const FILE_SELECTION_CANCELLED_ERROR = 'File selection cancelled';
|
||||
|
||||
const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur', 'postimages'];
|
||||
const VALID_PROVIDERS: ProviderId[] = ['catbox', 'imgur'];
|
||||
|
||||
/** Raw attempt shape from Android plugin rejection payload */
|
||||
interface RawAttempt {
|
||||
|
||||
@@ -6,25 +6,24 @@ describe('provider-order', () => {
|
||||
it('returns single-element array with preferred provider', () => {
|
||||
expect(getPreferredOrder('catbox')).toEqual(['catbox']);
|
||||
expect(getPreferredOrder('imgur')).toEqual(['imgur']);
|
||||
expect(getPreferredOrder('postimages')).toEqual(['postimages']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRandomOrder', () => {
|
||||
it('returns shuffled copy (Fisher-Yates) with default rng', () => {
|
||||
const providers = ['catbox', 'imgur', 'postimages'] as const;
|
||||
const providers = ['catbox', 'imgur'] as const;
|
||||
const result = getRandomOrder(providers);
|
||||
expect(result).toHaveLength(3);
|
||||
expect([...result].sort()).toEqual(['catbox', 'imgur', 'postimages']);
|
||||
expect(result).toHaveLength(2);
|
||||
expect([...result].sort()).toEqual(['catbox', 'imgur']);
|
||||
expect(result).not.toBe(providers);
|
||||
});
|
||||
|
||||
it('uses provided rng for deterministic shuffle', () => {
|
||||
const providers = ['catbox', 'imgur', 'postimages'] as const;
|
||||
const providers = ['catbox', 'imgur'] as const;
|
||||
const rng = vi.fn().mockReturnValue(0.5);
|
||||
const result = getRandomOrder(providers, rng);
|
||||
expect(rng).toHaveBeenCalled();
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles empty array', () => {
|
||||
@@ -80,8 +79,8 @@ describe('provider-order', () => {
|
||||
preferredProvider: 'catbox',
|
||||
runtime: 'electron',
|
||||
});
|
||||
expect(order).toHaveLength(3);
|
||||
expect([...order].sort()).toEqual(['catbox', 'imgur', 'postimages']);
|
||||
expect(order).toHaveLength(2);
|
||||
expect([...order].sort()).toEqual(['catbox', 'imgur']);
|
||||
});
|
||||
|
||||
it('filters by runtime for random mode', () => {
|
||||
|
||||
@@ -56,14 +56,14 @@ describe('orchestrateElectronUpload', () => {
|
||||
const file = new File(['z'], 'z.png', { type: 'image/png' });
|
||||
|
||||
try {
|
||||
await orchestrateElectronUpload(file, ['postimages']);
|
||||
await orchestrateElectronUpload(file, ['imgur']);
|
||||
throw new Error('Expected orchestrateElectronUpload to throw');
|
||||
} catch (error) {
|
||||
const typedError = error as Error & {
|
||||
attempts?: Array<{ provider: string; error?: string; elapsedMs?: number; stage?: string }>;
|
||||
};
|
||||
expect(typedError.message).toBe('All providers failed');
|
||||
expect(typedError.attempts?.[0]?.provider).toBe('postimages');
|
||||
expect(typedError.attempts?.[0]?.provider).toBe('imgur');
|
||||
expect(typedError.attempts?.[0]?.error).toContain('File path required for Electron automation');
|
||||
expect(typedError.attempts?.[0]?.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(typedError.attempts?.[0]?.stage).toBeDefined();
|
||||
@@ -115,21 +115,19 @@ describe('orchestrateElectronUpload', () => {
|
||||
|
||||
it('parses timeout stage when upload or URL extraction times out', async () => {
|
||||
const electronApi = createElectronApiMock();
|
||||
electronApi.automateUploadMedia = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Upload timeout or no direct URL extracted for postimages (elapsed: 45000ms, timeout: 45000ms)'));
|
||||
electronApi.automateUploadMedia = vi.fn().mockRejectedValue(new Error('Upload timeout or no direct URL extracted for imgur (elapsed: 45000ms, timeout: 45000ms)'));
|
||||
window.electronApi = electronApi;
|
||||
|
||||
const file = new File(['x'], 'x.png', { type: 'image/png' });
|
||||
|
||||
try {
|
||||
await orchestrateElectronUpload(file, ['postimages']);
|
||||
await orchestrateElectronUpload(file, ['imgur']);
|
||||
throw new Error('Expected orchestrateElectronUpload to throw');
|
||||
} catch (error) {
|
||||
const typedError = error as Error & {
|
||||
attempts?: Array<{ provider: string; stage?: string }>;
|
||||
};
|
||||
expect(typedError.attempts?.[0]?.provider).toBe('postimages');
|
||||
expect(typedError.attempts?.[0]?.provider).toBe('imgur');
|
||||
expect(typedError.attempts?.[0]?.stage).toBe('timeout');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,12 +22,6 @@ export const MEDIA_HOSTING_PROVIDERS: readonly ProviderDefinition[] = [
|
||||
homepageUrl: 'https://imgur.com',
|
||||
supportedRuntimes: ['electron', 'android'],
|
||||
},
|
||||
{
|
||||
id: 'postimages',
|
||||
label: 'Postimages',
|
||||
homepageUrl: 'https://postimages.org',
|
||||
supportedRuntimes: ['electron', 'android'],
|
||||
},
|
||||
] as const;
|
||||
|
||||
/** Provider IDs for ordering */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Supported media hosting provider identifiers */
|
||||
export type ProviderId = 'catbox' | 'imgur' | 'postimages';
|
||||
export type ProviderId = 'catbox' | 'imgur';
|
||||
|
||||
/** User-facing upload mode */
|
||||
export type UploadMode = 'random' | 'preferred' | 'none';
|
||||
|
||||
@@ -58,11 +58,11 @@ function resolveElectronFilePath(file: File): string | null {
|
||||
|
||||
/**
|
||||
* Uploads a file via a single provider. Catbox uses the web API;
|
||||
* imgur/postimages use Electron automation when available.
|
||||
* imgur uses Electron automation when available.
|
||||
*/
|
||||
async function uploadViaProvider(provider: ProviderId, file: File): Promise<string> {
|
||||
if (provider === 'catbox') return uploadToCatbox(file);
|
||||
if (provider === 'imgur' || provider === 'postimages') {
|
||||
if (provider === 'imgur') {
|
||||
const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia;
|
||||
if (fn) {
|
||||
const filePath = resolveElectronFilePath(file);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const alertChallengeVerificationFailed = (challengeVerification: Challeng
|
||||
|
||||
alert(`Error from ${resolveBoardIdentifier(publication?.subplebbitAddress)}: ${finalMessage || 'unknown error'}`);
|
||||
} else {
|
||||
console.warn('Challenge verification succeeded but no action taken:', challengeVerification);
|
||||
console.log('Challenge verification succeeded:', challengeVerification);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('useMediaHostingStore', () => {
|
||||
|
||||
it('exports MEDIA_HOSTING_PROVIDERS with ids, labels, homepage URLs, runtime metadata', () => {
|
||||
expect(MEDIA_HOSTING_PROVIDERS).toBeDefined();
|
||||
expect(MEDIA_HOSTING_PROVIDERS.length).toBe(3);
|
||||
expect(MEDIA_HOSTING_PROVIDERS.length).toBe(2);
|
||||
const catbox = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'catbox');
|
||||
expect(catbox).toEqual({
|
||||
id: 'catbox',
|
||||
|
||||
@@ -206,20 +206,21 @@ const Board = ({ feedCacheKey, viewType, boardIdentifier: boardIdentifierProp, i
|
||||
|
||||
// Redirect multiboard paths with page-number segments to normalized path (infinite-scroll only)
|
||||
useEffect(() => {
|
||||
if (!isForcedInfiniteScroll) return;
|
||||
if (!isVisible || !isForcedInfiniteScroll) return;
|
||||
const normalized = normalizeMultiboardFeedPath(location.pathname);
|
||||
if (normalized !== location.pathname) {
|
||||
navigate(normalized, { replace: true });
|
||||
}
|
||||
}, [isForcedInfiniteScroll, location.pathname, navigate]);
|
||||
}, [isVisible, isForcedInfiniteScroll, location.pathname, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!effectiveInfiniteScroll && currentPage > totalPages && totalPages > 0) {
|
||||
const targetPage = totalPages;
|
||||
const targetPath = targetPage === 1 ? paginationBasePath : `${paginationBasePath}/${targetPage}`;
|
||||
navigate(targetPath, { replace: true });
|
||||
}
|
||||
}, [effectiveInfiniteScroll, currentPage, totalPages, paginationBasePath, navigate]);
|
||||
}, [isVisible, effectiveInfiniteScroll, currentPage, totalPages, paginationBasePath, navigate]);
|
||||
|
||||
// Scroll to top instantly when page changes in pagination mode
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user