Skip to content

Commit

Permalink
Add stop method & previous volume & fadeDuration
Browse files Browse the repository at this point in the history
  • Loading branch information
gdelataillade committed Nov 1, 2023
1 parent 9436aeb commit b5b4f25
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 238 deletions.
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,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 @@ -125,12 +127,12 @@ The more time the app spends in the background, the higher the chance the OS mig

- **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 Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class AlarmPlugin: FlutterPlugin, MethodCallHandler {

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

Expand Down Expand Up @@ -50,10 +50,21 @@ class AlarmPlugin: FlutterPlugin, MethodCallHandler {
}
"stopAlarm" -> {
val id = call.argument<Int>("id")

// Intent to stop the alarm if it's currently ringing
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)
}
"setNotificationOnKillService" -> {
Expand Down
115 changes: 100 additions & 15 deletions android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,22 @@ import android.content.Intent
import android.media.MediaPlayer
import android.media.AudioManager
import android.media.AudioManager.FLAG_SHOW_UI
import android.provider.Settings
import android.os.*
import androidx.core.app.NotificationCompat
import io.flutter.Log
import io.flutter.plugin.common.MethodChannel
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import kotlin.math.round
import java.util.Timer
import java.util.TimerTask

class AlarmService : Service() {
private val mediaPlayers = mutableMapOf<Int, MediaPlayer>()
private var vibrator: Vibrator? = null
private var previousVolume: Int? = null
private var showSystemUI: Boolean = true
private val CHANNEL_ID = "AlarmServiceChannel"

override fun onCreate() {
Expand All @@ -39,7 +47,7 @@ class AlarmService : Service() {
val fadeDuration = intent?.getDoubleExtra("fadeDuration", 0.0)
val notificationTitle = intent?.getStringExtra("notificationTitle")
val notificationBody = intent?.getStringExtra("notificationBody")
val showSystemUI = true
showSystemUI = intent?.getBooleanExtra("showSystemUI", true) ?: true

Log.d("AlarmService", "id: $id")
Log.d("AlarmService", "assetAudioPath: $assetAudioPath")
Expand All @@ -50,7 +58,28 @@ class AlarmService : Service() {
Log.d("AlarmService", "notificationTitle: $notificationTitle")
Log.d("AlarmService", "notificationBody: $notificationBody")

// Create a new FlutterEngine instance.
val flutterEngine = FlutterEngine(this)

// Start executing Dart code to prepare for method channel communication.
flutterEngine.dartExecutor.executeDartEntrypoint(
DartExecutor.DartEntrypoint.createDefault()
)

val flutterChannel = MethodChannel(flutterEngine?.dartExecutor, "com.gdelataillade.alarm/alarm")
flutterChannel.invokeMethod("alarmRinging", mapOf("id" to id))

if (notificationTitle != null && notificationBody != null) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val areNotificationsEnabled = manager.areNotificationsEnabled()

if (!areNotificationsEnabled) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
startActivity(intent)
}

val iconResId = applicationContext.resources.getIdentifier("ic_launcher", "mipmap", applicationContext.packageName)
val intent = applicationContext.packageManager.getLaunchIntentForPackage(applicationContext.packageName)
val pendingIntent = PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
Expand All @@ -60,7 +89,7 @@ class AlarmService : Service() {
.setContentText(notificationBody)
.setSmallIcon(iconResId)
.setContentIntent(pendingIntent)
.setAutoCancel(true) // Automatically remove the notification when tapped
.setAutoCancel(true)
.build()

startForeground(id!!, notification)
Expand All @@ -70,8 +99,8 @@ class AlarmService : Service() {

if (volume != -1.0) {
val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC)
previousVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) // Save the previous volume
val _volume = (round(volume * maxVolume)).toInt()

audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, _volume, if (showSystemUI) FLAG_SHOW_UI else 0)
}

Expand All @@ -96,6 +125,9 @@ class AlarmService : Service() {
// Store MediaPlayer instance in map
mediaPlayers[id] = mediaPlayer

if (fadeDuration != null && fadeDuration > 0) {
startFadeIn(mediaPlayer, fadeDuration)
}
} catch (e: Exception) {
// Handle exceptions related to asset loading or MediaPlayer
e.printStackTrace()
Expand All @@ -119,23 +151,37 @@ class AlarmService : Service() {
.newWakeLock(PowerManager.FULL_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP, "app:AlarmWakelockTag")
wakeLock.acquire(5 * 60 * 1000L /*5 minutes*/)

Log.d("AlarmService => SET ALARM", "Current mediaPlayers keys: ${mediaPlayers.keys}")

return START_STICKY
}

fun stopAlarm(id: Int) {
mediaPlayers[id]?.stop()
mediaPlayers[id]?.release()
mediaPlayers.remove(id)

// Abandon audio focus
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.abandonAudioFocus(null)
Log.d("AlarmService => STOP ALARM", "id: $id")
Log.d("AlarmService => STOP ALARM", "Current mediaPlayers keys: ${mediaPlayers.keys}")
Log.d("AlarmService => STOP ALARM", "previousVolume: $previousVolume")
previousVolume?.let { prevVolume ->
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, prevVolume, if (showSystemUI) FLAG_SHOW_UI else 0)
previousVolume = null // Reset the previous volume
}

// Check if there are no more active alarms
if (mediaPlayers.isEmpty()) {
vibrator?.cancel()
stopForeground(true)
stopSelf()
if (mediaPlayers.containsKey(id)) {
Log.d("AlarmService => STOP ALARM", "Stopping MediaPlayer with id: $id")
mediaPlayers[id]?.stop()
mediaPlayers[id]?.release()
mediaPlayers.remove(id)

// Abandon audio focus
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.abandonAudioFocus(null)

// Check if there are no more active alarms
if (mediaPlayers.isEmpty()) {
vibrator?.cancel()
stopForeground(true)
stopSelf()
}
}
}

Expand All @@ -152,13 +198,52 @@ class AlarmService : Service() {
}
}

private fun startFadeIn(mediaPlayer: MediaPlayer, duration: Double) {
val maxVolume = 1.0f // Use 1.0f for MediaPlayer's max volume
val fadeDuration = (duration * 1000).toLong() // Convert seconds to milliseconds
val fadeInterval = 100L // Interval for volume increment
val numberOfSteps = fadeDuration / fadeInterval // Number of volume increments
val deltaVolume = maxVolume / numberOfSteps // Volume increment per step

val timer = Timer(true) // Use a daemon thread
var volume = 0.0f

val timerTask = object : TimerTask() {
override fun run() {
mediaPlayer.setVolume(volume, volume) // Set volume for both channels
volume += deltaVolume

if (volume >= maxVolume) {
mediaPlayer.setVolume(maxVolume, maxVolume) // Ensure max volume is set
this.cancel() // Cancel the timer
}
}
}

timer.schedule(timerTask, 0, fadeInterval)
}

override fun onDestroy() {
// Clean up MediaPlayer resources
mediaPlayers.values.forEach {
it.stop()
it.release()
}
mediaPlayers.clear()

// Cancel any ongoing vibration
vibrator?.cancel()

// Restore system volume if it was changed
val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
previousVolume?.let { prevVolume ->
audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, prevVolume, if (showSystemUI) FLAG_SHOW_UI else 0)
}

// Stop the foreground service and remove the notification
stopForeground(true)

// Call the superclass method
super.onDestroy()
}

Expand Down
2 changes: 1 addition & 1 deletion help/INSTALL-ANDROID.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ android {
```

## Step 2
Then, add the following to your `AndroidManifest.xml` within the `<manifest></manifest>` tags:
Then, add the following permissions to your `AndroidManifest.xml` within the `<manifest></manifest>` tags:

```xml
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
Expand Down
5 changes: 2 additions & 3 deletions lib/alarm.dart
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,8 @@ class Alarm {
final now = DateTime.now();
if (alarm.dateTime.isAfter(now)) {
await set(alarmSettings: alarm);
} else {
await AlarmStorage.unsaveAlarm(alarm.id);
}
// TODO: Fix this
}
}

Expand Down Expand Up @@ -151,7 +150,7 @@ class Alarm {

/// Whether the alarm is ringing.
static Future<bool> isRinging(int id) async =>
iOS ? await IOSAlarm.checkIfRinging(id) : AndroidAlarm.isRinging;
iOS ? await IOSAlarm.checkIfRinging(id) : false; // TODO: Android !

/// Whether an alarm is set.
static bool hasAlarm() => AlarmStorage.hasAlarm();
Expand Down
2 changes: 2 additions & 0 deletions lib/service/storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ class AlarmStorage {
}
}

print("Saved alarms: ${alarms.length}");

return alarms;
}

Expand Down
Loading

0 comments on commit b5b4f25

Please sign in to comment.