Published on May 24, 2025

**Why Do Permissions Matter for Background Tasks?**
Mobile apps that perform work in the background (like syncing from a smartwatch) must still obey Android’s runtime permission model. In Android 12+ especially, Bluetooth and Location permissions are runtime permissions that must be explicitly granted by the user before use. For example, to scan or connect to Bluetooth devices you need and in your AndroidManifest.xml. If you don’t check for and request these at runtime, any background sync will fail or crash. Moreover, background-fetch tasks (e.g. using the background_fetch plugin) will still run in a paused app process, so your native plugin code must be prepared to handle missing permissions gracefully and prompt the user when needed.
For example, your plugin’s AndroidManifest might include:
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
These entries (as shown above) declare that your app can use Bluetooth and location features. But on modern Android versions you still have to check at runtime whether the user has granted them, especially since background work cannot automatically bring up permission dialogs.
To handle background syncing, we’ll write a Flutter plugin with native Android code in Kotlin. First, add the necessary dependencies in your Flutter app’s pubspec.yaml:
dependencies:
flutter:
sdk: flutter
background_fetch: ^1.3.8
permission_handler: ^10.6.0 # (not used, only to declare in pubspec as reference, logic is in Android)
We include permission_handler only to ensure the Flutter app knows about these permissions (it’s not strictly used in the native code below, but it can help check status from Dart). The real permission logic will be done in the plugin’s Android code.
Next, create a plugin (e.g. with flutter create --template=plugin my_smartwatch_sync) or add a new Android package in an existing plugin. In the plugin’s Android module, make sure your Gradle is set up for Kotlin and coroutines. In android/build.gradle set compileSdkVersion (e.g. 33) and enable Java 1.8 if needed:
android {
compileSdkVersion 33
defaultConfig {
minSdkVersion 21
targetSdkVersion 33
}
kotlinOptions {
jvmTarget = "1.8"
}
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
}
Also be sure the plugin’s AndroidManifest.xml (usually under android/src/main/) contains the needed lines as shown above. This lets Android know which permissions might be needed. Your manifest might look like:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.smartwatchsync">
<!-- Android 12+ Bluetooth & location permissions -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<!-- ... other config ... -->
</manifest>
These entries (summarized from Android docs) are required for scanning and connecting via Bluetooth, but do not automatically grant the user’s approval — you must ask at runtime.
The core of our solution is in the plugin’s Kotlin code. We implement the new Plugin API (v2 embedding) with an ActivityAware plugin to get an Activity context for showing dialogs. This allows us to check and request permissions from native code. Our plugin class will look something like this:
package com.example.smartwatchsync
import android.Manifest
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import kotlinx.coroutines.*
class SmartwatchSyncPlugin: FlutterPlugin, MethodChannel.MethodCallHandler,
ActivityAware, PluginRegistry.RequestPermissionsResultListener,
PluginRegistry.ActivityResultListener {
private var activity: Activity? = null
private lateinit var channel: MethodChannel
private val coroutineScope = CoroutineScope(Dispatchers.Main)
companion object {
private const val PERMISSION_REQUEST_CODE = 1001
private const val REQUEST_ENABLE_BLUETOOTH = 1002
}
// Deferred results for async calls
private var permissionResult: CompletableDeferred<Boolean>? = null
private var bluetoothEnableResult: CompletableDeferred<Boolean>? = null
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(binding.binaryMessenger, "smartwatch_sync")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
Notice that the class implements ActivityAware. In onAttachedToActivity, we capture the Activity and register listeners for permission and activity results:
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity
// Listen for permission and activity results
binding.addRequestPermissionsResultListener(this)
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivity() {
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activity = binding.activity
binding.addRequestPermissionsResultListener(this)
binding.addActivityResultListener(this)
}
override fun onDetachedFromActivityForConfigChanges() {
activity = null
}
In onMethodCall, we respond to Flutter method calls (via MethodChannel). We might expose two methods, e.g. "ensurePermissions" to just prompt for missing permissions, and "syncData" to perform the background sync. Using Kotlin coroutines, we can launch work on the main thread and await permission requests without blocking:
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"ensurePermissions" -> {
coroutineScope.launch {
val granted = ensurePermissions()
result.success(granted)
}
}
"syncData" -> {
coroutineScope.launch {
// Check and request permissions
val hasPerm = ensurePermissions()
if (!hasPerm) {
result.error("PERMISSION_DENIED", "Permissions not granted", null)
return@launch
}
// Check and enable Bluetooth
val btOk = ensureBluetoothEnabled()
if (!btOk) {
result.error("BLUETOOTH_OFF", "Bluetooth not enabled", null)
return@launch
}
// TODO: perform actual smartwatch data sync here
val data = fetchDataFromSmartwatch()
result.success(data)
}
}
else -> result.notImplemented()
}
}
Here, ensurePermissions() is a suspend function that checks which permissions are missing and requests them. We use ContextCompat.checkSelfPermission and if anything isn’t granted, we call ActivityCompat.requestPermissions(...). We keep a CompletableDeferred to await the user’s response asynchronously. For example:
private suspend fun ensurePermissions(): Boolean {
val currentActivity = activity ?: return false
val needed = mutableListOf<String>()
// Check Fine Location permission (required for Bluetooth scanning)
if (ContextCompat.checkSelfPermission(currentActivity,
Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
needed.add(Manifest.permission.ACCESS_FINE_LOCATION)
}
// On Android 12+, also check BLUETOOTH_SCAN and BLUETOOTH_CONNECT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (ContextCompat.checkSelfPermission(currentActivity,
Manifest.permission.BLUETOOTH_SCAN) != PackageManager.PERMISSION_GRANTED) {
needed.add(Manifest.permission.BLUETOOTH_SCAN)
}
if (ContextCompat.checkSelfPermission(currentActivity,
Manifest.permission.BLUETOOTH_CONNECT) != PackageManager.PERMISSION_GRANTED) {
needed.add(Manifest.permission.BLUETOOTH_CONNECT)
}
}
if (needed.isNotEmpty()) {
// Ask the user for the permissions
permissionResult = CompletableDeferred()
ActivityCompat.requestPermissions(
currentActivity,
needed.toTypedArray(),
PERMISSION_REQUEST_CODE
)
return permissionResult!!.await()
}
return true
}
We also override onRequestPermissionsResult to capture the result. If our PERMISSION_REQUEST_CODE matches, we complete the deferred so the coroutine can resume:
override fun onRequestPermissionsResult(requestCode: Int,
permissions: Array<out String>?,
grantResults: IntArray?): Boolean {
if (requestCode == PERMISSION_REQUEST_CODE) {
// Check if all permissions were granted
val granted = grantResults?.all { it == PackageManager.PERMISSION_GRANTED } == true
permissionResult?.complete(granted)
permissionResult = null
return true
}
return false
}
This pattern (taken from best-practice guides) allows us to suspend for the user’s response without blocking the plugin thread.
Similarly, we may need to prompt the user to enable Bluetooth if it’s turned off. We do this by firing an Intent for BluetoothAdapter.ACTION_REQUEST_ENABLE, which displays the system dialog. We wrap this in another suspend function:
private suspend fun ensureBluetoothEnabled(): Boolean {
val currentActivity = activity ?: return false
val adapter = BluetoothAdapter.getDefaultAdapter()
if (adapter == null || adapter.isEnabled) return true
// Ask user to enable Bluetooth
bluetoothEnableResult = CompletableDeferred()
val enableBtIntent = Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE)
currentActivity.startActivityForResult(enableBtIntent, REQUEST_ENABLE_BLUETOOTH)
return bluetoothEnableResult!!.await()
}
We then override onActivityResult to catch the user’s choice (OK or Cancel) and complete the deferred:
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == REQUEST_ENABLE_BLUETOOTH) {
val enabled = (resultCode == Activity.RESULT_OK)
bluetoothEnableResult?.complete(enabled)
bluetoothEnableResult = null
return true
}
return false
}
Putting it all together, our native Android code can now asynchronously check and request both permissions and Bluetooth status, and only proceed with syncing after everything is in place. This keeps the background task robust and user-friendly.
On the Flutter (Dart) side, we provide a simple interface to call the native plugin methods. For example, in lib/smartwatch_sync.dart:
import 'package:flutter/services.dart';
class SmartwatchSyncPlugin {
static const MethodChannel _channel =
MethodChannel('smartwatch_sync');
/// Ensure Bluetooth/Location permissions are granted.
static Future<bool> ensurePermissions() async {
final bool? granted = await _channel.invokeMethod<bool>('ensurePermissions');
return granted == true;
}
/// Trigger a data sync. Returns the synced data string (dummy example).
static Future<String> syncData() async {
final String? data = await _channel.invokeMethod<String>('syncData');
return data ?? '';
}
}
With this interface, your Flutter app can call await SmartwatchSyncPlugin.syncData() from anywhere (including background tasks).
To actually run this in the background, we use the background_fetch plugin. In your Flutter main.dart, you might configure it like this:
import 'package:flutter/material.dart';
import 'package:background_fetch/background_fetch.dart';
import 'smartwatch_sync.dart';
/// This is the callback that background_fetch will invoke.
void backgroundFetchHeadlessTask(HeadlessTask task) async {
print('[BackgroundFetch] Headless event received.');
final data = await SmartwatchSyncPlugin.syncData();
print('[BackgroundFetch] Synced data: $data');
BackgroundFetch.finish(task.taskId);
}
void main() {
runApp(MyApp());
// Register the headless task
BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
}
class MyApp extends StatefulWidget {
@override Widget build(BuildContext context) => MaterialApp(home: Scaffold());
@override
void initState() {
super.initState();
// Configure background fetch to run periodically even if app is in background/terminated.
BackgroundFetch.configure(
BackgroundFetchConfig(
minimumFetchInterval: 15,
stopOnTerminate: false,
enableHeadless: true,
),
(String taskId) async {
print("[BackgroundFetch] Event received: $taskId");
final data = await SmartwatchSyncPlugin.syncData();
print("Background fetch synced data: $data");
BackgroundFetch.finish(taskId);
}
);
}
}
Here we register a headless task so that even after the app is terminated, the native code can wake and run our sync method. This ties the background-fetch event to our plugin call. In practice, you must ensure the user has granted permissions (via your plugin) before relying on background fetch. You might do an initial permissions check in your app UI. The stopOnTerminate: false, enableHeadless: true flags ensure the fetch still works when the app is not in the foreground.
To test the setup:
Run on a real device (Android emulators may not accurately simulate background fetch). Install the app and grant Bluetooth/Location permissions when prompted.
Enable the plugin’s background fetch in debug logs. You can trigger a fetch event manually via ADB (useful during development):
adb shell cmd jobscheduler run -f com.yourapplicationid.app 999
Replace com.yourapplicationid.app with your application actual package name. This forces a background fetch event
Observe logs. In logcat, you should see your print statements from the headless task, e.g. [BackgroundFetch] Synced data: .... Verify that the plugin correctly checked permissions (or logged an error) before syncing.
Simulate permission denial: If you deny a permission, the plugin will attempt to request it (bringing up the system dialog). Ensure you handle that gracefully and that your Flutter code catches the plugin’s error if needed.
By walking through these steps, you can validate that your Flutter plugin correctly handles background tasks and native Android permissions. The key is that all permission checks and requests happen in the native plugin using the Activity context, while Flutter simply triggers the sync when needed. This clean separation of concerns aligns with modern plugin best practices. Nevertheless, if you want to handle permissions in your application itself, you can use third party packages such as permission_handler. While code structures, optimizations and best practices may differ, you get the gist!