Search code examples
androidflutterforeground-service

How can I start a Flutter app's foreground service on boot in Android without using the flutter_background_service plugin?


I'm looking for either a permissively licensed Android plugin similar to flutter_background_service, but one that only starts an application's foreground service on reboot, app upgrade, etc; or a simple way to do this with something like a MethodChannel in Kotlin or Java.

The reason I would prefer not to use flutter_background_service at this time is because it requires communication via serializable data structures between isolates, and it does much more than I need, like starting foreground notifications, a behavior my code already handles via flutter_local_notifications. I was looking at building a custom solution based on code from that plugin, but I don't know much about Android development, and the code is quite complex.

https://stackoverflow.com/a/70980729/582326 works, but it displays the app's main screen on boot. I'd rather not make my app that invasive, so I'm hoping to only launch a foreground service via a sticky notification.

flutter_boot_receiver seems like it would be the right fit, except for its copyleft license. I would prefer not to use that, because I'm working an open source app under the MIT license. (see old answer)

I've found help on Stack Exchange including the following: https://stackoverflow.com/a/78336034/582326. However I already have an isolate in Dart that starts a foreground service, and I don't know how to complete step 4 of that answer without using flutter_background_service.

From what I understand, I may have to open a MethodChannel to call Flutter code from Java / Kotlin. The Flutter platform-channels documentation explains how to do something similar, but only in the other (and more commonly used) direction. That, and what to call in Java / Kotlin, is where I'm currently stuck.

Pointers to documentation, method calls, or code for calling a Flutter isolate on boot / after an app upgrade would be much appreciated.


Solution

  • I decided to switch to handling this behavior manually, rather than using an existing module. This allows me to resolve some issues I was running into in a simple way.

    To restart the app automatically, we need to start the Flutter engine when one of the following intents is sent by the system.

    BootReceiver.kt:

    package com.example.foo
    
    import android.content.BroadcastReceiver
    import android.content.Context
    import android.content.Intent
    
    class BootReceiver : BroadcastReceiver() {
        override fun onReceive(context : Context, intent : Intent) {
            if (intent.action.equals("android.intent.action.BOOT_COMPLETED") ||
                intent.action.equals("android.intent.action.QUICKBOOT_POWERON") ||
                intent.action.equals("com.htc.intent.action.QUICKBOOT_POWERON") ||
                intent.action.equals("android.intent.action.MY_PACKAGE_REPLACED")) {
    
                StartFlutterOnce(context, true)
            }
        }
    }
    

    We also start the Flutter engine if / when the app is opened manually.

    MainActivity.kt:

    package com.example.foo
    
    import android.content.Context
    import io.flutter.embedding.android.FlutterActivity
    import io.flutter.embedding.engine.FlutterEngine
    
    class MainActivity: FlutterActivity() {
        override fun provideFlutterEngine(context: Context): FlutterEngine {
            return StartFlutterOnce(context, false).flutterEngine
        }
    }
    

    We also want to only run one FlutterEngine at a time. That's because opening multiple of these will likely waste memory, and could lead to multiple background processes running jobs that don't get used for any real purpose. It appears that another isolate can't be stopped from within another, so we do it in Kotlin.

    Furthermore, when the BootReceiver is called by the system after a reboot, the app doesn't have permission to display the full app yet. That seems to be a feature of the Context variable that gets passed in. So when the app is started by the user, we first need to clean up the old background FlutterEngine and DartExecutor.

    This code also works around a bug in Android SDK 35 where the "boot complete" gets delivered to the app every time that it is started by the user, which would create multiple isolates again.

    StartFlutterOnce.kt:

    package com.example.foo
    
    import android.content.Context
    import io.flutter.embedding.engine.FlutterEngine
    import io.flutter.embedding.engine.FlutterEngineCache
    import io.flutter.embedding.engine.FlutterEngineGroup
    import io.flutter.embedding.engine.dart.DartExecutor
    
    class StartFlutterOnce (context: Context, backgroundOnly: Boolean) {
        private val bgEngineName: String = "single_bg_engine"
        private val fgEngineName: String = "single_fg_engine"
        lateinit var flutterEngine: FlutterEngine
        init {
            val engineName: String
            if (backgroundOnly) {
                engineName = bgEngineName
            } else {
                FlutterEngineCache.getInstance().get(bgEngineName)?.destroy()
                engineName = fgEngineName
            }
            if (FlutterEngineCache.getInstance().get(engineName) == null) {
                val group = FlutterEngineGroup(context)
                flutterEngine = group.createAndRunDefaultEngine(context)
                flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
                FlutterEngineCache.getInstance().put(engineName, flutterEngine)
            }
        }
    }
    

    I'm using the flutter_local_notifications package, so I have that in the AndroidManifest, along with the code for the BootReceiver. The intent filter may be optional here, because it's checked by the app itself. This code also restarts the app if it's been upgraded by the system or via adb install.

    AndroidManifest.xml:

            <service
                android:name="com.dexterous.flutterlocalnotifications.ForegroundService"
                android:exported="false"
                android:stopWithTask="false"
                android:foregroundServiceType="dataSync" />
            <receiver
                android:name=".BootReceiver"
                android:enabled="true"
                android:exported="true"
                android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
                <intent-filter>
                    <action android:name="android.intent.action.BOOT_COMPLETED" />
                    <action android:name="android.intent.action.QUICKBOOT_POWERON" />
                    <action android:name="com.htc.intent.action.QUICKBOOT_POWERON" />
                    <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
                </intent-filter>
            </receiver>
    

    Here we call Dart code to start the app. I have it broken out in two functions, but you don't have to do that. If the app is started in the background after a boot, the app won't get drawn on the screen until the user launches it manually. Dart seems to handle this gracefully, so the app manages to run properly in the background without running foreground code.

    Note that I'm using flutter_local_notifications, and I am using .startForegroundService() from that package to start the notification that keeps the background process running.

    To get that setup working for me, I ended up moving all code that should run in the background into its own background isolate, using https://dart.dev/language/isolates#complete-example as a starting point to bridge the two isolates. Communication between the main and background isolates happens by sending enums for method names, plus arguments. The messages are sent via ports set up in that tutorial.

    Note that you'll still need to ask for permission to show notifications from the main Flutter isolate before you can show the notification for a "foreground service" (a background service with a sticky notification in the foreground).

    main.dart:

    Future<void> main() async {
      await startBackground(); // custom dart code
      await startForeground(); // custom dart code
    }