Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions android/app/src/main/java/md/zennotes/ClipboardImageData.java
Original file line number Diff line number Diff line change
@@ -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.");
}
}
45 changes: 45 additions & 0 deletions android/app/src/main/java/md/zennotes/ClipboardImageRead.java
Original file line number Diff line number Diff line change
@@ -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 */ }
}
}
126 changes: 126 additions & 0 deletions android/app/src/main/java/md/zennotes/ImagePastePlugin.java
Original file line number Diff line number Diff line change
@@ -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<String, InputContentInfoCompat> 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();
}
}
36 changes: 36 additions & 0 deletions android/app/src/main/java/md/zennotes/ImagePasteWebView.java
Original file line number Diff line number Diff line change
@@ -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));
}
}
8 changes: 8 additions & 0 deletions android/app/src/main/java/md/zennotes/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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).
Expand Down
15 changes: 15 additions & 0 deletions android/app/src/main/res/drawable/zn_splash_icon.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A non-adaptive, square logo in the 288dp splash canvas. The complete
rounded square fits inside Android's 192dp circular safe area, so the
system mask cannot clip its corners or distort the ring. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<size android:width="288dp" android:height="288dp" />
<solid android:color="@android:color/transparent" />
</shape>
</item>
<item android:width="128dp" android:height="128dp" android:gravity="center">
<bitmap android:src="@mipmap/ic_launcher_foreground" android:gravity="fill" />
</item>
</layer-list>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Same Capacitor layout/IDs; subclass only the keyboard input connection. -->
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent" android:layout_height="match_parent">
<md.zennotes.ImagePasteWebView android:id="@+id/webview"
android:layout_width="match_parent" android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
5 changes: 3 additions & 2 deletions android/app/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@
</style>

<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
<item name="android:background">@drawable/splash</item>
<item name="android:windowBackground">@color/znBackground</item>
<item name="windowSplashScreenBackground">@color/znBackground</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/zn_splash_icon</item>
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
<item name="android:statusBarColor">@color/znBackground</item>
<item name="android:navigationBarColor">@color/znBackground</item>
<item name="android:windowLightStatusBar">false</item>
Expand Down
25 changes: 25 additions & 0 deletions android/app/src/test/java/md/zennotes/ClipboardImageDataTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package md.zennotes;

import org.junit.Test;
import static org.junit.Assert.*;
import java.io.ByteArrayInputStream;
import java.io.IOException;

public class ClipboardImageDataTest {
@Test public void boundedReadPreservesBytes() throws Exception {
byte[] data = {1, 2, 3, 4};
assertArrayEquals(data, ClipboardImageData.read(new ByteArrayInputStream(data), 4));
}
@Test public void overLimitAndEmptyInputFail() {
assertThrows(IOException.class, () -> ClipboardImageData.read(new ByteArrayInputStream(new byte[5]), 4));
assertThrows(IOException.class, () -> ClipboardImageData.read(new ByteArrayInputStream(new byte[0]), 4));
}
@Test public void checksImageSignaturesNotUntrustedMimeOrFilename() throws Exception {
assertEquals("image/png", ClipboardImageData.mimeType(new byte[]{(byte)137,80,78,71,13,10,26,10}));
assertEquals("image/jpeg", ClipboardImageData.mimeType(new byte[]{(byte)255,(byte)216,(byte)255,0}));
assertEquals("image/gif", ClipboardImageData.mimeType("GIF89a".getBytes()));
assertEquals("image/webp", ClipboardImageData.mimeType("RIFF0000WEBP".getBytes()));
assertThrows(IOException.class, () -> ClipboardImageData.mimeType("<svg onload='alert(1)'/>".getBytes()));
assertThrows(IOException.class, () -> ClipboardImageData.mimeType(new byte[0]));
}
}
Loading