Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
38f1ee1
Proof of concept that can call startStylusHandwriting
justinmc May 20, 2024
f5c45e2
Fix the notification 'this app does not support handwriting input'
justinmc May 21, 2024
2c70875
Correctly report success/error
justinmc May 21, 2024
2da6b2e
Show icon on hover
justinmc May 21, 2024
815e6cd
Trying the other gestures, but not getting called...
justinmc May 21, 2024
da21044
Use isStylusHandwritingAvailable, as recommended in the doc
justinmc May 22, 2024
cdf7366
TODO for upgraded androidx
justinmc May 23, 2024
a298ad0
Merge branch 'main' into scribe
justinmc May 28, 2024
39249a5
AndroidX has been upgraded, though the PR seems to be getting reverte…
justinmc May 28, 2024
14ff786
Overriding preview as well, and checking if we ever get a call (no)
justinmc May 28, 2024
8217442
Declare that we support all the handwriting gestures!
justinmc May 28, 2024
d5e0301
Well that's why that wasn't getting called
justinmc May 29, 2024
39a4c8e
Version gate
justinmc May 29, 2024
27ca8db
Send selection gesture to framework
justinmc May 29, 2024
521fb22
TODO for granularity
justinmc May 30, 2024
2a27c55
Merge branch 'main' into scribe
justinmc Sep 30, 2024
5046264
Auto formatting
justinmc Sep 30, 2024
f9856a2
Pointer functionality is being moved to a separate PR
justinmc Sep 30, 2024
989eb2a
WIP Generic handwriting gesture call
justinmc Oct 1, 2024
47bd6b7
WIP previewHandwritingGesture
justinmc Oct 1, 2024
948d387
Scribe gestures will be handled in a separate PR
justinmc Oct 1, 2024
c2f1425
isStylusHandwritingAvailable method
justinmc Oct 1, 2024
5d385c5
Don't need to call isStylusHandwritingAvailable, that's on the caller
justinmc Oct 1, 2024
0a54134
Remove support for gestures, now shows dialog when gesture performed
justinmc Oct 1, 2024
aa95c06
Let's keep it a separate channel for now and check in review
justinmc Oct 1, 2024
0962507
ScribePluginTest
justinmc Oct 1, 2024
885f4bb
Get tests working, and add basic tests for ScribePlugin
justinmc Oct 1, 2024
8ef8f49
License check fix
justinmc Oct 1, 2024
49fa99b
Test for ScribePlugin
justinmc Oct 2, 2024
585ac33
Enforce api levels
justinmc Oct 2, 2024
250adbc
Separate Plugin and Channel tests
justinmc Oct 2, 2024
e96d8bc
Only start scribe when api available
justinmc Oct 2, 2024
8056d17
Test for TextInputPlugin change
justinmc Oct 2, 2024
016223e
Add missing api_level guards
justinmc Oct 3, 2024
cc69690
Move method channel handlers to their own private methods
justinmc Oct 3, 2024
6f1a6d8
Misc Reid's review comments
justinmc Oct 3, 2024
1ae66f1
Test unsupported api levels in scribeplugintest. Allow it to be creat…
justinmc Oct 4, 2024
f019841
Some work on testing api versions in ScribeChannel
justinmc Oct 4, 2024
d802eb2
Works without deprecated method
justinmc Oct 4, 2024
3685c9d
Test ScribeChannel when old api level
justinmc Oct 4, 2024
0ceab93
Make view public so it could be updated if the view changes
justinmc Oct 4, 2024
bc4dafd
TestInputPlugin unsupported test
justinmc Oct 4, 2024
00621e2
Private but settable view
justinmc Oct 7, 2024
53212fc
Use jsonmethodcodec
justinmc Oct 7, 2024
02c8f43
Test reply value now that we're using jsonmethodcodec
justinmc Oct 7, 2024
6d86717
Fix test missing mocked scribechannel
justinmc Oct 7, 2024
44c22d5
Fix platformviewscontrollertest due to missing scribechannel again.
justinmc Oct 7, 2024
06edcdb
Fix mixed up api version in test
justinmc Oct 7, 2024
df68cb8
isFeatureAvailable convenience method
justinmc Oct 18, 2024
37cab6c
Merge branch 'main' into scribe
justinmc Oct 21, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -1140,11 +1140,9 @@ public void attachToFlutterEngine(@NonNull FlutterEngine flutterEngine) {
Log.e(TAG, "TextServicesManager not supported by device, spell check disabled.");
}

