Skip to content

Commit

Permalink
Refactor -> Android native alarm service (#105)
Browse files Browse the repository at this point in the history
* Create android native alarm service

* Add multiple alarms management

* Add stop method & previous volume & fadeDuration

* Increment plugin version to 2.2.0

* Implement alarm isRinging method

* Add minor fixes

* Remove native notification

* Split alarm services

* Add onRing callback

* Fix NotificationOnKillService

* Optimize alarm services instances

* Make alarm foreground service

* Add full screen intent notification

* Add minor adjustments

* Handle immediate alarms

* Update android installation steps

* Remove stopOnNotificationOpen

* Update changelog & readme + add minor improvements

* Update pubspec

* Update version to 3.0.0-dev.1
  • Loading branch information
gdelataillade committed Nov 18, 2023
1 parent a8ef6aa commit 5e01be2
Show file tree
Hide file tree
Showing 26 changed files with 742 additions and 686 deletions.
20 changes: 15 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
## 3.0.0-dev.1
**💥 Breaking Changes**
**🔧 Android installation steps were updated [here](https://github.com/gdelataillade/alarm/blob/main/help/INSTALL-ANDROID.md).**
* Remove [stopOnNotificationOpen] property.
* Make notification mandatory so android foreground services can be used.
* [Android] Refactor alarm to native android services.

## 2.2.0
* [Android] Move alarm service to native code.

## 2.1.1
* Fix AlarmSettings.fromJson method with missing [androidFullScreenIntent].

## 2.1.0
**Android installation steps were updated.**
**🔧 Android installation steps were updated [here](https://github.com/gdelataillade/alarm/blob/main/help/INSTALL-ANDROID.md).**
* [Android] Add parameter [androidFullScreenIntent] that turns screen on when alarm notification is triggered.
* [Android] Fix 'ring now' alarm delay.
* [Android] Fix fadeDuration cast error.
Expand All @@ -14,7 +24,7 @@
* Refactor set alarm methods.

## 2.0.0
**Breaking Changes**
**💥 Breaking Changes**
* Installation steps were updated in the README. Please make sure to follow them.
* [iOS] Add Background Fetch to periodically make sure alarms are still active in the background.

Expand Down Expand Up @@ -131,7 +141,7 @@
* Add optional [vibrate] parameter, to toggle vibrations when alarm rings.

## 0.2.0
* **Breaking changes**: Add multiple alarm management. Now, you have to provide a unique [id] to [AlarmSettings].
* **💥 Breaking changes**: Add multiple alarm management. Now, you have to provide a unique [id] to [AlarmSettings].
* Update example application.
* [Android] Fix potential delay between notification and alarm sound.

Expand All @@ -155,8 +165,8 @@
* Export [AlarmSettings] model in [Alarm] service so it's not necessary to import it separately anymore.

## 0.1.0
* **Breaking changes**: [Alarm.set] method now takes a [AlarmSettings] as only parameter.
* **Breaking changes**: You will have to create a `StreamSubscription` attached to [Alarm.ringStream.stream] in order to listen to the alarm ringing state now. This way, even if your app was previously killed, your custom callback can still be triggered.
* **💥 Breaking changes**: [Alarm.set] method now takes a [AlarmSettings] as only parameter.
* **💥 Breaking changes**: You will have to create a `StreamSubscription` attached to [Alarm.ringStream.stream] in order to listen to the alarm ringing state now. This way, even if your app was previously killed, your custom callback can still be triggered.
* By default, if an alarm was set and the app is killed, a notification will be shown to warn
the user that the alarm may not ring, with the possibility to reopen the app and automatically reschedule the alarm.
To disable this feature, you can call the method [Alarm.toggleNotificationOnAppKill(false)].
Expand Down
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@

# Alarm plugin for iOS and Android

This Flutter plugin provides a simple and easy-to-use interface for setting and canceling alarms on iOS and Android devices. It utilizes the `android_alarm_manager_plus` plugin for Android and the native iOS `AVAudioPlayer` class.
This plugin offers a straightforward interface to set and cancel alarms on both iOS and Android devices. Using native code, it handles audio playback, vibrations, system volume, and notifications seamlessly.

## 🔧 Installation steps

Please carefully follow these installation steps. They have been updated for plugin version `2.0.0`.
Please carefully follow these installation steps. They have been updated for plugin version `3.0.0`.

### [iOS Setup](https://github.com/gdelataillade/alarm/blob/feat/ios-background-fetch/help/INSTALL-IOS.md)
### [Android Setup](https://github.com/gdelataillade/alarm/blob/feat/ios-background-fetch/help/INSTALL-ANDROID.md)
Expand Down Expand Up @@ -63,11 +63,8 @@ fadeDuration | `double` | Duration, in seconds, over which to fade the ala
notificationTitle | `String` | The title of the notification triggered when alarm rings if app is on background.
notificationBody | `String` | The body of the notification.
enableNotificationOnKill | `bool` | Whether to show a notification when application is killed to warn the user that the alarm he set may not ring. Enabled by default.
stopOnNotificationOpen | `bool` | Whether to stop the alarm when opening the received notification. Disabled by default.
androidFullScreenIntent | `bool` | Whether to turn screen on when android alarm notification is triggered. Enabled by default.

The notification shown on alarm ring can be disabled simply by ignoring the parameters `notificationTitle` and `notificationBody`. However, if you want a notification to be triggered, you will have to provide **both of them**.

If you enabled `enableNotificationOnKill`, you can chose your own notification title and body by using this method before setting your alarms:
```Dart
await Alarm.setNotificationOnAppKillContent(title, body)
Expand Down Expand Up @@ -101,9 +98,11 @@ Don't hesitate to check out the example's code, and take a look at the app:
| Do not disturb | ✅ | ✅ | ✅ | Silenced
| Sleep mode | ✅ | ✅ | ✅ | Silenced
| While playing other media| ✅ | ✅ | ✅ | ✅
| App killed | | | | ✅
| App killed | 🤖 | 🤖 | 🤖 | ✅

*Silenced: Means that the notification is not shown directly on the top of the screen. You have to go in your notification center to see it.*
✅ : iOS and Android
🤖 : Android only.
Silenced: Means that the notification is not shown directly on the top of the screen. You have to go in your notification center to see it.

## ❓ FAQ

Expand All @@ -123,14 +122,15 @@ Most common solution is to educate users to disable **battery optimization** set

The more time the app spends in the background, the higher the chance the OS might stop it from running due to memory or battery optimizations. Here's how you can optimize:

- **Battery Optimization**: Educate users to disable battery optimization on Android.
- **Regular App Usage**: Encourage users to open the app at least once a day.
- **Leverage Background Modes**: Engage in activities like weather API calls that keep the app active in the background.
- **User Settings**: Educate users to refrain from using 'Do Not Disturb' (DnD) and 'Low Power Mode' when they're expecting the alarm to ring.
- **User Settings**: Educate users to refrain from using 'Do Not Disturb' and 'Low Power Mode' when they're expecting the alarm to ring.

## ⚙️ Under the hood

### Android
Uses `oneShotAt` from the `android_alarm_manager_plus` plugin with a two-way communication isolated callback to start/stop the alarm.
Leverages a foreground service with AlarmManager scheduling to ensure alarm reliability, even if the app is terminated. Utilizes AudioManager for robust alarm sound management.

### iOS
Keeps the app awake using a silent `AVAudioPlayer` until alarm rings. When in the background, it also uses `Background App Refresh` to periodically ensure the app is still active.
Expand All @@ -156,4 +156,8 @@ These are some features that I have in mind that could be useful:

Thank you for considering contributing to this plugin. Your help is greatly appreciated!

❤️ Let me know if you like the plugin by liking it on [pub.dev](https://pub.dev/packages/alarm) and starring the repo on [Github](https://github.com/gdelataillade/alarm) 🙂
🙏 Special thanks to the main contributors 🇫🇷
- [evolum](https://evolum.co)
- [WayUp](https://wayuphealth.fr)

❤️ Let me know if you like the plugin by liking it on [pub.dev](https://pub.dev/packages/alarm) and starring the repo on [Github](https://github.com/gdelataillade/alarm) 🙂
24 changes: 5 additions & 19 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,11 @@
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application>
<service
android:name=".NotificationOnKillService"
android:enabled="true"
android:exported="false"/>
<service
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmService"
android:permission="android.permission.BIND_JOB_SERVICE"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.AlarmBroadcastReceiver"
android:exported="false"/>
<receiver
android:name="dev.fluttercommunity.plus.androidalarmmanager.RebootBroadcastReceiver"
android:enabled="false"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<receiver android:name=".AlarmReceiver" />
<service android:name=".AlarmService" />
</application>
</manifest>
150 changes: 118 additions & 32 deletions android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt
Original file line number Diff line number Diff line change
@@ -1,52 +1,138 @@
package com.gdelataillade.alarm.alarm

import com.gdelataillade.alarm.services.NotificationOnKillService

import android.os.Build
import android.os.Handler
import android.os.Looper
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.annotation.NonNull
import io.flutter.Log
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.Log

/// Communication between Flutter Alarm service and native Android.
class AlarmPlugin: FlutterPlugin, MethodCallHandler {
private lateinit var context: Context
private lateinit var channel : MethodChannel
private lateinit var context: Context
private lateinit var channel : MethodChannel

override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.gdelataillade.alarm/notifOnAppKill")
channel.setMethodCallHandler(this)
}
companion object {
@JvmStatic
lateinit var binaryMessenger: BinaryMessenger
}

override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"setNotificationOnKillService" -> {
val title = call.argument<String>("title")
val description = call.argument<String>("description")
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
context = flutterPluginBinding.applicationContext
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.gdelataillade.alarm/alarm")
channel.setMethodCallHandler(this)
binaryMessenger = flutterPluginBinding.binaryMessenger
}

val serviceIntent = Intent(context, NotificationOnKillService::class.java)
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
when (call.method) {
"setAlarm" -> {
val id = call.argument<Int>("id")!!
val delayInSeconds = call.argument<Int>("delayInSeconds")!!

serviceIntent.putExtra("title", title)
serviceIntent.putExtra("description", description)
val alarmIntent = createAlarmIntent(context, call, id)

context.startService(serviceIntent)
result.success(true)
}
"stopNotificationOnKillService" -> {
val serviceIntent = Intent(context, NotificationOnKillService::class.java)
context.stopService(serviceIntent)
result.success(true)
}
else -> {
result.notImplemented()
if (delayInSeconds <= 5) {
handleImmediateAlarm(context, alarmIntent, delayInSeconds)
} else {
handleDelayedAlarm(context, alarmIntent, delayInSeconds, id)
}

result.success(true)
}
"stopAlarm" -> {
val id = call.argument<Int>("id")

// Intent to stop the alarm
val stopIntent = Intent(context, AlarmService::class.java)
stopIntent.action = "STOP_ALARM"
stopIntent.putExtra("id", id)
context.startService(stopIntent)

// Intent to cancel the future alarm if it's set
val alarmIntent = Intent(context, AlarmReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(context, id!!, alarmIntent, PendingIntent.FLAG_UPDATE_CURRENT)

// Cancel the future alarm using AlarmManager
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
alarmManager.cancel(pendingIntent)

result.success(true)
}
"isRinging" -> {
val id = call.argument<Int>("id")
val ringingAlarmIds = AlarmService.ringingAlarmIds
val isRinging = ringingAlarmIds.contains(id)
result.success(isRinging)
}
"setNotificationOnKillService" -> {
val title = call.argument<String>("title")
val description = call.argument<String>("description")
val body = call.argument<String>("body")

val serviceIntent = Intent(context, NotificationOnKillService::class.java)
serviceIntent.putExtra("title", title)
serviceIntent.putExtra("description", description)

context.startService(serviceIntent)

result.success(true)
}
"stopNotificationOnKillService" -> {
val serviceIntent = Intent(context, NotificationOnKillService::class.java)
context.stopService(serviceIntent)
result.success(true)
}
else -> {
result.notImplemented()
}
}
}
}

override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
fun createAlarmIntent(context: Context, call: MethodCall, id: Int?): Intent {
val alarmIntent = Intent(context, AlarmReceiver::class.java)
setIntentExtras(alarmIntent, call, id)
return alarmIntent
}

fun setIntentExtras(intent: Intent, call: MethodCall, id: Int?) {
intent.putExtra("id", id)
intent.putExtra("assetAudioPath", call.argument<String>("assetAudioPath"))
intent.putExtra("loopAudio", call.argument<Boolean>("loopAudio"))
intent.putExtra("vibrate", call.argument<Boolean>("vibrate"))
intent.putExtra("volume", call.argument<Boolean>("volume"))
intent.putExtra("fadeDuration", call.argument<Double>("fadeDuration"))
intent.putExtra("notificationTitle", call.argument<String>("notificationTitle"))
intent.putExtra("notificationBody", call.argument<String>("notificationBody"))
intent.putExtra("fullScreenIntent", call.argument<Boolean>("fullScreenIntent"))
}

fun handleImmediateAlarm(context: Context, intent: Intent, delayInSeconds: Int) {
val handler = Handler(Looper.getMainLooper())
handler.postDelayed({
context.sendBroadcast(intent)
}, delayInSeconds * 1000L)
}

fun handleDelayedAlarm(context: Context, intent: Intent, delayInSeconds: Int, id: Int) {
val triggerTime = System.currentTimeMillis() + delayInSeconds * 1000
val pendingIntent = PendingIntent.getBroadcast(context, id, intent, PendingIntent.FLAG_UPDATE_CURRENT)
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager

alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent)
}

override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.gdelataillade.alarm.alarm

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import io.flutter.Log

class AlarmReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val serviceIntent = Intent(context, AlarmService::class.java)
serviceIntent.putExtras(intent)

context.startService(serviceIntent)
}
}
Loading

0 comments on commit 5e01be2

Please sign in to comment.