feat(oekaki): add drawing flow for /i/ (#1144)

* feat(oekaki): add drawing flow for /i/

* fix(oekaki): address review feedback

* fix(oekaki): reset Tegaki edit sessions

* fix(oekaki): block drawing during export

* fix(oekaki): destroy Tegaki on preload errors

* fix(oekaki): preserve drawing on export failure

* fix(oekaki): unlock controls after export failure
This commit is contained in:
Tommaso Casaburi
2026-05-30 15:06:47 +07:00
committed by GitHub
parent 5a0b4909b8
commit 56894700c1
28 changed files with 2134 additions and 49 deletions
@@ -3,6 +3,7 @@ package fivechan.android;
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.util.Base64;
import android.util.Log;
import androidx.activity.result.ActivityResult;
@@ -31,6 +32,7 @@ public class FileUploaderPlugin extends Plugin {
private static final String PROVIDER_CATBOX = "catbox";
private static final long CATBOX_TIMEOUT_SEC = 30;
private static final int MAX_GENERATED_UPLOAD_BYTES = 20 * 1024 * 1024;
@PluginMethod
public void pickAndUploadMedia(PluginCall call) {
@@ -47,6 +49,62 @@ public class FileUploaderPlugin extends Plugin {
startActivityForResult(call, intent, "pickFileResult");
}
@PluginMethod
public void uploadGeneratedMedia(PluginCall call) {
Log.d(TAG, "uploadGeneratedMedia called");
List<String> providerOrder = getProviderOrder(call);
if (providerOrder.isEmpty()) {
call.reject("No supported upload providers selected");
return;
}
String base64 = call.getString("base64");
if (base64 == null || base64.trim().isEmpty()) {
call.reject("Generated upload data is required");
return;
}
int commaIndex = base64.indexOf(',');
String base64Payload = commaIndex >= 0 ? base64.substring(commaIndex + 1) : base64;
byte[] bytes;
try {
bytes = Base64.decode(base64Payload, Base64.DEFAULT);
} catch (IllegalArgumentException e) {
call.reject("Generated upload data is invalid");
return;
}
if (bytes.length == 0) {
call.reject("Generated upload data is empty");
return;
}
if (bytes.length > MAX_GENERATED_UPLOAD_BYTES) {
call.reject("Generated upload is too large");
return;
}
String fileName = call.getString("fileName", "tegaki.png");
String mimeType = call.getString("mimeType", "application/octet-stream");
new Thread(
() -> {
File cachedFile = null;
try {
cachedFile = FileUtils.writeBytesToCacheFile(getContext(), fileName, bytes);
tryProvidersSequentially(Uri.fromFile(cachedFile), providerOrder, call, fileName, bytes, mimeType);
} catch (Exception e) {
Log.e(TAG, "Generated upload failed", e);
try {
call.reject("Upload failed: " + e.getMessage());
} catch (Exception rejectEx) {
Log.e(TAG, "Failed to reject generated upload call", rejectEx);
}
} finally {
if (cachedFile != null && cachedFile.exists() && !cachedFile.delete()) {
Log.w(TAG, "Could not delete generated upload cache file: " + cachedFile.getAbsolutePath());
}
}
})
.start();
}
private List<String> parseProviderOrder(PluginCall call) {
List<String> order = new ArrayList<>();
JSArray arr = call.getArray("providerOrder");
@@ -127,8 +185,19 @@ public class FileUploaderPlugin extends Plugin {
}
private void tryProvidersSequentially(Uri fileUri, List<String> providerOrder, PluginCall call) {
tryProvidersSequentially(fileUri, providerOrder, call, null, null, null);
}
private void tryProvidersSequentially(
Uri fileUri,
List<String> providerOrder,
PluginCall call,
String generatedFileName,
byte[] generatedFileBytes,
String generatedMimeType) {
List<JSObject> attempts = new ArrayList<>();
StringBuilder errorSummary = new StringBuilder();
String fileName = generatedFileName != null ? generatedFileName : getFileName(fileUri);
for (String provider : providerOrder) {
JSObject attempt = new JSObject();
@@ -140,18 +209,21 @@ public class FileUploaderPlugin extends Plugin {
if (res.success) {
attempt.put("url", res.url);
attempts.add(attempt);
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
resolveWithSuccess(call, res.url, fileName, provider, attempts);
return;
}
attempt.put("error", res.error);
errorSummary.append(provider).append(": ").append(res.error).append("; ");
} else if (MediaUploadRecipes.isWebViewProvider(provider)) {
MediaUploadResult res = uploadViaWebViewSync(fileUri, provider);
MediaUploadResult res =
generatedFileBytes != null
? uploadGeneratedViaWebViewSync(generatedFileBytes, fileName, generatedMimeType, provider)
: uploadViaWebViewSync(fileUri, provider);
attempt.put("success", res.success);
if (res.success) {
attempt.put("url", res.url);
attempts.add(attempt);
resolveWithSuccess(call, res.url, getFileName(fileUri), provider, attempts);
resolveWithSuccess(call, res.url, fileName, provider, attempts);
return;
}
attempt.put("error", res.error);
@@ -251,4 +323,54 @@ public class FileUploaderPlugin extends Plugin {
return new MediaUploadResult(false, null, "Interrupted");
}
}
private MediaUploadResult uploadGeneratedViaWebViewSync(
byte[] fileBytes, String fileName, String mimeType, String provider) {
JSObject statusUpdate = new JSObject();
statusUpdate.put("status", "Uploading to " + provider + "...");
notifyListeners("uploadStatus", statusUpdate);
CountDownLatch latch = new CountDownLatch(1);
AtomicReference<MediaUploadResult> resultRef = new AtomicReference<>();
AppCompatActivity activity = getActivity();
if (activity == null) {
return new MediaUploadResult(false, null, "Activity unavailable");
}
MediaUploadCallback callback =
res -> {
resultRef.set(res);
latch.countDown();
};
activity.runOnUiThread(
() -> {
MediaUploadAutomationRunner runner =
new MediaUploadAutomationRunner(
getContext(),
fileBytes,
fileName,
mimeType,
provider,
callback,
null);
runner.run();
});
try {
boolean ok =
latch.await(
MediaUploadRecipes.getUploadTimeoutMs(provider) + 5000,
TimeUnit.MILLISECONDS);
if (!ok) {
return new MediaUploadResult(false, null, "WebView upload timeout");
}
MediaUploadResult r = resultRef.get();
return r != null ? r : new MediaUploadResult(false, null, "No result");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return new MediaUploadResult(false, null, "Interrupted");
}
}
}
@@ -16,6 +16,13 @@ public class FileUtils {
private static final Pattern UNSAFE_CHARS = Pattern.compile("[^a-zA-Z0-9._-]");
public static File getFileFromUri(Context context, Uri uri) throws Exception {
if ("file".equals(uri.getScheme()) && uri.getPath() != null) {
File source = new File(uri.getPath());
if (source.isFile()) {
return source;
}
}
String fileName = getFileName(context, uri);
String sanitizedName = sanitizeFileName(fileName);
File file = new File(context.getCacheDir(), sanitizedName);
@@ -69,6 +76,25 @@ public class FileUtils {
* Takes only the last path segment, removes/replaces unsafe chars, enforces max length,
* and falls back to a UUID if the result is empty.
*/
public static File writeBytesToCacheFile(Context context, String fileName, byte[] bytes) throws Exception {
String safeName = sanitizeFileName(fileName);
int dot = safeName.lastIndexOf('.');
String base = dot > 0 ? safeName.substring(0, dot) : safeName;
String ext = dot > 0 ? safeName.substring(dot) : null;
if (base.length() < 3) {
base = (base + "___").substring(0, 3);
}
if (base.length() > 64) {
base = base.substring(0, 64);
}
File file = File.createTempFile(base + "-", ext, context.getCacheDir());
try (FileOutputStream outputStream = new FileOutputStream(file)) {
outputStream.write(bytes);
outputStream.flush();
}
return file;
}
private static String sanitizeFileName(String fileName) {
if (fileName == null || fileName.isEmpty()) {
return UUID.randomUUID().toString();
@@ -82,4 +108,4 @@ public class FileUtils {
}
return basename;
}
}
}