From 113232411274ab50cf066085360f77df9f01564f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20A=C3=9Fmann?= Date: Mon, 22 Feb 2021 22:30:49 +0100 Subject: [PATCH 1/2] Android: Improve initialize method, add custom notification icon. --- CHANGELOG.md | 8 +- README.md | 36 +-- .../FlutterBackgroundPlugin.kt | 41 +++- .../IsolateHolderService.kt | 2 +- .../drawable-anydpi-v24/background_icon.xml | 15 ++ .../res/drawable-hdpi/background_icon.png | Bin 0 -> 450 bytes .../res/drawable-mdpi/background_icon.png | Bin 0 -> 325 bytes .../res/drawable-xhdpi/background_icon.png | Bin 0 -> 591 bytes .../res/drawable-xxhdpi/background_icon.png | Bin 0 -> 823 bytes example/android/settings_aar.gradle | 1 + example/lib/app.dart | 9 +- .../bloc/tcp_client_bloc/tcp_client_bloc.dart | 53 +++-- .../tcp_client_bloc/tcp_client_event.dart | 6 +- .../tcp_client_bloc/tcp_client_state.dart | 6 +- example/lib/main.dart | 3 +- example/lib/models/message.dart | 16 +- .../lib/models/socket_connection_state.dart | 2 +- example/lib/pages/about_page.dart | 2 +- example/lib/pages/home_page.dart | 215 +++++++++--------- example/lib/utils/notification_service.dart | 32 ++- example/lib/utils/validators.dart | 11 +- example/pubspec.lock | 2 +- example/server/server.dart | 2 +- lib/flutter_background.dart | 2 +- lib/src/android_config.dart | 25 +- lib/src/flutter_background.dart | 7 +- pubspec.yaml | 2 +- 27 files changed, 282 insertions(+), 216 deletions(-) create mode 100644 example/android/app/src/main/res/drawable-anydpi-v24/background_icon.xml create mode 100644 example/android/app/src/main/res/drawable-hdpi/background_icon.png create mode 100644 example/android/app/src/main/res/drawable-mdpi/background_icon.png create mode 100644 example/android/app/src/main/res/drawable-xhdpi/background_icon.png create mode 100644 example/android/app/src/main/res/drawable-xxhdpi/background_icon.png create mode 100644 example/android/settings_aar.gradle diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c349f..ce4acf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,4 +30,10 @@ ## 0.1.5 -* Add `isBackgroundExecutionEnabled` property to enable checking the current background execution state \ No newline at end of file +* Add `isBackgroundExecutionEnabled` property to enable checking the current background execution state. + +## 0.1.6 + +* Improve initialize method on Android +* Add ability to specify custom notification icons +* Update documentation accordingly \ No newline at end of file diff --git a/README.md b/README.md index 57b51ac..dd1dae6 100644 --- a/README.md +++ b/README.md @@ -50,19 +50,31 @@ final androidConfig = FlutterBackgroundAndroidConfig( notificationTitle: "Title of the notification", notificationText: "Text of the notification", notificationImportance: AndroidNotificationImportance.Default, + notificationIcon: AndroidResource(name: 'background_icon', defType: 'drawable'), // Default is ic_launcher from folder mipmap ); bool success = await FlutterBackground.initialize(androidConfig: androidconfig); ``` -In order to function correctly, this plugin needs a few permissions. `FlutterBackground.initialize(...)` will request permissions from the user if necessary. +The notification icon is for the small icon displayed in the top left of a notification and must be +a drawable Android Resource (see [here](https://developer.android.com/reference/android/app/Notification.Builder#setSmallIcon(int,%20int)) for more). +Check out the example app or the +[Android documentation for creating notification icons](https://developer.android.com/studio/write/image-asset-studio#create-notification) +for more information how to create and store an icon. -In order to notify the user about upcoming permission requests by the system, you need to know, whether or not the app already has these permissions. You can find out by calling +In order to function correctly, this plugin needs a few permissions. +`FlutterBackground.initialize(...)` will request permissions from the user if necessary. +You can call initialize more than one time, so you can call `initalize()` every time before you call +`enableBackgroundExecution()` (see below). + +In order to notify the user about upcoming permission requests by the system, you need to know, +whether or not the app already has these permissions. You can find out by calling ```dart bool hasPermissions = await FlutterBackground.hasPermissions; ``` -before calling `FlutterBackground.initialize(...)`. If the app already has all necessary permissions, no permission requests will be displayed to the user. +before calling `FlutterBackground.initialize(...)`. If the app already has all necessary permissions, +no permission requests will be displayed to the user. ### Run app in background @@ -72,7 +84,8 @@ With bool success = await FlutterBackground.enableBackgroundExecution(); ``` -you can try to get the app running in the background. You must call `FlutterBackground.initialize()` before calling `FlutterBackground.enableBackgroundExecution()`. +you can try to get the app running in the background. You must call `FlutterBackground.initialize()` +before calling `FlutterBackground.enableBackgroundExecution()`. With @@ -80,7 +93,8 @@ With await FlutterBackground.disableBackgroundExecution(); ``` -you can stop the background execution of the app. You must call `FlutterBackground.initialize()` before calling `FlutterBackground.disableBackgroundExecution()`. +you can stop the background execution of the app. You must call `FlutterBackground.initialize()` +before calling `FlutterBackground.disableBackgroundExecution()`. To check whether background execution is currently enabled, use @@ -88,24 +102,12 @@ To check whether background execution is currently enabled, use bool enabled = FlutterBackground.isBackgroundExecutionEnabled ``` -### Notes - -The plugin is currently hard-coded to load the icon for the foreground service notification from a drawable resource with the identifier `ic_launcher`. -So if you want to change the logo for the notification, you have to change this resource. I'm planning to allow for custom resource names. - ## Example The example is a TCP chat app: It can connect to a TCP server and send and receive messages. The user is notified about incoming messages by notifications created with the plugin [flutter_local_notifications](https://pub.dev/packages/flutter_local_notifications). Using this plugin, the example app can maintain the TCP connection with the server, receiving messages and creating notifications for the user even when in the background. -## ToDo - -- Add automated tests -- On android, add [ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS](https://developer.android.com/reference/android/provider/Settings#ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) as an option to obtain excemption from battery optimizations, as declaring [REQUEST_IGNORE_BATTERY_OPTIMIZATIONS](https://developer.android.com/reference/android/Manifest.permission.html#REQUEST_IGNORE_BATTERY_OPTIMIZATIONS) might lead to a ban of the app in the Play Store, "unless the core function of the app is adversely affected" (see the note [here](https://developer.android.com/training/monitoring-device-state/doze-standby.html#support_for_other_use_cases)) -- On android, allow for other notification icon resource names than `ic_launcher` -- Explore options of background execution for iOS (help needed, I don't have any Mac/iOS devices or experience with programming for them) - ## Maintainer [Julian Aßmann](https://github.com/JulianAssmann) diff --git a/android/src/main/kotlin/de/julianassmann/flutter_background/FlutterBackgroundPlugin.kt b/android/src/main/kotlin/de/julianassmann/flutter_background/FlutterBackgroundPlugin.kt index 51afe22..73805cd 100644 --- a/android/src/main/kotlin/de/julianassmann/flutter_background/FlutterBackgroundPlugin.kt +++ b/android/src/main/kotlin/de/julianassmann/flutter_background/FlutterBackgroundPlugin.kt @@ -38,6 +38,20 @@ class FlutterBackgroundPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { var notificationText: String? = "Keeps the flutter app running in the background" @JvmStatic var notificationImportance: Int? = NotificationCompat.PRIORITY_DEFAULT + + @JvmStatic + var notificationIconName: String? = "ic_launcher" + @JvmStatic + var notificationIconDefType: String? = "mipmap" + } + + private fun isValidResource(context: Context, name: String, defType: String, result: Result, errorCode: String): Boolean { + val resourceId = context.getResources().getIdentifier(name, defType, context.getPackageName()) + if (resourceId == 0) { + result.error("ResourceError", "The resource $defType/$name could not be found. Please make sure it has been added as a resource to your Android head project.", null) + return false + } + return true } override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { @@ -58,23 +72,34 @@ class FlutterBackgroundPlugin: FlutterPlugin, MethodCallHandler, ActivityAware { val title = call.argument("android.notificationTitle") val text = call.argument("android.notificationText") val importance = call.argument("android.notificationImportance") + val iconName = call.argument("android.notificationIconName") + val iconDefType = call.argument("android.notificationIconDefType") // Set static values so the IsolateHolderService can use them later on to configure the notification - notificationImportance = importance ?: NotificationCompat.PRIORITY_DEFAULT - notificationTitle = title ?: "flutter_background foreground service" - notificationText = text ?: "Keeps the flutter app running in the background" + notificationImportance = importance ?: notificationImportance + notificationTitle = title ?: notificationTitle + notificationText = text ?: text + notificationIconName = iconName ?: notificationIconName + notificationIconDefType = iconDefType ?: notificationIconDefType - // Ensure all the necessary permissions are granted, otherwise request them + if (permissionHandler!!.isWakeLockPermissionGranted() && permissionHandler!!.isIgnoringBatteryOptimizations()) { + result.success(true) + return + } + + // Ensure wake lock permissions are granted if (!permissionHandler!!.isWakeLockPermissionGranted()) { - result.error("PermissionError","Please add the WAKE_LOCK permission to the AndroidManifest.xml in order to use background_sockets.", "") - } else if (!permissionHandler!!.isIgnoringBatteryOptimizations()) { + result.error("PermissionError", "Please add the WAKE_LOCK permission to the AndroidManifest.xml in order to use background_sockets.", "") + return + } + + // Ensure ignoring battery optimizations is enabled + if (!permissionHandler!!.isIgnoringBatteryOptimizations()) { if (activity != null) { permissionHandler!!.requestBatteryOptimizationsOff(result, activity!!) } else { result.error("NoActivityError", "The plugin is not attached to an activity", "The plugin is not attached to an activity. This is required in order to request battery optimization to be off.") } - } else { - result.success(true) } } "enableBackgroundExecution" -> { diff --git a/android/src/main/kotlin/de/julianassmann/flutter_background/IsolateHolderService.kt b/android/src/main/kotlin/de/julianassmann/flutter_background/IsolateHolderService.kt index b31852f..4498c2b 100644 --- a/android/src/main/kotlin/de/julianassmann/flutter_background/IsolateHolderService.kt +++ b/android/src/main/kotlin/de/julianassmann/flutter_background/IsolateHolderService.kt @@ -53,7 +53,7 @@ class IsolateHolderService : Service() { notificationManager.createNotificationChannel(channel) } - val imageId = resources.getIdentifier("ic_launcher", "mipmap", packageName) + val imageId = resources.getIdentifier(FlutterBackgroundPlugin.notificationIconName, FlutterBackgroundPlugin.notificationIconDefType, packageName) val notification = NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(FlutterBackgroundPlugin.notificationTitle) .setContentText(FlutterBackgroundPlugin.notificationText) diff --git a/example/android/app/src/main/res/drawable-anydpi-v24/background_icon.xml b/example/android/app/src/main/res/drawable-anydpi-v24/background_icon.xml new file mode 100644 index 0000000..92b52f5 --- /dev/null +++ b/example/android/app/src/main/res/drawable-anydpi-v24/background_icon.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/example/android/app/src/main/res/drawable-hdpi/background_icon.png b/example/android/app/src/main/res/drawable-hdpi/background_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e3d29ad7bcbb9889fb320ee834bd4370de1a3c75 GIT binary patch literal 450 zcmV;z0X_bSP)WGl94QQ>iH4U`-vrg1wS74 zBQ{-k+G#JaZQE4xOY$lCHTjMCMBmhZ5HYl=)PBjDqcVaJDecxQV+lwnUy2Q4az`0c z#ri(EP5xQL+;?Y-!0!bS1IGJ;nB7AtG9X`@cFN>8!!k7>jsb*5I*hD*3JuPsT3ZQ{ za96^ek-(9cN}!X8+z7A-hTTgE1s0?Qgt9pye!hXDG_BcI;RYxnL+ihg#bPm|g!m0c z^)rJdMhJN+Ay-j?8bV_eLL}lS>|y?yG$e%VvkVnKIhYJeNJ~Ubq(TiD_CJc!`sTi~ zNX3beVbbtYLNnVy0i-;5?FVFR*u;NRDgoI2*A6ju>ZSF3OxhdKpuVaz%MKcAgL!A3 z8jKzvbV$7iXf9q6eFcVPbGdGjX8YiI^Pc?ek-kW@7s?19^h`@34#ixISUBY2NIGx; sv2e&k@sAecNMnJyqY|s`w9{T-KNSO(h`SL0jQ{`u07*qoM6N<$f-Akl)&Kwi literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/drawable-mdpi/background_icon.png b/example/android/app/src/main/res/drawable-mdpi/background_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..812a1313f7b1c69dc8158a05c8db84475d330615 GIT binary patch literal 325 zcmV-L0lNN)P)s;ea~m+PlYHkB_8u{3Cby1Ic@jBux?G zQs$g1z2D9REWAIxf8E-`vxj$?3w~b!4&FbJTRfiv(NXXg$reO%Cr*Pn*eeyhiWC=# zIZZN&4;m3%W|mY@K^j4ZC|yV7hu-uQfdN5905~FFA-~|tBdO*yr5bk2G|wZ~Ookv- zK_?U7^jf!VW=BLtecq}>AQ?IEh(K`%Ol`cAkpRO?lB9MIr7n;(%&DMF`9oFq&%XA$1FdsI{8w26Ht;r%8+ zw)9&H#@U1T7KqkBaltdr)_$Sm7+)geZ<5<%Iwt zy@T;lHy^l2O`+Xsz-?cXEDu(Co;t>m54w-u0%1t`um)m_R&!z%((DCoK5nV^qt6<* z2^O?9@Dcsd@yFK{`L@_G%~3l|E{(gb*NNL`g@&8r)#RIKl{Q{v=wXi+Tz=Hq)002ovPDHLkV1nB>3~B%X literal 0 HcmV?d00001 diff --git a/example/android/app/src/main/res/drawable-xxhdpi/background_icon.png b/example/android/app/src/main/res/drawable-xxhdpi/background_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8b5f8696936af1326234f38969369b5885120444 GIT binary patch literal 823 zcmV-71IYY|P) zl_Y>6%aW3kl9G~=l9GZ-Yb}f&j2(?#9d?HA#MrI}r{zJypCw-ayZ#b`$hmu1oA{uTbul?*ve8 z>7u~wfW*Cg?GwbJn#9dK*{j(Zpn@d(dY11BaqvAgAWKFYc9Y(18_f`VHLOOTKLkjk z2Wro;hM@XqsQ_7?M{aG*wLsLAQ=b@6hY3gq0#pVB)UpEP$%0!|Bg|Gw7K4zsDna2` z0Oc5fMhq82gj8e2JF*n~?ev7z>=+9U6(EJV4%z^zMt&Z&t%k%t>HtVxXgD*EK-){Y z;!w4*O|cRZ8XV~Ng?@nV*pIac?JhyWh9juXgHFW9qBS*~Stji>g2qS6*F7^x=x}Cz zR1}S?04|^i;pAj*H5N9myQn&SJGgHtJE_TEq0rABN!gLG#G-+FvG?1dUmKz`TP(UW zhI0#EVBuK1D8_K;D?2PcJ+i6^^uuXl48V1Kab^&ZpDYTyJUjh+j6uOIp(x>>86VCHNbk3E4#-fB3-yw`?ruQ6@|vqm5BSHAaA8g?w0i(M1L)_9 z;&q;@d)W1lI-V{Wcfs^;0)uJUUx%`QlAZ&9EOi_!Y`+18J)@dkU&-{iUI`EPX`Jfe zccuQNOT8;ryb`jXve;$m!d+5QQc_Yox literal 0 HcmV?d00001 diff --git a/example/android/settings_aar.gradle b/example/android/settings_aar.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/example/android/settings_aar.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/example/lib/app.dart b/example/lib/app.dart index 80a7c43..da68673 100644 --- a/example/lib/app.dart +++ b/example/lib/app.dart @@ -5,11 +5,12 @@ import 'bloc/tcp_client_bloc/tcp_client_bloc.dart'; class BackgroundSocketsExampleApp extends StatefulWidget { @override - _BackgroundSocketsExampleAppState createState() => _BackgroundSocketsExampleAppState(); + _BackgroundSocketsExampleAppState createState() => + _BackgroundSocketsExampleAppState(); } -class _BackgroundSocketsExampleAppState extends State { - +class _BackgroundSocketsExampleAppState + extends State { @override void initState() { super.initState(); @@ -26,4 +27,4 @@ class _BackgroundSocketsExampleAppState extends State { print('hasPermissions: $hasPermissions'); } try { - hasPermissions = await FlutterBackground.initialize( - androidConfig: FlutterBackgroundAndroidConfig( - notificationTitle: 'flutter_background example app', - notificationText: 'Background notification for keeping the example app running in the background' - ) + final config = FlutterBackgroundAndroidConfig( + notificationTitle: 'flutter_background example app', + notificationText: + 'Background notification for keeping the example app running in the background', + notificationIcon: AndroidResource(name: 'background_icon'), ); + // Demonstrate calling initialize twice in a row is possible without causing problems. + hasPermissions = + await FlutterBackground.initialize(androidConfig: config); + hasPermissions = + await FlutterBackground.initialize(androidConfig: config); } catch (ex) { print(ex); } if (hasPermissions) { - final backgroundExecution = await FlutterBackground.enableBackgroundExecution(); + final backgroundExecution = + await FlutterBackground.enableBackgroundExecution(); if (backgroundExecution) { try { _socket = await Socket.connect(event.host, event.port); _socketStreamSub = _socket.asBroadcastStream().listen((event) { - add( - MessageReceived( + add(MessageReceived( message: Message( - message: String.fromCharCodes(event), - timestamp: DateTime.now(), - origin: MessageOrigin.Server, - ) - ) - ); + message: String.fromCharCodes(event), + timestamp: DateTime.now(), + origin: MessageOrigin.Server, + ))); }); _socket.handleError(() { add(ErrorOccured()); }); - yield state.copywith(connectionState: SocketConnectionState.Connected); + yield state.copywith( + connectionState: SocketConnectionState.Connected); } catch (err) { print(err); yield state.copywith(connectionState: SocketConnectionState.Failed); @@ -88,18 +92,18 @@ class TcpClientBloc extends Bloc { await _socketStreamSub?.cancel(); await _socket?.close(); await FlutterBackground.disableBackgroundExecution(); - yield state.copywith(connectionState: SocketConnectionState.None, messages: []); + yield state + .copywith(connectionState: SocketConnectionState.None, messages: []); } Stream _mapSendMessageToState(SendMessage event) async* { if (_socket != null) { yield state.copyWithNewMessage( - message: Message( - message: event.message, - timestamp: DateTime.now(), - origin: MessageOrigin.Client, - ) - ); + message: Message( + message: event.message, + timestamp: DateTime.now(), + origin: MessageOrigin.Client, + )); _socket.write(event.message); } } @@ -117,8 +121,9 @@ class TcpClientBloc extends Bloc { await _socket?.close(); } - Stream _mapMessageReceivedToState(MessageReceived event) async* { + Stream _mapMessageReceivedToState( + MessageReceived event) async* { await NotificationService().newNotification(event.message.message, false); yield state.copyWithNewMessage(message: event.message); } -} \ No newline at end of file +} diff --git a/example/lib/bloc/tcp_client_bloc/tcp_client_event.dart b/example/lib/bloc/tcp_client_bloc/tcp_client_event.dart index c0a2f53..12b3b8b 100644 --- a/example/lib/bloc/tcp_client_bloc/tcp_client_event.dart +++ b/example/lib/bloc/tcp_client_bloc/tcp_client_event.dart @@ -7,11 +7,13 @@ abstract class TcpClientEvent {} class Connect extends TcpClientEvent { /// The host of the server to connect to. final dynamic host; + /// The port of the server to connect to. final int port; Connect({@required this.host, @required this.port}) - : assert(host != null), assert(port != null); + : assert(host != null), + assert(port != null); @override String toString() => '''Connect { @@ -53,4 +55,4 @@ class SendMessage extends TcpClientEvent { @override String toString() => 'SendMessage { }'; -} \ No newline at end of file +} diff --git a/example/lib/bloc/tcp_client_bloc/tcp_client_state.dart b/example/lib/bloc/tcp_client_bloc/tcp_client_state.dart index 729ae90..047aa7d 100644 --- a/example/lib/bloc/tcp_client_bloc/tcp_client_state.dart +++ b/example/lib/bloc/tcp_client_bloc/tcp_client_state.dart @@ -12,9 +12,7 @@ class TcpClientState { factory TcpClientState.initial() { return TcpClientState( - connectionState: SocketConnectionState.None, - messages: [] - ); + connectionState: SocketConnectionState.None, messages: []); } TcpClientState copywith({ @@ -33,4 +31,4 @@ class TcpClientState { messages: List.from(messages)..add(message), ); } -} \ No newline at end of file +} diff --git a/example/lib/main.dart b/example/lib/main.dart index fb412b1..902f183 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,6 @@ - import 'package:flutter/material.dart'; import 'package:flutter_background_example/app.dart'; void main() { runApp(BackgroundSocketsExampleApp()); -} \ No newline at end of file +} diff --git a/example/lib/models/message.dart b/example/lib/models/message.dart index 64e2711..6ee78b0 100644 --- a/example/lib/models/message.dart +++ b/example/lib/models/message.dart @@ -1,18 +1,14 @@ import 'package:flutter/foundation.dart'; -enum MessageOrigin { - Client, - Server -} +enum MessageOrigin { Client, Server } class Message { final DateTime timestamp; final String message; final MessageOrigin origin; - Message({ - @required this.timestamp, - @required this.message, - @required this.origin - }); -} \ No newline at end of file + Message( + {@required this.timestamp, + @required this.message, + @required this.origin}); +} diff --git a/example/lib/models/socket_connection_state.dart b/example/lib/models/socket_connection_state.dart index 6f7825a..78a3664 100644 --- a/example/lib/models/socket_connection_state.dart +++ b/example/lib/models/socket_connection_state.dart @@ -4,4 +4,4 @@ enum SocketConnectionState { Connected, Failed, None -} \ No newline at end of file +} diff --git a/example/lib/pages/about_page.dart b/example/lib/pages/about_page.dart index ffd1831..ebe286c 100644 --- a/example/lib/pages/about_page.dart +++ b/example/lib/pages/about_page.dart @@ -29,4 +29,4 @@ class AboutPage extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/example/lib/pages/home_page.dart b/example/lib/pages/home_page.dart index c26a881..9afa3fc 100644 --- a/example/lib/pages/home_page.dart +++ b/example/lib/pages/home_page.dart @@ -24,16 +24,14 @@ class _MainPageState extends State { @override void initState() { super.initState(); - _tcpBloc = BlocProvider.of(context); + _tcpBloc = BlocProvider.of(context); _hostEditingController = TextEditingController(text: '10.0.2.2'); _portEditingController = TextEditingController(text: '5555'); _chatTextEditingController = TextEditingController(text: ''); _chatTextEditingController.addListener(() { - setState(() { - - }); + setState(() {}); }); } @@ -46,107 +44,105 @@ class _MainPageState extends State { IconButton( icon: Icon(Icons.info_outline), onPressed: () { - Navigator.of(context).push(MaterialPageRoute( - builder: (BuildContext context) { - return AboutPage(); - } - )); + Navigator.of(context) + .push(MaterialPageRoute(builder: (BuildContext context) { + return AboutPage(); + })); }, ) ], ), body: BlocConsumer( - cubit: _tcpBloc, - listener: (BuildContext context, TcpClientState state) { - if (state.connectionState == SocketConnectionState.Connected) { - Scaffold.of(context) - ..hideCurrentSnackBar(); - } else if (state.connectionState == SocketConnectionState.Failed) { - Scaffold.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text('Connection failed'), Icon(Icons.error)], - ), - backgroundColor: Colors.red, - ), - ); - } else { - return Container(); - } - }, - builder: (context, state) { - if (state.connectionState == SocketConnectionState.None || state.connectionState == SocketConnectionState.Failed) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8.0), - child: ListView( - children: [ - TextFormField( - controller: _hostEditingController, - autovalidateMode: AutovalidateMode.always, - validator: (str) => isValidHost(str) ? null : 'Invalid hostname', - decoration: InputDecoration( - helperText: 'The ip address or hostname of the TCP server', - hintText: 'Enter the address here, e.g. 10.0.2.2', + cubit: _tcpBloc, + listener: (BuildContext context, TcpClientState state) { + if (state.connectionState == SocketConnectionState.Connected) { + Scaffold.of(context)..hideCurrentSnackBar(); + } else if (state.connectionState == SocketConnectionState.Failed) { + Scaffold.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text('Connection failed'), Icon(Icons.error)], ), + backgroundColor: Colors.red, ), - TextFormField( - controller: _portEditingController, - autovalidateMode: AutovalidateMode.always, - validator: (str) => isValidPort(str) ? null : 'Invalid port', - decoration: InputDecoration( - helperText: 'The port the TCP server is listening on', - hintText: 'Enter the port here, e. g. 8000', + ); + } else { + return Container(); + } + }, + builder: (context, state) { + if (state.connectionState == SocketConnectionState.None || + state.connectionState == SocketConnectionState.Failed) { + return Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8.0), + child: ListView( + children: [ + TextFormField( + controller: _hostEditingController, + autovalidateMode: AutovalidateMode.always, + validator: (str) => + isValidHost(str) ? null : 'Invalid hostname', + decoration: InputDecoration( + helperText: + 'The ip address or hostname of the TCP server', + hintText: 'Enter the address here, e.g. 10.0.2.2', + ), ), - ), - RaisedButton( - child: Text('Connect'), - onPressed: isValidHost(_hostEditingController.text) && isValidPort(_portEditingController.text) - ? () { - _tcpBloc.add( - Connect( - host: _hostEditingController.text, - port: int.parse(_portEditingController.text) - ) - ); - } - : null, - ) - ], - ), - ); - } else if (state.connectionState == SocketConnectionState.Connecting) { - return Center( - child: Column( - children: [ - CircularProgressIndicator(), - Text('Connecting...'), - RaisedButton( - child: Text('Abort'), - onPressed: () { - _tcpBloc.add(Disconnect()); - }, - ) - ], - ), - ); - } else if (state.connectionState == SocketConnectionState.Connected) { - return Column( - children: [ + TextFormField( + controller: _portEditingController, + autovalidateMode: AutovalidateMode.always, + validator: (str) => + isValidPort(str) ? null : 'Invalid port', + decoration: InputDecoration( + helperText: 'The port the TCP server is listening on', + hintText: 'Enter the port here, e. g. 8000', + ), + ), + RaisedButton( + child: Text('Connect'), + onPressed: isValidHost(_hostEditingController.text) && + isValidPort(_portEditingController.text) + ? () { + _tcpBloc.add(Connect( + host: _hostEditingController.text, + port: + int.parse(_portEditingController.text))); + } + : null, + ) + ], + ), + ); + } else if (state.connectionState == + SocketConnectionState.Connecting) { + return Center( + child: Column( + children: [ + CircularProgressIndicator(), + Text('Connecting...'), + ], + ), + ); + } else if (state.connectionState == + SocketConnectionState.Connected) { + return Column(children: [ Expanded( child: Container( child: ListView.builder( - itemCount: state.messages.length, - itemBuilder: (context, idx) { - final m = state.messages[idx]; - return Bubble( - child: Text(m.message), - alignment: m.origin == MessageOrigin.Client ? Alignment.centerRight : Alignment.centerLeft, - ); - } - ), + itemCount: state.messages.length, + itemBuilder: (context, idx) { + final m = state.messages[idx]; + return Bubble( + child: Text(m.message), + alignment: m.origin == MessageOrigin.Client + ? Alignment.centerRight + : Alignment.centerLeft, + ); + }), ), ), Padding( @@ -155,20 +151,19 @@ class _MainPageState extends State { children: [ Expanded( child: TextField( - decoration: InputDecoration( - hintText: 'Message' - ), + decoration: InputDecoration(hintText: 'Message'), controller: _chatTextEditingController, ), ), IconButton( icon: Icon(Icons.send), onPressed: _chatTextEditingController.text.isEmpty - ? null - : () { - _tcpBloc.add(SendMessage(message: _chatTextEditingController.text)); - _chatTextEditingController.text = ''; - }, + ? null + : () { + _tcpBloc.add(SendMessage( + message: _chatTextEditingController.text)); + _chatTextEditingController.text = ''; + }, ) ], ), @@ -178,14 +173,12 @@ class _MainPageState extends State { onPressed: () { _tcpBloc.add(Disconnect()); }, - ), - ] - ); - } else { - return Container(); - } - } - ), + ), + ]); + } else { + return Container(); + } + }), ); } -} \ No newline at end of file +} diff --git a/example/lib/utils/notification_service.dart b/example/lib/utils/notification_service.dart index 42bfb99..bdf78e9 100644 --- a/example/lib/utils/notification_service.dart +++ b/example/lib/utils/notification_service.dart @@ -3,20 +3,19 @@ import 'dart:typed_data'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; /// The service used to display notifications and handle callbacks when the user taps on the notification. -/// +/// /// This is a singleton. Just call NotificationService() to get the singleton. class NotificationService { static final NotificationService _instance = NotificationService._internal(); + factory NotificationService() => _instance; FlutterLocalNotificationsPlugin plugin; NotificationService._internal() { - final initializationSettings = InitializationSettings( - AndroidInitializationSettings('@mipmap/ic_launcher'), - IOSInitializationSettings() - ); + AndroidInitializationSettings('@mipmap/ic_launcher'), + IOSInitializationSettings()); plugin = FlutterLocalNotificationsPlugin(); plugin.initialize(initializationSettings); @@ -33,27 +32,22 @@ class NotificationService { AndroidNotificationDetails androidNotificationDetails; final channelName = 'Text messages'; - + androidNotificationDetails = AndroidNotificationDetails( - channelName, channelName, channelName, - importance: Importance.Max, - priority: Priority.High, + channelName, channelName, channelName, + importance: Importance.Max, + priority: Priority.High, + vibrationPattern: vibration ? vibrationPattern : null, + enableVibration: vibration); - vibrationPattern: vibration ? vibrationPattern : null, - enableVibration: vibration); - var iOSPlatformChannelSpecifics = IOSNotificationDetails(); var notificationDetails = NotificationDetails( androidNotificationDetails, iOSPlatformChannelSpecifics); - + try { - await plugin.show( - 0, - msg, - msg, - notificationDetails); + await plugin.show(0, msg, msg, notificationDetails); } catch (ex) { print(ex); } } -} \ No newline at end of file +} diff --git a/example/lib/utils/validators.dart b/example/lib/utils/validators.dart index 1647828..f837c2b 100644 --- a/example/lib/utils/validators.dart +++ b/example/lib/utils/validators.dart @@ -1,14 +1,17 @@ /// Validates Hostname bool isValidHost(String str) { if (str == null || str.isEmpty) return false; - final ipAddressExp = RegExp(r'^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'); - final hostnameExp = RegExp(r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'); + final ipAddressExp = RegExp( + r'^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$'); + final hostnameExp = RegExp( + r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$'); return ipAddressExp.hasMatch(str) || hostnameExp.hasMatch(str); } /// Validates a TCP port bool isValidPort(String str) { if (str == null || str.isEmpty) return false; - final regex = RegExp(r'^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$'); + final regex = RegExp( + r'^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$'); return regex.hasMatch(str); -} \ No newline at end of file +} diff --git a/example/pubspec.lock b/example/pubspec.lock index 498e8f7..8825e8e 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -82,7 +82,7 @@ packages: path: ".." relative: true source: path - version: "0.1.5" + version: "0.1.6" flutter_bloc: dependency: "direct main" description: diff --git a/example/server/server.dart b/example/server/server.dart index 6dba3f9..87e99ec 100644 --- a/example/server/server.dart +++ b/example/server/server.dart @@ -14,7 +14,7 @@ Future startServer() async { 'New TCP client ${socket.address.address}:${socket.port} connected.'); var totalSeconds = 0; - final timer = Timer.periodic(Duration(seconds: 5), (timer) { + final timer = Timer.periodic(Duration(seconds: 60), (timer) { totalSeconds += timer.tick; socket.add(totalSeconds.toString().codeUnits); }); diff --git a/lib/flutter_background.dart b/lib/flutter_background.dart index 91846a8..cc4b2f2 100644 --- a/lib/flutter_background.dart +++ b/lib/flutter_background.dart @@ -1,2 +1,2 @@ -export 'src/flutter_background.dart'; export 'src/android_config.dart'; +export 'src/flutter_background.dart'; diff --git a/lib/src/android_config.dart b/lib/src/android_config.dart index 3cff817..e54e855 100644 --- a/lib/src/android_config.dart +++ b/lib/src/android_config.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + /// Represents the importance of an android notification as described /// under https://developer.android.com/training/notify-user/channels#importance. enum AndroidNotificationImportance { @@ -8,6 +10,19 @@ enum AndroidNotificationImportance { Max, } +// Represents the information required to get an Android resource. +// See https://developer.android.com/reference/android/content/res/Resources for reference. +class AndroidResource { + // The name of the desired resource. + final String name; + + // Optional default resource type to find, if "type/" is not included in the name. Can be null to require an explicit type. + final String defType; + + const AndroidResource({@required this.name, this.defType = 'drawable'}) + : assert(name != null); +} + /// Android configuration for the [FlutterBackground] plugin. class FlutterBackgroundAndroidConfig { /// The importance of the notification used for the foreground service. @@ -19,16 +34,24 @@ class FlutterBackgroundAndroidConfig { /// The body used for the foreground service notification. final String notificationText; + /// The resource name of the icon to be used for the foreground notification. + final AndroidResource notificationIcon; + /// Creates an Android specific configuration for the [FlutterBackground] plugin. /// /// [notificationTitle] is the title used for the foreground service notification. /// [notificationText] is the body used for the foreground service notification. /// [notificationImportance] is the importance of the foreground service notification. + /// [notificationIcon] must be a drawable resource. + /// E. g. if the icon with name "background_icon" is in the "drawable" resource folder, + /// [notificationIcon] should be of value `AndroidResource(name: 'background_icon', defType: 'drawable'). /// It must be greater than [AndroidNotificationImportance.Min]. const FlutterBackgroundAndroidConfig( {this.notificationTitle = 'Notification title', this.notificationText = 'Notification text', - this.notificationImportance = AndroidNotificationImportance.Default}) + this.notificationImportance = AndroidNotificationImportance.Default, + this.notificationIcon = + const AndroidResource(name: 'ic_launcher', defType: 'mipmap')}) : assert(notificationTitle != null), assert(notificationText != null), assert(notificationImportance != null); diff --git a/lib/src/flutter_background.dart b/lib/src/flutter_background.dart index f9641be..363e178 100644 --- a/lib/src/flutter_background.dart +++ b/lib/src/flutter_background.dart @@ -23,7 +23,9 @@ class FlutterBackground { 'android.notificationText': androidConfig.notificationText, 'android.notificationImportance': _androidNotificationImportanceToInt( androidConfig.notificationImportance), - }) as bool; + 'android.notificationIconName': androidConfig.notificationIcon.name, + 'android.notificationIconDefType': androidConfig.notificationIcon.defType, + }); return _isInitialized; } @@ -43,7 +45,8 @@ class FlutterBackground { /// May throw a [PlatformException]. static Future enableBackgroundExecution() async { if (_isInitialized) { - final success = await _channel.invokeMethod('enableBackgroundExecution') as bool; + final success = + await _channel.invokeMethod('enableBackgroundExecution') as bool; _isBackgroundExecutionEnabled = true; return success; } else { diff --git a/pubspec.yaml b/pubspec.yaml index c04bb1b..18ccc57 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_background description: A plugin to keep flutter apps running in the background by using foreground service, wake lock and disabling battery optimizations -version: 0.1.5 +version: 0.1.6 repository: https://github.com/JulianAssmann/flutter_background homepage: https://julianassmann.de/ From 3da186534d5008fdb32eac1b8fe3fca5163013bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20A=C3=9Fmann?= Date: Tue, 23 Feb 2021 09:27:27 +0100 Subject: [PATCH 2/2] Add image of foreground notification to the documentation --- README.md | 29 ++++++++++++----------------- images/notification.png | Bin 0 -> 28187 bytes 2 files changed, 12 insertions(+), 17 deletions(-) create mode 100755 images/notification.png diff --git a/README.md b/README.md index dd1dae6..b5187cd 100644 --- a/README.md +++ b/README.md @@ -55,26 +55,23 @@ final androidConfig = FlutterBackgroundAndroidConfig( bool success = await FlutterBackground.initialize(androidConfig: androidconfig); ``` -The notification icon is for the small icon displayed in the top left of a notification and must be -a drawable Android Resource (see [here](https://developer.android.com/reference/android/app/Notification.Builder#setSmallIcon(int,%20int)) for more). -Check out the example app or the -[Android documentation for creating notification icons](https://developer.android.com/studio/write/image-asset-studio#create-notification) -for more information how to create and store an icon. +This results in the following notification (where `background_icon` is a drawable resource, see example app): +![The foreground notification created by the code above.](./images/notification.png "Foreground notification created by the code above.") -In order to function correctly, this plugin needs a few permissions. -`FlutterBackground.initialize(...)` will request permissions from the user if necessary. -You can call initialize more than one time, so you can call `initalize()` every time before you call -`enableBackgroundExecution()` (see below). +The notification icon is for the small icon displayed in the top left of a notification and must be a drawable Android Resource (see [here](https://developer.android.com/reference/android/app/Notification.Builder#setSmallIcon(int,%20int)) for more). +Check out the example app or the [Android documentation for creating notification icons](https://developer.android.com/studio/write/image-asset-studio#create-notification) for more information how to create and store an icon. -In order to notify the user about upcoming permission requests by the system, you need to know, -whether or not the app already has these permissions. You can find out by calling +In order to function correctly, this plugin needs a few permissions. +`FlutterBackground.initialize(...)` will request permissions from the user if necessary. +You can call initialize more than one time, so you can call `initalize()` every time before you call `enableBackgroundExecution()` (see below). + +In order to notify the user about upcoming permission requests by the system, you need to know, whether or not the app already has these permissions. You can find out by calling ```dart bool hasPermissions = await FlutterBackground.hasPermissions; ``` -before calling `FlutterBackground.initialize(...)`. If the app already has all necessary permissions, -no permission requests will be displayed to the user. +before calling `FlutterBackground.initialize(...)`. If the app already has all necessary permissions, no permission requests will be displayed to the user. ### Run app in background @@ -84,8 +81,7 @@ With bool success = await FlutterBackground.enableBackgroundExecution(); ``` -you can try to get the app running in the background. You must call `FlutterBackground.initialize()` -before calling `FlutterBackground.enableBackgroundExecution()`. +you can try to get the app running in the background. You must call `FlutterBackground.initialize()` before calling `FlutterBackground.enableBackgroundExecution()`. With @@ -93,8 +89,7 @@ With await FlutterBackground.disableBackgroundExecution(); ``` -you can stop the background execution of the app. You must call `FlutterBackground.initialize()` -before calling `FlutterBackground.disableBackgroundExecution()`. +you can stop the background execution of the app. You must call `FlutterBackground.initialize()` before calling `FlutterBackground.disableBackgroundExecution()`. To check whether background execution is currently enabled, use diff --git a/images/notification.png b/images/notification.png new file mode 100755 index 0000000000000000000000000000000000000000..1a987ceff3fc8a6e127829e607cc28ba581b6a2e GIT binary patch literal 28187 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 literal 0 HcmV?d00001