Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(android_intent_plus): adds getResolvedActivity method #3313

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
19 changes: 19 additions & 0 deletions packages/android_intent_plus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ of integers or strings.
> ACTION_VIEW intents for Android, however this intent plugin also allows
> clients to set extra parameters for the intent.

### Querying activities
`canResolveActivity()` and `getResolvedActivity()` can be used to query whether an activity can handle an intent,
or get the details of the activity that can handle the intent.

```dart
final intent = AndroidIntent(
action: 'action_view',
data: Uri.encodeFull('http://'),
);

// can this intent be handled by an activity
final canHandleIntent = await intent.canResolveActivity();

// get the details of the activity that will handle this intent
final details = await intent.getResolvedActivity();

print(details.packageName); // prints com.google.chrome
```

## Android 11 package visibility

Android 11 introduced new permissions for package visibility.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import androidx.annotation.Nullable;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.Map;

/** Forms and launches intents. */
public final class IntentSender {
Expand Down Expand Up @@ -102,6 +105,41 @@ boolean canResolveActivity(Intent intent) {
return packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null;
}

/**
* Get the default activity that will resolve the intent
*
* <p>This will fail to create and send the intent if {@code applicationContext} hasn't been set *
* at the time of calling.
*
* <p>This currently only supports resolving activities.
*
* @param intent Fully built intent.
* @return Whether the package manager found {@link android.content.pm.ResolveInfo} using its
* {@link PackageManager#resolveActivity(Intent, int)} method.
* @see #buildIntent(String, Integer, String, Uri, Bundle, String, ComponentName, String)
*/
@Nullable
Map<String, Object> getResolvedActivity(Intent intent) {
if (applicationContext == null) {
Log.wtf(TAG, "Trying to resolve an activity before the applicationContext was initialized.");
return null;
}

final PackageManager packageManager = applicationContext.getPackageManager();
ResolveInfo resolveInfo =
packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);

if (resolveInfo != null) {
Map<String, Object> resultMap = new HashMap<>();
resultMap.put("activityName", resolveInfo.activityInfo.name);
resultMap.put("packageName", resolveInfo.activityInfo.packageName);
resultMap.put("appName", resolveInfo.loadLabel(packageManager));
return resultMap;
}

return null;
}

/** Caches the given {@code activity} to use for {@link #send}. */
void setActivity(@Nullable Activity activity) {
this.activity = activity;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ public void onMethodCall(@NonNull MethodCall call, @NonNull Result result) {
result.success(null);
} else if ("canResolveActivity".equalsIgnoreCase(call.method)) {
result.success(sender.canResolveActivity(intent));
} else if ("getResolvedActivity".equalsIgnoreCase(call.method)) {
result.success(sender.getResolvedActivity(intent));
} else {
result.notImplemented();
}
Expand Down
josh-burton marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,18 @@
flutter needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET"/>

<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="http"/>
</intent>
<intent>
<action android:name="android.intent.action.VIEW"/>
<data android:scheme="https"/>
</intent>
</queries>

<application
android:name="${applicationName}"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import 'dart:io';

import 'package:android_intent_plus_example/main.dart';
import 'package:android_intent_plus/android_intent.dart';
import 'package:android_intent_plus_example/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -107,4 +107,23 @@ void main() {
const intent = AndroidIntent(action: 'LAUNCH', package: 'foobar');
await expectLater(await intent.canResolveActivity(), isFalse);
}, skip: !Platform.isAndroid);

testWidgets(
'getResolvedActivity return activity details when example Activity is found',
(WidgetTester tester) async {
final intent = AndroidIntent(
action: 'action_view',
data: Uri.encodeFull('http://'),
);
await expectLater(await intent.getResolvedActivity(), isNotNull);
}, skip: !Platform.isAndroid);

testWidgets('getResolvedActivity returns null when no Activity is found',
(WidgetTester tester) async {
final intent = AndroidIntent(
action: 'action_view',
data: Uri.encodeFull('mycustomscheme://'),
);
await expectLater(await intent.getResolvedActivity(), isNull);
}, skip: !Platform.isAndroid);
}
21 changes: 21 additions & 0 deletions packages/android_intent_plus/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,20 @@ class ExplicitIntentsWidget extends StatelessWidget {
intent.launch();
}

void _getResolvedActivity(BuildContext context) async {
final intent = AndroidIntent(
action: 'action_view',
data: Uri.encodeFull('http://'),
);

final details = await intent.getResolvedActivity();
if (details != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("${details.appName} - ${details.packageName}")),
);
josh-burton marked this conversation as resolved.
Show resolved Hide resolved
}
}

