diff --git a/android/app/build.gradle b/android/app/build.gradle index bf87c98..382fcfb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -15,8 +15,8 @@ android { applicationId "md.zennotes" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 20 - versionName "1.1.18" + versionCode 21 + versionName "1.1.19" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/android/app/src/main/java/md/zennotes/ClipboardImageData.java b/android/app/src/main/java/md/zennotes/ClipboardImageData.java new file mode 100644 index 0000000..224e873 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ClipboardImageData.java @@ -0,0 +1,37 @@ +package md.zennotes; + +import java.io.IOException; +import java.io.InputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; + +final class ClipboardImageData { + static final int MAX_BYTES = 10 * 1024 * 1024; + static final class InvalidImage extends IOException { + InvalidImage(String message) { super(message); } + } + static byte[] read(InputStream stream, int limit) throws IOException { + if (stream == null) throw new InvalidImage("Could not read the pasted image."); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + byte[] buffer = new byte[16384]; + int size; + while ((size = stream.read(buffer)) != -1) { + if (size > limit - output.size()) throw new InvalidImage("Pasted images must be 10 MB or smaller."); + output.write(buffer, 0, size); + } + if (output.size() == 0) throw new InvalidImage("The pasted image is empty."); + return output.toByteArray(); + } + static String mimeType(byte[] b) throws IOException { + if (b.length >= 8 && (b[0] & 255) == 137 && b[1] == 80 && b[2] == 78 && b[3] == 71 && + b[4] == 13 && b[5] == 10 && b[6] == 26 && b[7] == 10) return "image/png"; + if (b.length >= 3 && (b[0] & 255) == 255 && (b[1] & 255) == 216 && (b[2] & 255) == 255) return "image/jpeg"; + if (b.length >= 6) { + String header = new String(b, 0, 6, StandardCharsets.US_ASCII); + if (header.equals("GIF87a") || header.equals("GIF89a")) return "image/gif"; + } + if (b.length >= 12 && new String(b, 0, 4, StandardCharsets.US_ASCII).equals("RIFF") && + new String(b, 8, 4, StandardCharsets.US_ASCII).equals("WEBP")) return "image/webp"; + throw new InvalidImage("Paste a PNG, JPEG, GIF, or WebP image."); + } +} diff --git a/android/app/src/main/java/md/zennotes/ClipboardImageRead.java b/android/app/src/main/java/md/zennotes/ClipboardImageRead.java new file mode 100644 index 0000000..38c369b --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ClipboardImageRead.java @@ -0,0 +1,45 @@ +package md.zennotes; + +import java.io.IOException; +import java.io.InputStream; + +/** Own the temporary grant even while a provider has not returned a stream. */ +final class ClipboardImageRead implements AutoCloseable { + private final Runnable releasePermission; + private InputStream stream; + private boolean closed; + + ClipboardImageRead(Runnable releasePermission) { this.releasePermission = releasePermission; } + + InputStream attach(InputStream opened) throws IOException { + synchronized (this) { + if (!closed) { stream = opened; return opened; } + } + closeStream(opened); + throw new IOException("Image paste was cancelled."); + } + + synchronized boolean isClosed() { return closed; } + + synchronized void respondIfOpen(Runnable response) { + if (!closed) response.run(); + } + + @Override public void close() { + InputStream opened; + synchronized (this) { + if (closed) return; + closed = true; + opened = stream; + stream = null; + } + // Release the grant without waiting for provider I/O to finish. + try { releasePermission.run(); } + finally { closeStream(opened); } + } + + private static void closeStream(InputStream stream) { + if (stream == null) return; + try { stream.close(); } catch (IOException | RuntimeException ignored) { /* already closed/revoked */ } + } +} diff --git a/android/app/src/main/java/md/zennotes/ImagePastePlugin.java b/android/app/src/main/java/md/zennotes/ImagePastePlugin.java new file mode 100644 index 0000000..401bb38 --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ImagePastePlugin.java @@ -0,0 +1,126 @@ +package md.zennotes; + +import android.content.res.AssetFileDescriptor; +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; +import android.util.Base64; +import androidx.core.view.inputmethod.InputConnectionCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; +import com.getcapacitor.JSObject; +import com.getcapacitor.Plugin; +import com.getcapacitor.PluginCall; +import com.getcapacitor.PluginMethod; +import com.getcapacitor.annotation.CapacitorPlugin; +import java.io.InputStream; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; + +@CapacitorPlugin(name = "ImagePaste") +public class ImagePastePlugin extends Plugin { + private final Map pending = new HashMap<>(); + private final Handler expiry = new Handler(Looper.getMainLooper()); + private final ExecutorService reader = Executors.newSingleThreadExecutor(); + private ClipboardImageRead activeRead; + private volatile boolean destroyed; + + @Override public void load() { + ((ImagePasteWebView) getBridge().getWebView()).imageReceiver = this::receive; + } + + @PluginMethod public void setEnabled(PluginCall call) { + boolean enabled = call.getBoolean("enabled", false); + getActivity().runOnUiThread(() -> { + if (destroyed) { call.reject("Image pasting is unavailable."); return; } + ((ImagePasteWebView) getBridge().getWebView()).setImagePasteEnabled(enabled); + call.resolve(); + }); + } + + private synchronized boolean receive(InputContentInfoCompat content, int flags) { + if (destroyed || !hasListeners("image") || activeRead != null || !pending.isEmpty() || !"content".equals(content.getContentUri().getScheme())) return false; + boolean supported = false; + for (String type : ImagePasteWebView.IMAGE_TYPES) supported |= content.getDescription().hasMimeType(type); + if (!supported) return false; + try { + if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) content.requestPermission(); + } catch (RuntimeException error) { return false; } + String id = UUID.randomUUID().toString(); + pending.put(id, content); + JSObject event = new JSObject(); + event.put("id", id); + notifyListeners("image", event); + expiry.postDelayed(() -> release(id), 30000); + return true; + } + + private synchronized InputContentInfoCompat take(String id) { return pending.remove(id); } + private void release(String id) { + InputContentInfoCompat content = take(id); + if (content != null) releasePermission(content); + } + private static void releasePermission(InputContentInfoCompat content) { + try { content.releasePermission(); } catch (RuntimeException ignored) { /* provider already revoked it */ } + } + + // Plugin methods run off the UI thread. Never send arbitrary URIs through + // the JS bridge: read only the short-lived token from a user paste event. + @PluginMethod public synchronized void read(PluginCall call) { + if (destroyed) { call.reject("Image pasting is unavailable."); return; } + InputContentInfoCompat content = take(call.getString("id", "")); + if (content == null) { call.reject("The pasted image expired. Please paste it again."); return; } + CancellationSignal cancellation = new CancellationSignal(); + ClipboardImageRead read = new ClipboardImageRead(() -> { + releasePermission(content); + try { cancellation.cancel(); } catch (RuntimeException ignored) { /* provider already gone */ } + }); + activeRead = read; + try { reader.execute(() -> readImage(call, content, read, cancellation)); } + catch (RejectedExecutionException error) { + activeRead = null; + read.close(); + call.reject("Image pasting is unavailable. Please reopen the note."); + } + } + private void readImage(PluginCall call, InputContentInfoCompat content, ClipboardImageRead read, CancellationSignal cancellation) { + try { + if (read.isClosed()) return; + byte[] bytes; + try (AssetFileDescriptor descriptor = getContext().getContentResolver() + .openAssetFileDescriptor(content.getContentUri(), "r", cancellation)) { + InputStream stream = read.attach(descriptor == null ? null : descriptor.createInputStream()); + bytes = ClipboardImageData.read(stream, ClipboardImageData.MAX_BYTES); + } + JSObject result = new JSObject(); + result.put("mimeType", ClipboardImageData.mimeType(bytes)); + result.put("base64", Base64.encodeToString(bytes, Base64.NO_WRAP)); + read.respondIfOpen(() -> call.resolve(result)); + } catch (ClipboardImageData.InvalidImage error) { + read.respondIfOpen(() -> call.reject(error.getMessage())); + } catch (IOException | RuntimeException error) { + read.respondIfOpen(() -> call.reject("Could not read the pasted image. Please copy it again.")); + } finally { + read.close(); + synchronized (this) { if (activeRead == read) activeRead = null; } + } + } + + @PluginMethod public void discard(PluginCall call) { + release(call.getString("id", "")); + call.resolve(); + } + + @Override protected synchronized void handleOnDestroy() { + destroyed = true; + expiry.removeCallbacksAndMessages(null); + if (activeRead != null) { activeRead.close(); activeRead = null; } + reader.shutdownNow(); + for (InputContentInfoCompat content : pending.values()) releasePermission(content); + pending.clear(); + } +} diff --git a/android/app/src/main/java/md/zennotes/ImagePasteWebView.java b/android/app/src/main/java/md/zennotes/ImagePasteWebView.java new file mode 100644 index 0000000..7c296fe --- /dev/null +++ b/android/app/src/main/java/md/zennotes/ImagePasteWebView.java @@ -0,0 +1,36 @@ +package md.zennotes; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import androidx.core.view.inputmethod.EditorInfoCompat; +import androidx.core.view.inputmethod.InputConnectionCompat; +import androidx.core.view.inputmethod.InputContentInfoCompat; +import com.getcapacitor.CapacitorWebView; + +/** Preserve Capacitor's keyboard handling and add Android's rich-content API. */ +public class ImagePasteWebView extends CapacitorWebView { + interface Receiver { boolean receive(InputContentInfoCompat content, int flags); } + static final String[] IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}; + private boolean imagePasteEnabled; + Receiver imageReceiver; + + public ImagePasteWebView(Context context, AttributeSet attrs) { super(context, attrs); } + + void setImagePasteEnabled(boolean enabled) { + if (imagePasteEnabled == enabled) return; + imagePasteEnabled = enabled; + InputMethodManager ime = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + if (ime != null) ime.restartInput(this); + } + + @Override public InputConnection onCreateInputConnection(EditorInfo info) { + InputConnection connection = super.onCreateInputConnection(info); + if (connection == null || !imagePasteEnabled) return connection; + EditorInfoCompat.setContentMimeTypes(info, IMAGE_TYPES); + return InputConnectionCompat.createWrapper(connection, info, + (content, flags, options) -> imagePasteEnabled && imageReceiver != null && imageReceiver.receive(content, flags)); + } +} diff --git a/android/app/src/main/java/md/zennotes/MainActivity.java b/android/app/src/main/java/md/zennotes/MainActivity.java index ce26fff..1f805e1 100644 --- a/android/app/src/main/java/md/zennotes/MainActivity.java +++ b/android/app/src/main/java/md/zennotes/MainActivity.java @@ -4,11 +4,13 @@ import android.content.pm.PackageInfo; import android.os.Build; import android.os.Bundle; +import android.os.SystemClock; import android.view.WindowManager; import android.webkit.WebView; import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsControllerCompat; +import androidx.core.splashscreen.SplashScreen; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; @@ -24,12 +26,18 @@ public class MainActivity extends BridgeActivity { @Override public void onCreate(Bundle savedInstanceState) { + // Install while the launch theme is still active, before Capacitor + // replaces it. One compat path for both pre-12 and modern Android. + long splashUntil = SystemClock.uptimeMillis() + 400; + SplashScreen splash = SplashScreen.installSplashScreen(this); + splash.setKeepOnScreenCondition(() -> SystemClock.uptimeMillis() < splashUntil); // App-local plugins must be registered before the bridge loads. registerPlugin(ShareInboxPlugin.class); registerPlugin(FolderPickerPlugin.class); registerPlugin(SafFsPlugin.class); registerPlugin(DirectUploadPlugin.class); registerPlugin(WidgetBridgePlugin.class); + registerPlugin(ImagePastePlugin.class); super.onCreate(savedInstanceState); // Cold-start share: the launch intent IS the share. Stash it now; the // WebView drains the inbox after the vault opens (importPendingShares). diff --git a/android/app/src/main/res/drawable/zn_splash_icon.xml b/android/app/src/main/res/drawable/zn_splash_icon.xml new file mode 100644 index 0000000..d7885ad --- /dev/null +++ b/android/app/src/main/res/drawable/zn_splash_icon.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/capacitor_bridge_layout_main.xml b/android/app/src/main/res/layout/capacitor_bridge_layout_main.xml new file mode 100644 index 0000000..78f64c8 --- /dev/null +++ b/android/app/src/main/res/layout/capacitor_bridge_layout_main.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index af9a126..85ec9c8 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -22,8 +22,9 @@