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