void _openGmail() {
const intent = AndroidIntent(
action: 'android.intent.action.SEND',
Expand Down Expand Up @@ -277,6 +291,13 @@ class ExplicitIntentsWidget extends StatelessWidget {
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => _getResolvedActivity(context),
child: const Text(
'Tap here to get default resolved activity',
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _openGmail,
child: const Text(
Expand Down
55 changes: 55 additions & 0 deletions packages/android_intent_plus/lib/android_intent.dart
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,31 @@ class AndroidIntent {
);
}

/// Get the default activity that will resolve the intent
///
/// Note: ensure the calling app's AndroidManifest contains queries that match the intent.
/// See: https://developer.android.com/guide/topics/manifest/queries-element
Future<ResolvedActivity?> getResolvedActivity() async {
if (!_platform.isAndroid) {
return null;
}

final result = await _channel.invokeMethod<Map<Object?, Object?>>(
'getResolvedActivity',
_buildArguments(),
);

if (result != null) {
return ResolvedActivity(
appName: result["appName"] as String,
activityName: result["activityName"] as String,
packageName: result["packageName"] as String,
);
}

return null;
}

/// Constructs the map of arguments which is passed to the plugin.
Map<String, dynamic> _buildArguments() {
return {
Expand All @@ -224,3 +249,33 @@ class AndroidIntent {
};
}
}

class ResolvedActivity {
final String appName;
final String activityName;
final String packageName;

ResolvedActivity({
required this.appName,
required this.activityName,
required this.packageName,
});

@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ResolvedActivity &&
runtimeType == other.runtimeType &&
appName == other.appName &&
activityName == other.activityName &&
packageName == other.packageName;

@override
int get hashCode =>
appName.hashCode ^ activityName.hashCode ^ packageName.hashCode;

@override
String toString() {
return 'ResolvedActivity{appName: $appName, activityName: $activityName, packageName: $packageName}';
}
}
80 changes: 80 additions & 0 deletions packages/android_intent_plus/test/android_intent_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,86 @@ void main() {
});
});

group('getResolvedActivity', () {
test('pass right params', () async {
androidIntent = AndroidIntent.private(
action: 'action_view',
data: Uri.encodeFull('https://flutter.dev'),
flags: <int>[Flag.FLAG_ACTIVITY_NEW_TASK],
channel: mockChannel,
platform: FakePlatform(operatingSystem: 'android'),
type: 'video/*');
await androidIntent.getResolvedActivity();
verify(mockChannel
.invokeMethod<void>('getResolvedActivity', <String, Object>{
'action': 'action_view',
'data': Uri.encodeFull('https://flutter.dev'),
'flags':
androidIntent.convertFlags(<int>[Flag.FLAG_ACTIVITY_NEW_TASK]),
'type': 'video/*',
}));
});

test('returns a ResolvedActivity', () async {
androidIntent = AndroidIntent.private(
action: 'action_view',
data: Uri.encodeFull('https://flutter.dev'),
channel: mockChannel,
platform: FakePlatform(operatingSystem: 'android'),
);

when(mockChannel.invokeMethod("getResolvedActivity", any))
.thenAnswer((_) async => <String, dynamic>{
"activityName": "activity name",
"appName": "App Name",
"packageName": "com.packagename",
});

final result = await androidIntent.getResolvedActivity();

expect(result?.activityName, equals("activity name"));
expect(result?.appName, equals("App Name"));
expect(result?.packageName, equals("com.packagename"));
});

test('can send Intent with an action and no component', () async {
androidIntent = AndroidIntent.private(
action: 'action_view',
channel: mockChannel,
platform: FakePlatform(operatingSystem: 'android'),
);
await androidIntent.getResolvedActivity();
verify(mockChannel
.invokeMethod<void>('getResolvedActivity', <String, Object>{
'action': 'action_view',
}));
});

test('can send Intent with a component and no action', () async {
androidIntent = AndroidIntent.private(
package: 'packageName',
componentName: 'componentName',
channel: mockChannel,
platform: FakePlatform(operatingSystem: 'android'),
);
await androidIntent.getResolvedActivity();
verify(mockChannel
.invokeMethod<void>('getResolvedActivity', <String, Object>{
'package': 'packageName',
'componentName': 'componentName',
}));
});

test('call in ios platform', () async {
androidIntent = AndroidIntent.private(
action: 'action_view',
channel: mockChannel,
platform: FakePlatform(operatingSystem: 'ios'));
await androidIntent.getResolvedActivity();
verifyZeroInteractions(mockChannel);
});
});

group('launchChooser', () {
test('pass title', () async {
androidIntent = AndroidIntent.private(
Expand Down