if (Build.VERSION.SDK_INT >= API_LEVELS.API_34) {
scribePlugin =
new ScribePlugin(
this, textInputPlugin.getInputMethodManager(), this.flutterEngine.getScribeChannel());
}
scribePlugin =
new ScribePlugin(
this, textInputPlugin.getInputMethodManager(), this.flutterEngine.getScribeChannel());

localizationPlugin = this.flutterEngine.getLocalizationPlugin();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import static io.flutter.Build.API_LEVELS;

import android.annotation.TargetApi;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
Expand Down Expand Up @@ -47,10 +48,10 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result
Object args = call.arguments;
Log.v(TAG, "Received '" + method + "' message.");
switch (method) {
case "Scribe.isStylusHandwritingAvailable":
case METHOD_IS_STYLUS_HANDWRITING_AVAILABLE:
isStylusHandwritingAvailable(call, result);
break;
case "Scribe.startStylusHandwriting":
case METHOD_START_STYLUS_HANDWRITING:
startStylusHandwriting(call, result);
break;
default:
Expand All @@ -62,6 +63,11 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull MethodChannel.Result

private void isStylusHandwritingAvailable(
@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if (Build.VERSION.SDK_INT < API_LEVELS.API_34) {
result.error("error", "Requires API level 34 or higher.", null);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this mean that the dart side of the plugin needs to check if it is running on android and check the api level?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess in practice this probably means that you want to call this wrapped in a try/catch in Dart. My thought process is that this method channel method is just a proxy for InputMethodManager.isStylusHandwritingAvailable. If I'm not even able to call that, I should error, rather than succeed with false, which the app developer might interpret to mean that InputMethodManager.isStylusHandwritingAvailable returned false.

If that sounds reasonable then I'll at least make sure this is clearly documented in the framework.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update here: #52943 (comment)

return;
}

try {
final boolean isAvailable = scribeMethodHandler.isStylusHandwritingAvailable();
result.success(isAvailable);
Expand All @@ -72,6 +78,11 @@ private void isStylusHandwritingAvailable(

private void startStylusHandwriting(
@NonNull MethodCall call, @NonNull MethodChannel.Result result) {
if (Build.VERSION.SDK_INT < API_LEVELS.API_33) {
result.error("error", "Requires API level 33 or higher.", null);
return;
}

try {
scribeMethodHandler.startStylusHandwriting();
result.success(null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import static io.flutter.Build.API_LEVELS;

import android.annotation.TargetApi;
import android.os.Build;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import androidx.annotation.NonNull;
Expand All @@ -19,18 +20,20 @@
*
* <p>The plugin handles requests for scribe sent by the {@link
* io.flutter.embedding.engine.systemchannels.ScribeChannel}.
*
* <p>On API versions below 33, the plugin does nothing.
*/
public class ScribePlugin implements ScribeChannel.ScribeMethodHandler {

private final ScribeChannel mScribeChannel;
private final InputMethodManager mInputMethodManager;
@NonNull private final View mView;
@NonNull private final ScribeChannel mScribeChannel;
@NonNull private final InputMethodManager mInputMethodManager;
@NonNull public View mView;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you want this to be private or at least public and @VisibleForTesting not public.
Then add a setter for the view. My gut is that over time if the view changes then you want to be informed and be able to setup new callbacks or other events.


@TargetApi(API_LEVELS.API_34)
@RequiresApi(API_LEVELS.API_34)
public ScribePlugin(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the view that is passed in here. Views in andoid IIRC do not have a long lifespan and this view is final. What happens when the app is backgrounded?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the FlutterView from when ScribePlugin is created in FlutterView.java:

      scribePlugin =
          new ScribePlugin(
              this, textInputPlugin.getInputMethodManager(), this.flutterEngine.getScribeChannel());

I need it in order to call InputMethodManager.startStylusHandwriting. Is there a better way to get ahold of the View at that time that's called maybe?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not so confident as to block the pr but I suspect that you can't make that variable final. That there is not a 1 to 1 relationship between flutter view and plugin instantiation.

I am specifically thinking of add to app and cached engine scenarios.
https://docs.flutter.dev/add-to-app/android/add-flutter-screen#step-3-optional-use-a-cached-flutterengine

None of our existing tests would catch this because they would not execute this code path.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(From another thread) I've made a view setter so it can be updated if needed.

@NonNull View view, @NonNull InputMethodManager imm, @NonNull ScribeChannel scribeChannel) {
view.setAutoHandwritingEnabled(false);
if (Build.VERSION.SDK_INT >= API_LEVELS.API_33) {
view.setAutoHandwritingEnabled(false);
}

mView = view;
mInputMethodManager = imm;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@

package io.flutter.embedding.engine.systemchannels;

import static io.flutter.Build.API_LEVELS;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import android.annotation.TargetApi;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import io.flutter.embedding.engine.dart.DartExecutor;
import io.flutter.plugin.common.BinaryMessenger;
Expand All @@ -19,22 +22,22 @@
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.robolectric.annotation.Config;

@RunWith(AndroidJUnit4.class)
public class ScribeChannelTest {
private static void sendToBinaryMessageHandler(
private static BinaryMessenger.BinaryReply sendToBinaryMessageHandler(
BinaryMessenger.BinaryMessageHandler binaryMessageHandler, String method) {
MethodCall methodCall = new MethodCall(method, null);
ByteBuffer encodedMethodCall = StandardMethodCodec.INSTANCE.encodeMethodCall(methodCall);
binaryMessageHandler.onMessage(
(ByteBuffer) encodedMethodCall.flip(), mock(BinaryMessenger.BinaryReply.class));
BinaryMessenger.BinaryReply mockReply = mock(BinaryMessenger.BinaryReply.class);
binaryMessageHandler.onMessage((ByteBuffer) encodedMethodCall.flip(), mockReply);
return mockReply;
}

ScribeChannel.ScribeMethodHandler mockHandler;
BinaryMessenger.BinaryMessageHandler binaryMessageHandler;

@SuppressWarnings("deprecation")
// setMessageHandler is deprecated.
@Before
public void setUp() {
ArgumentCaptor<BinaryMessenger.BinaryMessageHandler> binaryMessageHandlerCaptor =
Expand All @@ -45,24 +48,57 @@ public void setUp() {

scribeChannel.setScribeMethodHandler(mockHandler);

verify(mockBinaryMessenger, times(1))
verify((BinaryMessenger) mockBinaryMessenger, times(1))
.setMessageHandler(any(String.class), binaryMessageHandlerCaptor.capture());

binaryMessageHandler = binaryMessageHandlerCaptor.getValue();
}

@Config(minSdk = API_LEVELS.API_34)
@TargetApi(API_LEVELS.API_34)
@Test
public void respondsToStartStylusHandwriting() {
sendToBinaryMessageHandler(binaryMessageHandler, ScribeChannel.METHOD_START_STYLUS_HANDWRITING);
BinaryMessenger.BinaryReply mockReply =
sendToBinaryMessageHandler(
binaryMessageHandler, ScribeChannel.METHOD_START_STYLUS_HANDWRITING);

verify(mockReply).reply(any(ByteBuffer.class));
verify(mockHandler).startStylusHandwriting();
}

@Config(minSdk = API_LEVELS.API_34)
@TargetApi(API_LEVELS.API_34)
@Test
public void respondsToIsStylusHandwritingAvailable() {
sendToBinaryMessageHandler(
binaryMessageHandler, ScribeChannel.METHOD_IS_STYLUS_HANDWRITING_AVAILABLE);
BinaryMessenger.BinaryReply mockReply =
sendToBinaryMessageHandler(
binaryMessageHandler, ScribeChannel.METHOD_IS_STYLUS_HANDWRITING_AVAILABLE);

verify(mockReply).reply(any(ByteBuffer.class));
verify(mockHandler).isStylusHandwritingAvailable();
}

@Config(sdk = API_LEVELS.API_32)
@TargetApi(API_LEVELS.API_32)
@Test
public void respondsToStartStylusHandwritingWhenAPILevelUnsupported() {
BinaryMessenger.BinaryReply mockReply =
sendToBinaryMessageHandler(
binaryMessageHandler, ScribeChannel.METHOD_START_STYLUS_HANDWRITING);

verify(mockReply).reply(any(ByteBuffer.class));
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have liked to test the exact argument that reply was called with here, but I was struggling to do anything useful with the ByteBuffer. I was trying StandardMethodCodec.INSTANCE.decodeEnvelope because it looks like that's what it's encoded with (e.g. here), but I always got an underflow error.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of trying to reverse engineer the reply bytebuffer what if you used a spy on the result callback and verified that either callback.error or result.error was called.

I do not love that the error condition verifies the same thing as the success condition but at least we verify we do not call the methods we are trying to protect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed this to JSONMethodCodec which I believe is what we're supposed to be using for these kinds of method channels now, and incidentally I was able to decode and test the value of result then.

verify(mockHandler, never()).startStylusHandwriting();
}

@Config(sdk = API_LEVELS.API_33)
@TargetApi(API_LEVELS.API_33)
@Test
public void respondsToIsStylusHandwritingAvailableWhenAPILevelUnsupported() {
BinaryMessenger.BinaryReply mockReply =
sendToBinaryMessageHandler(
binaryMessageHandler, ScribeChannel.METHOD_IS_STYLUS_HANDWRITING_AVAILABLE);

verify(mockReply).reply(any(ByteBuffer.class));
verify(mockHandler, never()).isStylusHandwritingAvailable();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@

import static io.flutter.Build.API_LEVELS;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.annotation.TargetApi;
import android.content.Context;
import android.os.Build;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import androidx.test.core.app.ApplicationProvider;
Expand All @@ -35,7 +38,9 @@ public void setUp() {
ScribeChannel mockScribeChannel = mock(ScribeChannel.class);
testView = new View(ctx);
mockImm = mock(InputMethodManager.class);
when(mockImm.isStylusHandwritingAvailable()).thenReturn(true);
if (Build.VERSION.SDK_INT >= API_LEVELS.API_34) {
when(mockImm.isStylusHandwritingAvailable()).thenReturn(true);
}
scribePlugin = new ScribePlugin(testView, mockImm, mockScribeChannel);
}

Expand All @@ -56,4 +61,30 @@ public void scribePluginStartStylusHandwriting() {

verify(mockImm).startStylusHandwriting(testView);
}

@Config(sdk = API_LEVELS.API_33)
@TargetApi(API_LEVELS.API_33)
@Test
public void scribePluginStartStylusHandwritingWhenAPILevelUnsupported() {
assertNotNull(scribePlugin);

assertThrows(
NoSuchMethodError.class,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking: This feels like an odd error to expect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just confirming that it fails when the API level is too low. I could remove these tests if that's not necessary?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should keep the test it just seems to me that the error would be something like "feature not available" or "api too low" or something. "No such method". That type of error feels like a programing mistake not an error that tells you what is wrong.

() -> {
scribePlugin.startStylusHandwriting();
});
}

@Config(sdk = API_LEVELS.API_32)
@TargetApi(API_LEVELS.API_32)
@Test
public void scribePluginIsStylusHandwritingAvailableWhenAPILevelUnsupported() {
assertNotNull(scribePlugin);

assertThrows(
NoSuchMethodError.class,
() -> {
scribePlugin.isStylusHandwritingAvailable();
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static io.flutter.Build.API_LEVELS;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertThrows;
import static org.junit.Assert.assertTrue;
Expand Down Expand Up @@ -1427,6 +1428,40 @@ public void inputConnection_setsStylusHandwritingAvailable() {
assertTrue(EditorInfoCompat.isStylusHandwritingEnabled(editorInfo));
}

@Config(sdk = API_LEVELS.API_32)
@TargetApi(API_LEVELS.API_32)
@Test
public void inputConnection_doesNotcallSetsStylusHandwritingAvailableWhenAPILevelUnsupported() {
View testView = new View(ctx);
DartExecutor dartExecutor = mock(DartExecutor.class);
TextInputChannel textInputChannel = new TextInputChannel(dartExecutor);
ScribeChannel scribeChannel = new ScribeChannel(mock(DartExecutor.class));
TextInputPlugin textInputPlugin =
new TextInputPlugin(
testView, textInputChannel, scribeChannel, mock(PlatformViewsController.class));
textInputPlugin.setTextInputClient(
0,
new TextInputChannel.Configuration(
false,
false,
true,
true,
false,
TextInputChannel.TextCapitalization.NONE,
new TextInputChannel.InputType(TextInputChannel.TextInputType.MULTILINE, false, false),
null,
null,
null,
null,
null));

EditorInfo editorInfo = new EditorInfo();
InputConnection connection =
textInputPlugin.createInputConnection(testView, mock(KeyboardManager.class), editorInfo);

assertFalse(EditorInfoCompat.isStylusHandwritingEnabled(editorInfo));
}

// -------- Start: Autofill Tests -------
@Test
public void autofill_enabledByDefault() {
Expand Down