Skip to content
This repository was archived by the owner on Feb 22, 2023. It is now read-only.

[quick_actions] Android handle quick action without restart #5048

Merged
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f4d657f
[quick_actions] Support keeping state when App Shortcut is triggered …
TabooSun Mar 12, 2022
42ca2da
Remove redundant comments in QuickActionsTest
TabooSun Apr 29, 2022
d709a8e
Merge branch 'master' into android-handle-quick-action-without-restart
TabooSun Apr 29, 2022
d00c370
Fix incorrect test method name in QuickActionsTest
TabooSun Apr 29, 2022
b61dc9c
Revert removal of xml metadata
TabooSun Apr 29, 2022
661bc5d
Refactor to use ShortcutInfo and remove custom Shortcut class
TabooSun Apr 30, 2022
28a82b3
Format code
TabooSun Apr 30, 2022
8c754e3
Correct license file formatting
TabooSun May 4, 2022
84372e8
Set version in pubspec.yaml correctly
TabooSun May 4, 2022
4c83cd4
Fix import style that violates Google Java Style Guide
TabooSun May 4, 2022
ad7b4a9
Fix calling Java MethodChannel from Java side
TabooSun May 4, 2022
24bdc10
Bump version to 0.6.1
TabooSun May 4, 2022
4784fae
Update CHANGELOG
TabooSun May 4, 2022
b2cff95
Fix test failure in QuickActionsTest
TabooSun May 4, 2022
a962152
Fix CHANGELOG style
stuartmorgan Jun 7, 2022
a623044
Merge branch 'main' into android-handle-quick-action-without-restart
stuartmorgan Jun 7, 2022
f06f961
Use shortcut id to locate actual shortcut
TabooSun Jun 8, 2022
36bc6ea
Merge branch 'main' into android-handle-quick-action-without-restart
stuartmorgan Jun 9, 2022
a3437e1
Wait for all the shortcut creations before running test
TabooSun Jun 12, 2022
ac3dd42
Merge branch 'main' into android-handle-quick-action-without-restart
stuartmorgan Jun 13, 2022
c359441
Change the integration_test to run the app
TabooSun Jun 17, 2022
d03b75c
Merge branch 'main' into android-handle-quick-action-without-restart
stuartmorgan Jun 21, 2022
a1a4546
Await the description in home page before asserting
TabooSun Jun 22, 2022
87db162
Merge remote-tracking branch 'origin/android-handle-quick-action-with…
TabooSun Jun 22, 2022
116386b
Fix flakey test
TabooSun Jul 6, 2022
791fcfb
Merge branch 'main' into android-handle-quick-action-without-restart
TabooSun Jul 6, 2022
de02c2b
Merge branch 'main' into android-handle-quick-action-without-restart
stuartmorgan Jul 6, 2022
33bcb24
Fix license comment
TabooSun Jul 8, 2022
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: 4 additions & 0 deletions packages/quick_actions/quick_actions/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

* Updates minimum Flutter version to 2.8.

## 0.7.0
Copy link
Contributor

Choose a reason for hiding this comment

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

@stuartmorgan Should this include the NEXT item since this is a version bump?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, but the larger problem is that this version bump is to the wrong package. The changes are to quick_actions_android, not quick_actions, so that's what needs CHANGELOG and version updates.

@TabooSun Please make sure the publishable CI check is passing when you update your PR; the failure messages there explain the problem.


* Allow Android to trigger quick actions without restarting the app.

## 0.6.0+10

* Moves Android and iOS implementations to federated packages.
Expand Down
2 changes: 1 addition & 1 deletion packages/quick_actions/quick_actions/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ description: Flutter plugin for creating shortcuts on home screen, also known as
Quick Actions on iOS and App Shortcuts on Android.
repository: https://github.com/flutter/plugins/tree/main/packages/quick_actions/quick_actions
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+quick_actions%22
version: 0.6.0+10
version: 0.7.0
Copy link
Contributor

Choose a reason for hiding this comment

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

The way Dart versioning works for pre-1.0, this is a breaking version change (see link in the PR checklist about Dart versions). Is there anything about this change that actually breaks clients?


environment:
sdk: ">=2.14.0 <3.0.0"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="io.flutter.plugins.quickactions">
<manifest
xmlns:tools="http://schemas.android.com/tools"
package="io.flutter.plugins.quickactions">

