diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml index 1bf5124d..088ea810 100644 --- a/android/src/main/AndroidManifest.xml +++ b/android/src/main/AndroidManifest.xml @@ -1 +1 @@ - + diff --git a/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt b/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt index 46a00b65..044c9a83 100644 --- a/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt +++ b/android/src/main/kotlin/es/antonborri/home_widget/HomeWidgetPlugin.kt @@ -2,9 +2,13 @@ package es.antonborri.home_widget import android.app.Activity import android.appwidget.AppWidgetManager -import android.content.* +import android.appwidget.AppWidgetProviderInfo +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences import android.os.Build -import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -26,7 +30,7 @@ class HomeWidgetPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, private var activity: Activity? = null private var receiver: BroadcastReceiver? = null - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { channel = MethodChannel(flutterPluginBinding.binaryMessenger, "home_widget") channel.setMethodCallHandler(this) @@ -35,7 +39,7 @@ class HomeWidgetPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, context = flutterPluginBinding.applicationContext } - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + override fun onMethodCall(call: MethodCall, result: Result) { when (call.method) { "saveWidgetData" -> { if (call.hasArgument("id") && call.hasArgument("data")) { @@ -52,7 +56,7 @@ class HomeWidgetPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, else -> result.error("-10", "Invalid Type ${data!!::class.java.simpleName}. Supported types are Boolean, Float, String, Double, Long", IllegalArgumentException()) } } else { - prefs.remove(id); + prefs.remove(id) } result.success(prefs.commit()) } else { @@ -139,13 +143,54 @@ class HomeWidgetPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, result.error("-4", "No Widget found with Name $className. Argument 'name' must be the same as your AppWidgetProvider you wish to update", classException) } } + "getInstalledWidgets" -> { + try { + val pinnedWidgetInfoList = getInstalledWidgets(context) + result.success(pinnedWidgetInfoList) + } catch (e: Exception) { + result.error("-5", "Failed to get installed widgets: ${e.message}", null) + } + } else -> { result.notImplemented() } } } - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + private fun getInstalledWidgets(context: Context): List> { + val pinnedWidgetInfoList = mutableListOf>() + val appWidgetManager = AppWidgetManager.getInstance(context.applicationContext) + val installedProviders = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appWidgetManager.getInstalledProvidersForPackage(context.packageName, null) + } else { + appWidgetManager.installedProviders.filter { it.provider.packageName == context.packageName } + } + for (provider in installedProviders) { + val widgetIds = appWidgetManager.getAppWidgetIds(provider.provider) + for (widgetId in widgetIds) { + val widgetInfo = appWidgetManager.getAppWidgetInfo(widgetId) + pinnedWidgetInfoList.add(widgetInfoToMap(widgetId, widgetInfo)) + } + } + return pinnedWidgetInfoList + } + + private fun widgetInfoToMap(widgetId: Int, widgetInfo: AppWidgetProviderInfo): Map { + val label = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + widgetInfo.loadLabel(context.packageManager).toString() + } else { + @Suppress("DEPRECATION") + widgetInfo.label + } + + return mapOf( + WIDGET_INFO_KEY_WIDGET_ID to widgetId, + WIDGET_INFO_KEY_ANDROID_CLASS_NAME to widgetInfo.provider.shortClassName, + WIDGET_INFO_KEY_LABEL to label + ) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { channel.setMethodCallHandler(null) } @@ -156,6 +201,10 @@ class HomeWidgetPlugin : FlutterPlugin, MethodCallHandler, ActivityAware, private const val CALLBACK_DISPATCHER_HANDLE = "callbackDispatcherHandle" private const val CALLBACK_HANDLE = "callbackHandle" + private const val WIDGET_INFO_KEY_WIDGET_ID = "widgetId" + private const val WIDGET_INFO_KEY_ANDROID_CLASS_NAME = "androidClassName" + private const val WIDGET_INFO_KEY_LABEL = "label" + private fun saveCallbackHandle(context: Context, dispatcher: Long, handle: Long) { context.getSharedPreferences(INTERNAL_PREFERENCES, Context.MODE_PRIVATE) .edit() diff --git a/example/integration_test/android_test.dart b/example/integration_test/android_test.dart index d941319b..2cb03262 100644 --- a/example/integration_test/android_test.dart +++ b/example/integration_test/android_test.dart @@ -73,6 +73,11 @@ void main() { final retrievedData = await HomeWidget.initiallyLaunchedFromHomeWidget(); expect(retrievedData, isNull); }); + + testWidgets('Get Installed Widgets returns empty list', (tester) async { + final retrievedData = await HomeWidget.getInstalledWidgets(); + expect(retrievedData, isEmpty); + }); } Future backgroundCallback(Uri? uri) async {} diff --git a/example/integration_test/ios_test.dart b/example/integration_test/ios_test.dart index 7a888ad2..2dc667a1 100644 --- a/example/integration_test/ios_test.dart +++ b/example/integration_test/ios_test.dart @@ -87,7 +87,7 @@ void main() { expect(retrievedData, isNull); }); - group('Register Backgorund Callback', () { + group('Register Background Callback', () { testWidgets('RegisterBackgroundCallback completes without error', (tester) async { final deviceInfo = await DeviceInfoPlugin().iosInfo; @@ -116,6 +116,11 @@ void main() { }); }); }); + + testWidgets('Get Installed Widgets returns empty list', (tester) async { + final retrievedData = await HomeWidget.getInstalledWidgets(); + expect(retrievedData, isEmpty); + }); } Future interactivityCallback(Uri? uri) async {} diff --git a/example/lib/main.dart b/example/lib/main.dart index f474dd5e..1ddb825f 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:home_widget/home_widget.dart'; +import 'package:home_widget/home_widget_info.dart'; import 'package:workmanager/workmanager.dart'; /// Used for Background Updates using Workmanager Plugin @@ -172,6 +173,43 @@ class _MyAppState extends State { Workmanager().cancelByUniqueName('1'); } + Future _getInstalledWidgets() async { + try { + final widgets = await HomeWidget.getInstalledWidgets(); + if (!mounted) return; + + String getText(HomeWidgetInfo widget) { + if (Platform.isIOS) { + return 'iOS Family: ${widget.iOSFamily}, iOS Kind: ${widget.iOSKind}'; + } else { + return 'Android Widget id: ${widget.androidWidgetId}, ' + 'Android Class Name: ${widget.androidClassName}, ' + 'Android Label: ${widget.androidLabel}'; + } + } + + await showDialog( + context: context, + builder: (buildContext) => AlertDialog( + title: const Text('Installed Widgets'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Number of widgets: ${widgets.length}'), + const Divider(), + for (final widget in widgets) + Text( + getText(widget), + ), + ], + ), + ), + ); + } on PlatformException catch (exception) { + debugPrint('Error getting widget information. $exception'); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -179,43 +217,50 @@ class _MyAppState extends State { title: const Text('HomeWidget Example'), ), body: Center( - child: Column( - children: [ - TextField( - decoration: const InputDecoration( - hintText: 'Title', + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + TextField( + decoration: const InputDecoration( + hintText: 'Title', + ), + controller: _titleController, + ), + TextField( + decoration: const InputDecoration( + hintText: 'Body', + ), + controller: _messageController, ), - controller: _titleController, - ), - TextField( - decoration: const InputDecoration( - hintText: 'Body', + ElevatedButton( + onPressed: _sendAndUpdate, + child: const Text('Send Data to Widget'), + ), + ElevatedButton( + onPressed: _loadData, + child: const Text('Load Data'), ), - controller: _messageController, - ), - ElevatedButton( - onPressed: _sendAndUpdate, - child: const Text('Send Data to Widget'), - ), - ElevatedButton( - onPressed: _loadData, - child: const Text('Load Data'), - ), - ElevatedButton( - onPressed: _checkForWidgetLaunch, - child: const Text('Check For Widget Launch'), - ), - if (Platform.isAndroid) ElevatedButton( - onPressed: _startBackgroundUpdate, - child: const Text('Update in background'), + onPressed: _checkForWidgetLaunch, + child: const Text('Check For Widget Launch'), ), - if (Platform.isAndroid) + if (Platform.isAndroid) + ElevatedButton( + onPressed: _startBackgroundUpdate, + child: const Text('Update in background'), + ), + if (Platform.isAndroid) + ElevatedButton( + onPressed: _stopBackgroundUpdate, + child: const Text('Stop updating in background'), + ), ElevatedButton( - onPressed: _stopBackgroundUpdate, - child: const Text('Stop updating in background'), + onPressed: _getInstalledWidgets, + child: const Text('Get Installed Widgets'), ), - ], + ], + ), ), ), ); diff --git a/ios/Classes/SwiftHomeWidgetPlugin.swift b/ios/Classes/SwiftHomeWidgetPlugin.swift index 7de7d8aa..cd05fbba 100644 --- a/ios/Classes/SwiftHomeWidgetPlugin.swift +++ b/ios/Classes/SwiftHomeWidgetPlugin.swift @@ -170,8 +170,28 @@ public class SwiftHomeWidgetPlugin: NSObject, FlutterPlugin, FlutterStreamHandle result(false) } else if call.method == "requestPinWidget" { result(nil) - } - else { + } else if call.method == "getInstalledWidgets" { + if #available(iOS 14.0, *) { + #if arch(arm64) || arch(i386) || arch(x86_64) + WidgetCenter.shared.getCurrentConfigurations { result2 in + switch result2 { + case let .success(widgets): + let widgetInfoList = widgets.map { widget in + return ["family": "\(widget.family)", "kind": widget.kind] + } + result(widgetInfoList) + case let .failure(error): + result(FlutterError(code: "-8", message: "Failed to get installed widgets: \(error.localizedDescription)", details: nil)) + } + } + #endif + } else { + result( + FlutterError( + code: "-4", message: "Widgets are only available on iOS 14.0 and above", details: nil) + ) + } + } else { result(FlutterMethodNotImplemented) } } diff --git a/lib/home_widget.dart b/lib/home_widget.dart index e84ce925..e89a2505 100644 --- a/lib/home_widget.dart +++ b/lib/home_widget.dart @@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:home_widget/home_widget_callback_dispatcher.dart'; +import 'package:home_widget/home_widget_info.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path_provider_foundation/path_provider_foundation.dart'; @@ -115,7 +116,7 @@ class HomeWidget { try { return Uri.parse(value); } on FormatException { - debugPrint('Received Data($value) is not parsebale into an Uri'); + debugPrint('Received Data($value) is not parsable into an Uri'); } } return Uri(); @@ -206,7 +207,7 @@ class HomeWidget { ///adding the rootElement to the buildScope buildOwner.buildScope(rootElement); - /// finialize the buildOwner + /// finalize the buildOwner buildOwner.finalizeTree(); ///Flush Layout @@ -264,4 +265,21 @@ class HomeWidget { throw Exception('Failed to render the widget: $e'); } } + + /// On iOS, returns a list of [HomeWidgetInfo] for each type of widget currently installed, + /// regardless of the number of instances. + /// On Android, returns a list of [HomeWidgetInfo] for each instance of each widget + /// currently pinned on the home screen. + /// Returns an empty list if no widgets are pinned. + static Future> getInstalledWidgets() async { + final List? result = + await _channel.invokeMethod('getInstalledWidgets'); + return result + ?.map( + (widget) => + HomeWidgetInfo.fromMap(widget.cast()), + ) + .toList() ?? + []; + } } diff --git a/lib/home_widget_info.dart b/lib/home_widget_info.dart new file mode 100644 index 00000000..3c97350c --- /dev/null +++ b/lib/home_widget_info.dart @@ -0,0 +1,65 @@ +/// Represents information about the pinned home widget. +class HomeWidgetInfo { + /// Only iOS. The size of the widget: small, medium, or large. + String? iOSFamily; + + /// Only iOS. The string specified during creation of the widget’s configuration. + String? iOSKind; + + /// Only Android. Unique identifier for each instance of the widget, used for tracking individual widget usage. + int? androidWidgetId; + + /// Only Android. The [androidClassName] parameter represents the class name of the widget. + String? androidClassName; + + /// Only Android. Loads the localized label to display to the user in the AppWidget picker. + String? androidLabel; + + /// Constructs a [HomeWidgetInfo] object. + HomeWidgetInfo({ + this.iOSFamily, + this.iOSKind, + this.androidWidgetId, + this.androidClassName, + this.androidLabel, + }); + + /// Constructs a [HomeWidgetInfo] object from a map. + /// + /// The [data] parameter is a map that contains the widget information. + factory HomeWidgetInfo.fromMap(Map data) { + return HomeWidgetInfo( + iOSFamily: data['family'] as String?, + iOSKind: data['kind'] as String?, + androidWidgetId: data['widgetId'] as int?, + androidClassName: data['androidClassName'] as String?, + androidLabel: data['label'] as String?, + ); + } + + @override + String toString() { + return 'HomeWidgetInfo{iOSFamily: $iOSFamily, iOSKind: $iOSKind, androidWidgetId: $androidWidgetId, androidClassName: $androidClassName, androidLabel: $androidLabel}'; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is HomeWidgetInfo && + other.iOSFamily == iOSFamily && + other.iOSKind == iOSKind && + other.androidWidgetId == androidWidgetId && + other.androidClassName == androidClassName && + other.androidLabel == androidLabel; + } + + @override + int get hashCode { + return iOSFamily.hashCode ^ + iOSKind.hashCode ^ + androidWidgetId.hashCode ^ + androidClassName.hashCode ^ + androidLabel.hashCode; + } +} diff --git a/test/home_widget_info_test.dart b/test/home_widget_info_test.dart new file mode 100644 index 00000000..1b797936 --- /dev/null +++ b/test/home_widget_info_test.dart @@ -0,0 +1,69 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:home_widget/home_widget_info.dart'; + +void main() { + group('HomeWidgetInfo', () { + test('fromMap constructs HomeWidgetInfo object from map', () { + final data = { + 'family': 'medium', + 'kind': 'anotherKind', + 'widgetId': 1, + 'androidClassName': 'com.example.AnotherWidget', + 'label': 'Another Widget', + }; + + final info = HomeWidgetInfo.fromMap(data); + + expect(info.iOSFamily, 'medium'); + expect(info.iOSKind, 'anotherKind'); + expect(info.androidClassName, 'com.example.AnotherWidget'); + expect(info.androidLabel, 'Another Widget'); + }); + + test('HomeWidgetInfo toString', () { + final homeWidgetInfo = HomeWidgetInfo( + iOSFamily: 'systemSmall', + iOSKind: 'ParkingWidget', + androidWidgetId: 1, + androidClassName: 'com.example.MyWidget', + androidLabel: 'My Widget', + ); + + expect( + homeWidgetInfo.toString(), + 'HomeWidgetInfo{iOSFamily: systemSmall, iOSKind: ParkingWidget, androidWidgetId: 1, androidClassName: com.example.MyWidget, androidLabel: My Widget}', + ); + }); + + test('HomeWidgetInfo equality', () { + final info1 = HomeWidgetInfo( + iOSFamily: 'medium', + iOSKind: 'anotherKind', + androidWidgetId: 1, + androidClassName: 'com.example.AnotherWidget', + androidLabel: 'Another Widget', + ); + + final info2 = HomeWidgetInfo( + iOSFamily: 'medium', + iOSKind: 'anotherKind', + androidWidgetId: 1, + androidClassName: 'com.example.AnotherWidget', + androidLabel: 'Another Widget', + ); + + final info3 = HomeWidgetInfo( + iOSFamily: 'systemSmall', + iOSKind: 'ParkingWidget', + androidWidgetId: 1, + androidClassName: 'com.example.MyWidget', + androidLabel: 'My Widget', + ); + + expect(info1 == info2, true); + expect(info1.hashCode, equals(info2.hashCode)); + expect(info1 == info3, false); + expect(info1.hashCode, isNot(equals(info3.hashCode))); + }); + }); +} diff --git a/test/home_widget_test.dart b/test/home_widget_test.dart index d7130c1a..79f47177 100644 --- a/test/home_widget_test.dart +++ b/test/home_widget_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:golden_toolkit/golden_toolkit.dart'; import 'package:home_widget/home_widget.dart'; import 'package:home_widget/home_widget_callback_dispatcher.dart'; +import 'package:home_widget/home_widget_info.dart'; import 'package:mocktail/mocktail.dart'; // ignore: depend_on_referenced_packages @@ -360,6 +361,49 @@ void main() { ); }); }); + + group('getInstalledWidgets', () { + test( + 'returns a list of HomeWidgetInfo objects when method channel provides data', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + switch (methodCall.method) { + case 'getInstalledWidgets': + return [ + {"id": "widget1", "name": "Widget One"}, + {"id": "widget2", "name": "Widget Two"}, + ]; + default: + return null; + } + }); + + final expectedWidgets = [ + HomeWidgetInfo.fromMap({"id": "widget1", "name": "Widget One"}), + HomeWidgetInfo.fromMap({"id": "widget2", "name": "Widget Two"}), + ]; + + final widgets = await HomeWidget.getInstalledWidgets(); + + expect(widgets, equals(expectedWidgets)); + }); + + test('returns an empty list when method channel returns null', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + // ignore: body_might_complete_normally_nullable + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + switch (methodCall.method) { + case 'getInstalledWidgets': + return null; + } + }); + + final widgets = await HomeWidget.getInstalledWidgets(); + + expect(widgets, isEmpty); + }); + }); } void emitEvent(ByteData? event) {