Skip to content

Commit 1b4b65b

Browse files
authored
Merge pull request #63 from ZenNotes/release/1.1.19
ZenNotes for Android 1.1.19 (versionCode 21): lighter sync, better touch
2 parents 6759cb2 + ff8f534 commit 1b4b65b

58 files changed

Lines changed: 2761 additions & 67 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

android/app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ android {
1515
applicationId "md.zennotes"
1616
minSdkVersion rootProject.ext.minSdkVersion
1717
targetSdkVersion rootProject.ext.targetSdkVersion
18-
versionCode 20
19-
versionName "1.1.18"
18+
versionCode 21
19+
versionName "1.1.19"
2020
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
2121
aaptOptions {
2222
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package md.zennotes;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
import java.io.ByteArrayOutputStream;
6+
import java.nio.charset.StandardCharsets;
7+
8+
final class ClipboardImageData {
9+
static final int MAX_BYTES = 10 * 1024 * 1024;
10+
static final class InvalidImage extends IOException {
11+
InvalidImage(String message) { super(message); }
12+
}
13+
static byte[] read(InputStream stream, int limit) throws IOException {
14+
if (stream == null) throw new InvalidImage("Could not read the pasted image.");
15+
ByteArrayOutputStream output = new ByteArrayOutputStream();
16+
byte[] buffer = new byte[16384];
17+
int size;
18+
while ((size = stream.read(buffer)) != -1) {
19+
if (size > limit - output.size()) throw new InvalidImage("Pasted images must be 10 MB or smaller.");
20+
output.write(buffer, 0, size);
21+
}
22+
if (output.size() == 0) throw new InvalidImage("The pasted image is empty.");
23+
return output.toByteArray();
24+
}
25+
static String mimeType(byte[] b) throws IOException {
26+
if (b.length >= 8 && (b[0] & 255) == 137 && b[1] == 80 && b[2] == 78 && b[3] == 71 &&
27+
b[4] == 13 && b[5] == 10 && b[6] == 26 && b[7] == 10) return "image/png";
28+
if (b.length >= 3 && (b[0] & 255) == 255 && (b[1] & 255) == 216 && (b[2] & 255) == 255) return "image/jpeg";
29+
if (b.length >= 6) {
30+
String header = new String(b, 0, 6, StandardCharsets.US_ASCII);
31+
if (header.equals("GIF87a") || header.equals("GIF89a")) return "image/gif";
32+
}
33+
if (b.length >= 12 && new String(b, 0, 4, StandardCharsets.US_ASCII).equals("RIFF") &&
34+
new String(b, 8, 4, StandardCharsets.US_ASCII).equals("WEBP")) return "image/webp";
35+
throw new InvalidImage("Paste a PNG, JPEG, GIF, or WebP image.");
36+
}
37+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package md.zennotes;
2+
3+
import java.io.IOException;
4+
import java.io.InputStream;
5+
6+
/** Own the temporary grant even while a provider has not returned a stream. */
7+
final class ClipboardImageRead implements AutoCloseable {
8+
private final Runnable releasePermission;
9+
private InputStream stream;
10+
private boolean closed;
11+
12+
ClipboardImageRead(Runnable releasePermission) { this.releasePermission = releasePermission; }
13+
14+
InputStream attach(InputStream opened) throws IOException {
15+
synchronized (this) {
16+
if (!closed) { stream = opened; return opened; }
17+
}
18+
closeStream(opened);
19+
throw new IOException("Image paste was cancelled.");
20+
}
21+
22+
synchronized boolean isClosed() { return closed; }
23+
24+
synchronized void respondIfOpen(Runnable response) {
25+
if (!closed) response.run();
26+
}
27+
28+
@Override public void close() {
29+
InputStream opened;
30+
synchronized (this) {
31+
if (closed) return;
32+
closed = true;
33+
opened = stream;
34+
stream = null;
35+
}
36+
// Release the grant without waiting for provider I/O to finish.
37+
try { releasePermission.run(); }
38+
finally { closeStream(opened); }
39+
}
40+
41+
private static void closeStream(InputStream stream) {
42+
if (stream == null) return;
43+
try { stream.close(); } catch (IOException | RuntimeException ignored) { /* already closed/revoked */ }
44+
}
45+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package md.zennotes;
2+
3+
import android.content.res.AssetFileDescriptor;
4+
import android.os.CancellationSignal;
5+
import android.os.Handler;
6+
import android.os.Looper;
7+
import android.util.Base64;
8+
import androidx.core.view.inputmethod.InputConnectionCompat;
9+
import androidx.core.view.inputmethod.InputContentInfoCompat;
10+
import com.getcapacitor.JSObject;
11+
import com.getcapacitor.Plugin;
12+
import com.getcapacitor.PluginCall;
13+
import com.getcapacitor.PluginMethod;
14+
import com.getcapacitor.annotation.CapacitorPlugin;
15+
import java.io.InputStream;
16+
import java.io.IOException;
17+
import java.util.HashMap;
18+
import java.util.Map;
19+
import java.util.UUID;
20+
import java.util.concurrent.ExecutorService;
21+
import java.util.concurrent.Executors;
22+
import java.util.concurrent.RejectedExecutionException;
23+
24+
@CapacitorPlugin(name = "ImagePaste")
25+
public class ImagePastePlugin extends Plugin {
26+
private final Map<String, InputContentInfoCompat> pending = new HashMap<>();
27+
private final Handler expiry = new Handler(Looper.getMainLooper());
28+
private final ExecutorService reader = Executors.newSingleThreadExecutor();
29+
private ClipboardImageRead activeRead;
30+
private volatile boolean destroyed;
31+
32+
@Override public void load() {
33+
((ImagePasteWebView) getBridge().getWebView()).imageReceiver = this::receive;
34+
}
35+
36+
@PluginMethod public void setEnabled(PluginCall call) {
37+
boolean enabled = call.getBoolean("enabled", false);
38+
getActivity().runOnUiThread(() -> {
39+
if (destroyed) { call.reject("Image pasting is unavailable."); return; }
40+
((ImagePasteWebView) getBridge().getWebView()).setImagePasteEnabled(enabled);
41+
call.resolve();
42+
});
43+
}
44+
45+
private synchronized boolean receive(InputContentInfoCompat content, int flags) {
46+
if (destroyed || !hasListeners("image") || activeRead != null || !pending.isEmpty() || !"content".equals(content.getContentUri().getScheme())) return false;
47+
boolean supported = false;
48+
for (String type : ImagePasteWebView.IMAGE_TYPES) supported |= content.getDescription().hasMimeType(type);
49+
if (!supported) return false;
50+
try {
51+
if ((flags & InputConnectionCompat.INPUT_CONTENT_GRANT_READ_URI_PERMISSION) != 0) content.requestPermission();
52+
} catch (RuntimeException error) { return false; }
53+
String id = UUID.randomUUID().toString();
54+
pending.put(id, content);
55+
JSObject event = new JSObject();
56+
event.put("id", id);
57+
notifyListeners("image", event);
58+
expiry.postDelayed(() -> release(id), 30000);
59+
return true;
60+
}
61+
62+
private synchronized InputContentInfoCompat take(String id) { return pending.remove(id); }
63+
private void release(String id) {
64+
InputContentInfoCompat content = take(id);
65+
if (content != null) releasePermission(content);
66+
}
67+
private static void releasePermission(InputContentInfoCompat content) {
68+
try { content.releasePermission(); } catch (RuntimeException ignored) { /* provider already revoked it */ }
69+
}
70+
71+
// Plugin methods run off the UI thread. Never send arbitrary URIs through
72+
// the JS bridge: read only the short-lived token from a user paste event.
73+
@PluginMethod public synchronized void read(PluginCall call) {
74+
if (destroyed) { call.reject("Image pasting is unavailable."); return; }
75+
InputContentInfoCompat content = take(call.getString("id", ""));
76+
if (content == null) { call.reject("The pasted image expired. Please paste it again."); return; }
77+
CancellationSignal cancellation = new CancellationSignal();
78+
ClipboardImageRead read = new ClipboardImageRead(() -> {
79+
releasePermission(content);
80+
try { cancellation.cancel(); } catch (RuntimeException ignored) { /* provider already gone */ }
81+
});
82+
activeRead = read;
83+
try { reader.execute(() -> readImage(call, content, read, cancellation)); }
84+
catch (RejectedExecutionException error) {
85+
activeRead = null;
86+
read.close();
87+
call.reject("Image pasting is unavailable. Please reopen the note.");
88+
}
89+
}
90+
private void readImage(PluginCall call, InputContentInfoCompat content, ClipboardImageRead read, CancellationSignal cancellation) {
91+
try {
92+
if (read.isClosed()) return;
93+
byte[] bytes;
94+
try (AssetFileDescriptor descriptor = getContext().getContentResolver()
95+
.openAssetFileDescriptor(content.getContentUri(), "r", cancellation)) {
96+
InputStream stream = read.attach(descriptor == null ? null : descriptor.createInputStream());
97+
bytes = ClipboardImageData.read(stream, ClipboardImageData.MAX_BYTES);
98+
}
99+
JSObject result = new JSObject();
100+
result.put("mimeType", ClipboardImageData.mimeType(bytes));
101+
result.put("base64", Base64.encodeToString(bytes, Base64.NO_WRAP));
102+
read.respondIfOpen(() -> call.resolve(result));
103+
} catch (ClipboardImageData.InvalidImage error) {
104+
read.respondIfOpen(() -> call.reject(error.getMessage()));
105+
} catch (IOException | RuntimeException error) {
106+
read.respondIfOpen(() -> call.reject("Could not read the pasted image. Please copy it again."));
107+
} finally {
108+
read.close();
109+
synchronized (this) { if (activeRead == read) activeRead = null; }
110+
}
111+
}
112+
113+
@PluginMethod public void discard(PluginCall call) {
114+
release(call.getString("id", ""));
115+
call.resolve();
116+
}
117+
118+
@Override protected synchronized void handleOnDestroy() {
119+
destroyed = true;
120+
expiry.removeCallbacksAndMessages(null);
121+
if (activeRead != null) { activeRead.close(); activeRead = null; }
122+
reader.shutdownNow();
123+
for (InputContentInfoCompat content : pending.values()) releasePermission(content);
124+
pending.clear();
125+
}
126+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package md.zennotes;
2+
3+
import android.content.Context;
4+
import android.util.AttributeSet;
5+
import android.view.inputmethod.EditorInfo;
6+
import android.view.inputmethod.InputConnection;
7+
import android.view.inputmethod.InputMethodManager;
8+
import androidx.core.view.inputmethod.EditorInfoCompat;
9+
import androidx.core.view.inputmethod.InputConnectionCompat;
10+
import androidx.core.view.inputmethod.InputContentInfoCompat;
11+
import com.getcapacitor.CapacitorWebView;
12+
13+
/** Preserve Capacitor's keyboard handling and add Android's rich-content API. */
14+
public class ImagePasteWebView extends CapacitorWebView {
15+
interface Receiver { boolean receive(InputContentInfoCompat content, int flags); }
16+
static final String[] IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"};
17+
private boolean imagePasteEnabled;
18+
Receiver imageReceiver;
19+
20+
public ImagePasteWebView(Context context, AttributeSet attrs) { super(context, attrs); }
21+
22+
void setImagePasteEnabled(boolean enabled) {
23+
if (imagePasteEnabled == enabled) return;
24+
imagePasteEnabled = enabled;
25+
InputMethodManager ime = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
26+
if (ime != null) ime.restartInput(this);
27+
}
28+
29+
@Override public InputConnection onCreateInputConnection(EditorInfo info) {
30+
InputConnection connection = super.onCreateInputConnection(info);
31+
if (connection == null || !imagePasteEnabled) return connection;
32+
EditorInfoCompat.setContentMimeTypes(info, IMAGE_TYPES);
33+
return InputConnectionCompat.createWrapper(connection, info,
34+
(content, flags, options) -> imagePasteEnabled && imageReceiver != null && imageReceiver.receive(content, flags));
35+
}
36+
}

android/app/src/main/java/md/zennotes/MainActivity.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
import android.content.pm.PackageInfo;
55
import android.os.Build;
66
import android.os.Bundle;
7+
import android.os.SystemClock;
78
import android.view.WindowManager;
89
import android.webkit.WebView;
910

1011
import androidx.core.view.WindowCompat;
1112
import androidx.core.view.WindowInsetsControllerCompat;
13+
import androidx.core.splashscreen.SplashScreen;
1214

1315
import android.webkit.WebResourceRequest;
1416
import android.webkit.WebResourceResponse;
@@ -24,12 +26,18 @@ public class MainActivity extends BridgeActivity {
2426

2527
@Override
2628
public void onCreate(Bundle savedInstanceState) {
29+
// Install while the launch theme is still active, before Capacitor
30+
// replaces it. One compat path for both pre-12 and modern Android.
31+
long splashUntil = SystemClock.uptimeMillis() + 400;
32+
SplashScreen splash = SplashScreen.installSplashScreen(this);
33+
splash.setKeepOnScreenCondition(() -> SystemClock.uptimeMillis() < splashUntil);
2734
// App-local plugins must be registered before the bridge loads.
2835
registerPlugin(ShareInboxPlugin.class);
2936
registerPlugin(FolderPickerPlugin.class);
3037
registerPlugin(SafFsPlugin.class);
3138
registerPlugin(DirectUploadPlugin.class);
3239
registerPlugin(WidgetBridgePlugin.class);
40+
registerPlugin(ImagePastePlugin.class);
3341
super.onCreate(savedInstanceState);
3442
// Cold-start share: the launch intent IS the share. Stash it now; the
3543
// WebView drains the inbox after the vault opens (importPendingShares).
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- A non-adaptive, square logo in the 288dp splash canvas. The complete
3+
rounded square fits inside Android's 192dp circular safe area, so the
4+
system mask cannot clip its corners or distort the ring. -->
5+
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
6+
<item>
7+
<shape android:shape="rectangle">
8+
<size android:width="288dp" android:height="288dp" />
9+
<solid android:color="@android:color/transparent" />
10+
</shape>
11+
</item>
12+
<item android:width="128dp" android:height="128dp" android:gravity="center">
13+
<bitmap android:src="@mipmap/ic_launcher_foreground" android:gravity="fill" />
14+
</item>
15+
</layer-list>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!-- Same Capacitor layout/IDs; subclass only the keyboard input connection. -->
3+
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
4+
android:layout_width="match_parent" android:layout_height="match_parent">
5+
<md.zennotes.ImagePasteWebView android:id="@+id/webview"
6+
android:layout_width="match_parent" android:layout_height="match_parent" />
7+
</androidx.coordinatorlayout.widget.CoordinatorLayout>

android/app/src/main/res/values/styles.xml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
</style>
2323

2424
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
25-
<item name="android:background">@drawable/splash</item>
26-
<item name="android:windowBackground">@color/znBackground</item>
25+
<item name="windowSplashScreenBackground">@color/znBackground</item>
26+
<item name="windowSplashScreenAnimatedIcon">@drawable/zn_splash_icon</item>
27+
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
2728
<item name="android:statusBarColor">@color/znBackground</item>
2829
<item name="android:navigationBarColor">@color/znBackground</item>
2930
<item name="android:windowLightStatusBar">false</item>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package md.zennotes;
2+
3+
import org.junit.Test;
4+
import static org.junit.Assert.*;
5+
import java.io.ByteArrayInputStream;
6+
import java.io.IOException;
7+
8+
public class ClipboardImageDataTest {
9+
@Test public void boundedReadPreservesBytes() throws Exception {
10+
byte[] data = {1, 2, 3, 4};
11+
assertArrayEquals(data, ClipboardImageData.read(new ByteArrayInputStream(data), 4));
12+
}
13+
@Test public void overLimitAndEmptyInputFail() {
14+
assertThrows(IOException.class, () -> ClipboardImageData.read(new ByteArrayInputStream(new byte[5]), 4));
15+
assertThrows(IOException.class, () -> ClipboardImageData.read(new ByteArrayInputStream(new byte[0]), 4));
16+
}
17+
@Test public void checksImageSignaturesNotUntrustedMimeOrFilename() throws Exception {
18+
assertEquals("image/png", ClipboardImageData.mimeType(new byte[]{(byte)137,80,78,71,13,10,26,10}));
19+
assertEquals("image/jpeg", ClipboardImageData.mimeType(new byte[]{(byte)255,(byte)216,(byte)255,0}));
20+
assertEquals("image/gif", ClipboardImageData.mimeType("GIF89a".getBytes()));
21+
assertEquals("image/webp", ClipboardImageData.mimeType("RIFF0000WEBP".getBytes()));
22+
assertThrows(IOException.class, () -> ClipboardImageData.mimeType("<svg onload='alert(1)'/>".getBytes()));
23+
assertThrows(IOException.class, () -> ClipboardImageData.mimeType(new byte[0]));
24+
}
25+
}

0 commit comments

Comments
 (0)