Skip to content

Commit

Permalink
feat(android_intent_plus): adds getResolvedActivity method (#3313)
Browse files Browse the repository at this point in the history
  • Loading branch information
josh-burton authored Oct 12, 2024
1 parent 6469523 commit 8ad1c6d
Show file tree
Hide file tree
Showing 8 changed files with 247 additions and 2 deletions.
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
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}")),
);
}
}

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

0 comments on commit 8ad1c6d

Please sign in to comment.