Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
## 9.0.0

* [**CHORE**] Bump minimum supported SDK version to `Flutter 3.22/Dart 3.4`
* [**CHORE**] Bump `kotlin_version(1.7.10 -> 1.9.10)`, `gradle(7.3.0 -> 8.6.0)` for Android 15
* [**FEAT**] Add `isTimeout` param to the onDestroy callback
* [**FIX**] Fix "null object" error [#332](https://github.com/Dev-hwang/flutter_foreground_task/issues/332)
* [**FIX**] Fix "Reply already submitted" error [#330](https://github.com/Dev-hwang/flutter_foreground_task/issues/330)
* [**FIX**] Prevent crash by catching exceptions during foreground service start
* Check [migration_documentation](./documentation/migration_documentation.md) for changes

## 8.17.0

* [**FEAT**] Allow `onNotificationPressed` to trigger without `SYSTEM_ALERT_WINDOW` permission
Expand Down
39 changes: 30 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,20 @@ To use this plugin, add `flutter_foreground_task` as a [dependency in your pubsp

```yaml
dependencies:
flutter_foreground_task: ^8.17.0
flutter_foreground_task: ^9.0.0
```

After adding the plugin to your flutter project, we need to declare the platform-specific permissions ans service to use for this plugin to work properly.

### :baby_chick: Android

This plugin requires `Kotlin version 1.9.10+` and `Gradle version 8.6.0+`. Please refer to the migration documentation for more details.

- [project/settings.gradle](https://github.com/Dev-hwang/flutter_foreground_task/blob/master/example/android/settings.gradle)
- [project/gradle-wrapper.properties](https://github.com/Dev-hwang/flutter_foreground_task/blob/master/example/android/gradle/wrapper/gradle-wrapper.properties)
- [app/build.gradle](https://github.com/Dev-hwang/flutter_foreground_task/blob/master/example/android/app/build.gradle)
- [migration_documentation](https://github.com/Dev-hwang/flutter_foreground_task/blob/master/documentation/migration_documentation.md)

Open the `AndroidManifest.xml` file and declare the service tag inside the `<application>` tag as follows.

If you want the foreground service to run only when the app is running, add `android:stopWithTask="true"`.
Expand Down Expand Up @@ -71,9 +78,23 @@ As mentioned in the Android guidelines, to start a FG service on Android 14+, yo
android:exported="false" />
```

Check runtime requirements before starting the service. If this requirement is not met, the foreground service cannot be started.

<img src="https://github.com/Dev-hwang/flutter_foreground_task/assets/47127353/2a35dada-2c82-41f4-8a45-56776c88e9d3" width="700">
> [!CAUTION]
> Check [runtime requirements](https://developer.android.com/about/versions/14/changes/fgs-types-required#system-runtime-checks) before starting the service. If this requirement is not met, the foreground service cannot be started.

> [!CAUTION]
> Android 15 introduces a new timeout behavior to `dataSync` for apps targeting Android 15 (API level 35) or higher.
> The system permits an app's `dataSync` services to run for a total of 6 hours in a 24-hour period.
> However, if the user brings the app to the foreground, the timer resets and the app has 6 hours available.
>
> There are new restrictions on `BOOT_COMPLETED(autoRunOnBoot)` broadcast receivers launching foreground services.
> `BOOT_COMPLETED` receivers are not allowed to launch the following types of foreground services:
> - [dataSync](https://developer.android.com/develop/background-work/services/fg-service-types#data-sync)
> - [camera](https://developer.android.com/develop/background-work/services/fg-service-types#camera)
> - [mediaPlayback](https://developer.android.com/develop/background-work/services/fg-service-types#media)
> - [phoneCall](https://developer.android.com/develop/background-work/services/fg-service-types#phone-call)
> - [microphone](https://developer.android.com/about/versions/14/changes/fgs-types-required#microphone)
>
> You can find how to test this behavior and more details at this [link](https://developer.android.com/about/versions/15/behavior-changes-15#fgs-hardening).

### :baby_chick: iOS

Expand Down Expand Up @@ -212,8 +233,8 @@ class MyTaskHandler extends TaskHandler {

// Called when the task is destroyed.
@override
Future<void> onDestroy(DateTime timestamp) async {
print('onDestroy');
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {
print('onDestroy(isTimeout: $isTimeout)');
}

// Called when data is sent using `FlutterForegroundTask.sendDataToTask`.
Expand Down Expand Up @@ -428,7 +449,7 @@ class FirstTaskHandler extends TaskHandler {
}

@override
Future<void> onDestroy(DateTime timestamp) async {
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {
// some code
}
}
Expand Down Expand Up @@ -459,7 +480,7 @@ class SecondTaskHandler extends TaskHandler {
}

@override
Future<void> onDestroy(DateTime timestamp) async {
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {
// some code
}
}
Expand Down Expand Up @@ -554,7 +575,7 @@ class MyTaskHandler extends TaskHandler {
}

@override
Future<void> onDestroy(DateTime timestamp) async {
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {
_streamSubscription?.cancel();
_streamSubscription = null;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.PluginRegistry
import java.util.UUID
import kotlin.Exception

/** MethodCallHandlerImpl */
Expand All @@ -25,7 +26,8 @@ class MethodCallHandlerImpl(private val context: Context, private val provider:
private lateinit var channel: MethodChannel

private var activity: Activity? = null
private var resultCallbacks: MutableMap<Int, MethodChannel.Result?> = mutableMapOf()
private var methodCodes: MutableMap<Int, Int> = mutableMapOf()
private var methodResults: MutableMap<Int, MethodChannel.Result> = mutableMapOf()

override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val args = call.arguments
Expand Down Expand Up @@ -109,27 +111,30 @@ class MethodCallHandlerImpl(private val context: Context, private val provider:

"openIgnoreBatteryOptimizationSettings" -> {
checkActivityNull().let {
val reqCode = RequestCode.OPEN_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
resultCallbacks[reqCode] = result
PluginUtils.openIgnoreBatteryOptimizationSettings(it, reqCode)
val requestCode = UUID.randomUUID().hashCode() and 0xFFFF
methodCodes[requestCode] = RequestCode.OPEN_IGNORE_BATTERY_OPTIMIZATION_SETTINGS
methodResults[requestCode] = result
PluginUtils.openIgnoreBatteryOptimizationSettings(it, requestCode)
}
}

"requestIgnoreBatteryOptimization" -> {
checkActivityNull().let {
val reqCode = RequestCode.REQUEST_IGNORE_BATTERY_OPTIMIZATION
resultCallbacks[reqCode] = result
PluginUtils.requestIgnoreBatteryOptimization(it, reqCode)
val requestCode = UUID.randomUUID().hashCode() and 0xFFFF
methodCodes[requestCode] = RequestCode.REQUEST_IGNORE_BATTERY_OPTIMIZATION
methodResults[requestCode] = result
PluginUtils.requestIgnoreBatteryOptimization(it, requestCode)
}
}

"canDrawOverlays" -> result.success(PluginUtils.canDrawOverlays(context))

"openSystemAlertWindowSettings" -> {
checkActivityNull().let {
val reqCode = RequestCode.OPEN_SYSTEM_ALERT_WINDOW_SETTINGS
resultCallbacks[reqCode] = result
PluginUtils.openSystemAlertWindowSettings(it, reqCode)
val requestCode = UUID.randomUUID().hashCode() and 0xFFFF
methodCodes[requestCode] = RequestCode.OPEN_SYSTEM_ALERT_WINDOW_SETTINGS
methodResults[requestCode] = result
PluginUtils.openSystemAlertWindowSettings(it, requestCode)
}
}

Expand All @@ -138,9 +143,10 @@ class MethodCallHandlerImpl(private val context: Context, private val provider:

"openAlarmsAndRemindersSettings" -> {
checkActivityNull().let {
val reqCode = RequestCode.OPEN_ALARMS_AND_REMINDER_SETTINGS
resultCallbacks[reqCode] = result
PluginUtils.openAlarmsAndRemindersSettings(it, reqCode)
val requestCode = UUID.randomUUID().hashCode() and 0xFFFF
methodCodes[requestCode] = RequestCode.OPEN_ALARMS_AND_REMINDER_SETTINGS
methodResults[requestCode] = result
PluginUtils.openAlarmsAndRemindersSettings(it, requestCode)
}
}

Expand All @@ -152,19 +158,25 @@ class MethodCallHandlerImpl(private val context: Context, private val provider:
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
val resultCallback = resultCallbacks[requestCode] ?: return true
val methodCode = methodCodes[requestCode]
val methodResult = methodResults[requestCode]
methodCodes.remove(requestCode)
methodResults.remove(requestCode)

when (requestCode) {
if (methodCode == null || methodResult == null) {
return true
}

when (methodCode) {
RequestCode.OPEN_IGNORE_BATTERY_OPTIMIZATION_SETTINGS ->
resultCallback.success(PluginUtils.isIgnoringBatteryOptimizations(context))
methodResult.success(PluginUtils.isIgnoringBatteryOptimizations(context))
RequestCode.REQUEST_IGNORE_BATTERY_OPTIMIZATION ->
resultCallback.success(PluginUtils.isIgnoringBatteryOptimizations(context))
methodResult.success(PluginUtils.isIgnoringBatteryOptimizations(context))
RequestCode.OPEN_SYSTEM_ALERT_WINDOW_SETTINGS ->
resultCallback.success(PluginUtils.canDrawOverlays(context))
methodResult.success(PluginUtils.canDrawOverlays(context))
RequestCode.OPEN_ALARMS_AND_REMINDER_SETTINGS ->
resultCallback.success(PluginUtils.canScheduleExactAlarms(context))
methodResult.success(PluginUtils.canScheduleExactAlarms(context))
}

return true
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,8 @@ class ForegroundService : Service() {
try {
// Check if the given intent is a LaunchIntent.
val isLaunchIntent = (intent.action == Intent.ACTION_MAIN) &&
intent.categories.contains(Intent.CATEGORY_LAUNCHER)
if (!isLaunchIntent) {
// Log.d(TAG, "not LaunchIntent")
return
}
(intent.categories?.contains(Intent.CATEGORY_LAUNCHER) == true)
if (!isLaunchIntent) return

val data = intent.getStringExtra(INTENT_DATA_NAME)
if (data == ACTION_NOTIFICATION_PRESSED) {
Expand Down Expand Up @@ -96,6 +93,8 @@ class ForegroundService : Service() {
private var wakeLock: PowerManager.WakeLock? = null
private var wifiLock: WifiManager.WifiLock? = null

private var isTimeout: Boolean = false

// A broadcast receiver that handles intents that occur in the foreground service.
private var broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
Expand Down Expand Up @@ -125,61 +124,54 @@ class ForegroundService : Service() {
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
isTimeout = false
loadDataFromPreferences()

var action = foregroundServiceStatus.action
val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this)

if (action == ForegroundServiceAction.API_STOP) {
RestartReceiver.cancelRestartAlarm(this)
stopForegroundService()
return START_NOT_STICKY
}

if (intent == null) {
ForegroundServiceStatus.setData(this, ForegroundServiceAction.RESTART)
foregroundServiceStatus = ForegroundServiceStatus.getData(this)
action = foregroundServiceStatus.action
}

when (action) {
ForegroundServiceAction.API_START,
ForegroundServiceAction.API_RESTART -> {
startForegroundService()
createForegroundTask()
try {
if (intent == null) {
ForegroundServiceStatus.setData(this, ForegroundServiceAction.RESTART)
foregroundServiceStatus = ForegroundServiceStatus.getData(this)
action = foregroundServiceStatus.action
}
ForegroundServiceAction.API_UPDATE -> {
updateNotification()
val prevCallbackHandle = prevForegroundTaskData?.callbackHandle
val currCallbackHandle = foregroundTaskData.callbackHandle
if (prevCallbackHandle != currCallbackHandle) {

when (action) {
ForegroundServiceAction.API_START,
ForegroundServiceAction.API_RESTART -> {
startForegroundService()
createForegroundTask()
} else {
val prevEventAction = prevForegroundTaskOptions?.eventAction
val currEventAction = foregroundTaskOptions.eventAction
if (prevEventAction != currEventAction) {
updateForegroundTask()
}
}
}
ForegroundServiceAction.REBOOT,
ForegroundServiceAction.RESTART -> {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
try {
startForegroundService()
ForegroundServiceAction.API_UPDATE -> {
updateNotification()
val prevCallbackHandle = prevForegroundTaskData?.callbackHandle
val currCallbackHandle = foregroundTaskData.callbackHandle
if (prevCallbackHandle != currCallbackHandle) {
createForegroundTask()
} catch (e: ForegroundServiceStartNotAllowedException) {
Log.e(TAG,
"Cannot run service as foreground: $e for notification channel ")
RestartReceiver.cancelRestartAlarm(this)
stopForegroundService()
} else {
val prevEventAction = prevForegroundTaskOptions?.eventAction
val currEventAction = foregroundTaskOptions.eventAction
if (prevEventAction != currEventAction) {
updateForegroundTask()
}
}
} else {
}
ForegroundServiceAction.REBOOT,
ForegroundServiceAction.RESTART -> {
startForegroundService()
createForegroundTask()
Log.d(TAG, "The service has been restarted by Android OS.")
}
Log.d(TAG, "The service has been restarted by Android OS.")
}
} catch (e: Exception) {
Log.e(TAG, e.message, e)
stopForegroundService()
}

return if (isSetStopWithTaskFlag) {
Expand All @@ -195,17 +187,17 @@ class ForegroundService : Service() {

override fun onDestroy() {
super.onDestroy()
destroyForegroundTask()
val isTimeout = this.isTimeout
destroyForegroundTask(isTimeout)
stopForegroundService()
unregisterBroadcastReceiver()

var isCorrectlyStopped = false
if (::foregroundServiceStatus.isInitialized) {
isCorrectlyStopped = foregroundServiceStatus.isCorrectlyStopped()
}
val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this)
if (!isCorrectlyStopped && !isSetStopWithTaskFlag) {
Log.e(TAG, "The service was terminated due to an unexpected problem. The service will restart after 5 seconds.")
if (!isCorrectlyStopped && !ForegroundServiceUtils.isSetStopWithTaskFlag(this)) {
Log.e(TAG, "The service will be restarted after 5 seconds because it wasn't properly stopped.")
RestartReceiver.setRestartAlarm(this, 5000)
}
}
Expand All @@ -221,13 +213,17 @@ class ForegroundService : Service() {

override fun onTimeout(startId: Int) {
super.onTimeout(startId)
isTimeout = true
stopForegroundService()
Log.e(TAG, "The service(id: $startId) timed out and was terminated by the system.")
}

@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
override fun onTimeout(startId: Int, fgsType: Int) {
super.onTimeout(startId, fgsType)
isTimeout = true
stopForegroundService()
Log.e(TAG, "The service(id: $startId) timed out and was terminated by the system.")
}

private fun loadDataFromPreferences() {
Expand Down Expand Up @@ -273,6 +269,8 @@ class ForegroundService : Service() {

@SuppressLint("WrongConstant", "SuspiciousIndentation")
private fun startForegroundService() {
RestartReceiver.cancelRestartAlarm(this)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel()
}
Expand All @@ -296,6 +294,8 @@ class ForegroundService : Service() {
}

private fun stopForegroundService() {
RestartReceiver.cancelRestartAlarm(this)

releaseLockMode()
stopForeground(true)
stopSelf()
Expand Down Expand Up @@ -480,8 +480,8 @@ class ForegroundService : Service() {
task?.update(taskEventAction = foregroundTaskOptions.eventAction)
}

private fun destroyForegroundTask() {
task?.destroy()
private fun destroyForegroundTask(isTimeout: Boolean = false) {
task?.destroy(isTimeout)
task = null
}

Expand Down
Loading