<uses-sdk tools:overrideLibrary="android_libs.ub_uiautomator"/>
</manifest>
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,8 @@ public void onMethodCall(MethodCall call, MethodChannel.Result result) {

final boolean didSucceed = dynamicShortcutsSet;

// TODO(camsim99): Move re-dispatch below to background thread when Flutter 2.8+ is stable.
// TODO(camsim99): Move re-dispatch below to background thread when Flutter 2.8+ is
// stable.
uiThreadExecutor.execute(
() -> {
if (didSucceed) {
Expand Down Expand Up @@ -162,8 +163,7 @@ private Intent getIntentToOpenMainActivity(String type) {
.getLaunchIntentForPackage(packageName)
.setAction(Intent.ACTION_RUN)
.putExtra(EXTRA_ACTION, type)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK);
.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
}

private static class UiThreadExecutor implements Executor {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ public boolean onNewIntent(Intent intent) {
}
// Notify the Dart side if the launch intent has the intent extra relevant to quick actions.
if (intent.hasExtra(MethodCallHandlerImpl.EXTRA_ACTION) && channel != null) {
channel.invokeMethod("getLaunchAction", null);
Copy link
Contributor

Choose a reason for hiding this comment

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

Why are calling Java code from Java code via the method channel? And why are you calling a getter but not using the result?

Copy link
Contributor Author

@TabooSun TabooSun May 4, 2022

Choose a reason for hiding this comment

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

I have updated the code.

channel.invokeMethod("launch", intent.getStringExtra(MethodCallHandlerImpl.EXTRA_ACTION));
}
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ if (flutterVersionName == null) {
apply plugin: 'com.android.application'
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

def androidXTestVersion = '1.2.0'

android {
compileSdkVersion 31

Expand Down Expand Up @@ -53,7 +55,12 @@ flutter {

dependencies {
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
api 'androidx.test:core:1.2.0'
api "androidx.test:core:$androidXTestVersion"

androidTestImplementation "androidx.test:runner:$androidXTestVersion"
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0'
androidTestImplementation 'androidx.test.ext:junit:1.0.0'
androidTestImplementation 'org.mockito:mockito-core:4.3.1'
androidTestImplementation 'org.mockito:mockito-android:4.3.1'
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,47 @@

package io.flutter.plugins.quickactionsexample;

import static org.junit.Assert.assertTrue;
import static org.junit.Assert.*;
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.


import android.content.Context;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.util.Log;
import androidx.lifecycle.Lifecycle;
import androidx.test.core.app.ActivityScenario;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import androidx.test.uiautomator.*;
Copy link
Contributor

Choose a reason for hiding this comment

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

Same.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

import io.flutter.plugins.quickactions.QuickActionsPlugin;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;

@RunWith(AndroidJUnit4.class)
public class QuickActionsTest {
private Context context;
private UiDevice device;
private ActivityScenario<QuickActionsTestActivity> scenario;

@Before
public void setUp() {
context = ApplicationProvider.getApplicationContext();
device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
scenario = ensureAppRunToView();
}

@After
public void tearDown() {
scenario.close();
Log.i(QuickActionsTest.class.getSimpleName(), "Run to completion");
}

@Test
public void imagePickerPluginIsAdded() {
final ActivityScenario<QuickActionsTestActivity> scenario =
Expand All @@ -20,4 +54,108 @@ public void imagePickerPluginIsAdded() {
assertTrue(activity.engine.getPlugins().has(QuickActionsPlugin.class));
});
}

@Test
public void appShortcutsAreCreated() {
// Arrange
Copy link
Contributor

Choose a reason for hiding this comment

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

I appreciate the organization of the tests, but can you remove the "arrange", "act", and "assert" comments? I think the clarity will remain!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure.

List<Shortcut> expectedShortcuts = createMockShortcuts();

// Act
ShortcutManager shortcutManager =
(ShortcutManager) context.getSystemService(Context.SHORTCUT_SERVICE);
List<ShortcutInfo> dynamicShortcuts = shortcutManager.getDynamicShortcuts();
Object[] shortcuts = dynamicShortcuts.stream().map(Shortcut::new).toArray();
Copy link
Contributor

Choose a reason for hiding this comment

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

I may be missing some context here, but can you just use the ShortcutInfos to test versus creating this new Shortcut object?

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 meant to keep the test variants created from createMockShortcuts as simple as possible, without the need to go through the complexity of creating a ShortcutInfo.

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 that complexity? If doable, I would suggest testing with ShortcutInfos for clarity since this is the object actually used in the plugin.

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 have updated the code.


// Assert the app shortcuts defined in ../lib/main.dart.
assertFalse(dynamicShortcuts.isEmpty());
assertEquals(2, dynamicShortcuts.size());
assertArrayEquals(expectedShortcuts.toArray(), shortcuts);
}

@Test
public void appShortcutExistsAfterLongPressingAppIcon() throws UiObjectNotFoundException {
// Arrange
List<Shortcut> shortcuts = createMockShortcuts();
String appName = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString();

// Act
findAppIcon(device, appName).longClick();

// Assert
for (Shortcut shortcut : shortcuts) {
Assert.assertTrue(
"The specified shortcut label '" + shortcut.shortLabel + "' does not exist.",
device.hasObject(By.text(shortcut.shortLabel)));
}
}

@Test
public void appShortcutLaunchActivityAfterPressing() throws UiObjectNotFoundException {
// Arrange
List<Shortcut> shortcuts = createMockShortcuts();
String appName = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString();
Shortcut firstShortcut = shortcuts.get(0);
AtomicReference<QuickActionsTestActivity> initialActivity = new AtomicReference<>();
scenario.onActivity(initialActivity::set);

// Act
findAppIcon(device, appName).longClick();
UiObject appShortcut = device.findObject(new UiSelector().text(firstShortcut.shortLabel));
appShortcut.clickAndWaitForNewWindow();
AtomicReference<QuickActionsTestActivity> currentActivity = new AtomicReference<>();
scenario.onActivity(currentActivity::set);

// Assert
Assert.assertTrue(
"AppShortcut:" + firstShortcut.type + " does not launch the correct activity",
// We can only find the shortcut type in content description while inspecting it in Ui
// Automator Viewer.
device.hasObject(By.desc(firstShortcut.type)));
// This is Android SingleTop behavior in which Android does not destroy the initial activity and
// launch a new activity.
Assert.assertEquals(initialActivity.get(), currentActivity.get());
}

private List<Shortcut> createMockShortcuts() {
List<Shortcut> expectedShortcuts = new ArrayList<>();
String actionOneLocalizedTitle = "Action one";
expectedShortcuts.add(
new Shortcut("action_one", actionOneLocalizedTitle, actionOneLocalizedTitle));

String actionTwoLocalizedTitle = "Action two";
expectedShortcuts.add(
new Shortcut("action_two", actionTwoLocalizedTitle, actionTwoLocalizedTitle));

return expectedShortcuts;
}

private ActivityScenario<QuickActionsTestActivity> ensureAppRunToView() {
final ActivityScenario<QuickActionsTestActivity> scenario =
ActivityScenario.launch(QuickActionsTestActivity.class);
scenario.moveToState(Lifecycle.State.STARTED);
return scenario;
}

private UiObject findAppIcon(UiDevice device, String appName) throws UiObjectNotFoundException {
device.pressHome();

// Swipe up to open App Drawer
UiScrollable homeView = new UiScrollable(new UiSelector().scrollable(true));
homeView.scrollForward();

if (!device.hasObject(By.text(appName))) {
Log.i(
QuickActionsTest.class.getSimpleName(),
"Attempting to scroll App Drawer for App Icon...");
UiScrollable appDrawer = new UiScrollable(new UiSelector().scrollable(true));
// The scrollTextIntoView scrolls to the beginning before performing searching scroll; this
// causes an issue in a scenario where the view is already in the beginning. In this case, it
// scrolls back to home view. Therefore, we perform a dummy forward scroll to ensure it is not
// in the beginning.
appDrawer.scrollForward();
appDrawer.scrollTextIntoView(appName);
}

return device.findObject(new UiSelector().text(appName));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2013 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package io.flutter.plugins.quickactionsexample;

import android.content.pm.ShortcutInfo;
import java.util.Objects;

class Shortcut {
Copy link
Contributor

Choose a reason for hiding this comment

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

Made a comment about this above, but do we need this object for testing?

final String type;
final String shortLabel;
final String longLabel;
String icon;

public Shortcut(ShortcutInfo shortcutInfo) {
this.type = shortcutInfo.getId();
this.shortLabel = shortcutInfo.getShortLabel().toString();
this.longLabel = shortcutInfo.getLongLabel().toString();
}

public Shortcut(String type, String shortLabel, String longLabel) {
this.type = type;
this.shortLabel = shortLabel;
this.longLabel = longLabel;
}

@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;

Shortcut shortcut = (Shortcut) o;

if (!type.equals(shortcut.type)) return false;
if (!shortLabel.equals(shortcut.shortLabel)) return false;
if (!longLabel.equals(shortcut.longLabel)) return false;
return Objects.equals(icon, shortcut.icon);
}

@Override
public int hashCode() {
int result = type.hashCode();
result = 31 * result + shortLabel.hashCode();
result = 31 * result + longLabel.hashCode();
result = 31 * result + (icon != null ? icon.hashCode() : 0);
return result;
}
}