diff --git a/analysis_options.yaml b/analysis_options.yaml index 670d9396..31132456 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1 +1,6 @@ -include: package:very_good_analysis/analysis_options.yaml \ No newline at end of file +include: package:very_good_analysis/analysis_options.yaml + +analyzer: + exclude: [lib/src/generated/**] + errors: + todo: ignore diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt index 77ee0f28..230df040 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt @@ -1,254 +1,22 @@ package com.gdelataillade.alarm.alarm -import com.gdelataillade.alarm.services.NotificationOnKillService -import com.gdelataillade.alarm.services.AlarmStorage -import com.gdelataillade.alarm.models.AlarmSettings - -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 java.util.Date +import AlarmApi +import AlarmTriggerApi +import com.gdelataillade.alarm.api.AlarmApiImpl import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.plugin.common.EventChannel -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.Log -import com.google.gson.Gson - -class AlarmPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { - private lateinit var context: Context - private lateinit var methodChannel: MethodChannel - private lateinit var eventChannel: EventChannel - - private val alarmIds: MutableList = mutableListOf() - private var notifOnKillEnabled: Boolean = false - private var notificationOnKillTitle: String = "Your alarms may not ring" - private var notificationOnKillBody: String = "You killed the app. Please reopen so your alarms can be rescheduled." +class AlarmPlugin : FlutterPlugin { companion object { @JvmStatic - var eventSink: EventChannel.EventSink? = null - } - - override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { - context = flutterPluginBinding.applicationContext - - methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "com.gdelataillade.alarm/alarm") - methodChannel.setMethodCallHandler(this) - - eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "com.gdelataillade.alarm/events") - eventChannel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink) { - eventSink = events - } - - override fun onCancel(arguments: Any?) { - eventSink = null - } - }) - } - - override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: MethodChannel.Result) { - when (call.method) { - "setAlarm" -> { - setAlarm(call, result) - } - "stopAlarm" -> { - val id = call.argument("id") - if (id == null) { - result.error("INVALID_ID", "Alarm ID is null", null) - return - } - - stopAlarm(id, result) - } - "isRinging" -> { - val id = call.argument("id") - val ringingAlarmIds = AlarmService.ringingAlarmIds - val isRinging = if (id == null) { - ringingAlarmIds.isNotEmpty() - } else { - ringingAlarmIds.contains(id) - } - result.success(isRinging) - } - "setWarningNotificationOnKill" -> { - val title = call.argument("title") - val body = call.argument("body") - if (title != null && body != null) { - notificationOnKillTitle = title - notificationOnKillBody = body - } - result.success(true) - } - "disableWarningNotificationOnKill" -> { - disableWarningNotificationOnKill(context) - result.success(true) - } - else -> { - result.notImplemented() - } - } - } - - fun setAlarm(call: MethodCall, result: MethodChannel.Result, customContext: Context? = null) { - val alarmJsonMap = call.arguments as? Map - val contextToUse = customContext ?: context - - if (alarmJsonMap != null) { - try { - val gson = Gson() - val alarmJsonString = gson.toJson(alarmJsonMap) - val alarm = AlarmSettings.fromJson(alarmJsonString) - - if (alarm != null) { - val alarmIntent = createAlarmIntent(contextToUse, call, alarm.id) - val delayInSeconds = (alarm.dateTime.time - System.currentTimeMillis()) / 1000 - - if (delayInSeconds <= 5) { - handleImmediateAlarm(contextToUse, alarmIntent, delayInSeconds.toInt()) - } else { - handleDelayedAlarm( - contextToUse, - alarmIntent, - delayInSeconds.toInt(), - alarm.id, - alarm.warningNotificationOnKill - ) - } - alarmIds.add(alarm.id) - result.success(true) - } else { - result.error("INVALID_ALARM", "Failed to parse alarm JSON", null) - } - } catch (e: Exception) { - Log.e("AlarmPlugin", "Error parsing alarmJsonMap: ${e.message}", e) - result.error("PARSE_ERROR", "Error parsing alarmJsonMap", e.message) - } - } else { - result.error("INVALID_ARGUMENTS", "Invalid arguments provided for setAlarm", null) - } - } - - fun stopAlarm(id: Int, result: MethodChannel.Result? = null) { - if (AlarmService.ringingAlarmIds.contains(id)) { - val stopIntent = Intent(context, AlarmService::class.java) - stopIntent.action = "STOP_ALARM" - stopIntent.putExtra("id", id) - context.stopService(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 or PendingIntent.FLAG_IMMUTABLE - ) - - // Cancel the future alarm using AlarmManager - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.cancel(pendingIntent) - - alarmIds.remove(id) - if (alarmIds.isEmpty() && notifOnKillEnabled) { - disableWarningNotificationOnKill(context) - } - - result?.success(true) - } - - 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("assetAudioPath")) - intent.putExtra("loopAudio", call.argument("loopAudio") ?: true) - intent.putExtra("vibrate", call.argument("vibrate") ?: true) - intent.putExtra("volume", call.argument("volume")) - intent.putExtra("volumeEnforced", call.argument("volumeEnforced") ?: false) - intent.putExtra("fadeDuration", call.argument("fadeDuration") ?: 0.0) - intent.putExtra("fullScreenIntent", call.argument("androidFullScreenIntent") ?: true) - - val notificationSettingsMap = call.argument>("notificationSettings") - val gson = Gson() - val notificationSettingsJson = gson.toJson(notificationSettingsMap ?: emptyMap()) - intent.putExtra("notificationSettings", notificationSettingsJson) - } - - 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, - warningNotificationOnKill: Boolean - ) { - try { - val triggerTime = System.currentTimeMillis() + delayInSeconds * 1000L - val pendingIntent = PendingIntent.getBroadcast( - context, - id, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager - ?: throw IllegalStateException("AlarmManager not available") - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) - } else { - alarmManager.set(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) - } - - if (warningNotificationOnKill && !notifOnKillEnabled) { - setWarningNotificationOnKill(context) - } - } catch (e: ClassCastException) { - Log.e("AlarmPlugin", "AlarmManager service type casting failed", e) - } catch (e: IllegalStateException) { - Log.e("AlarmPlugin", "AlarmManager service not available", e) - } catch (e: Exception) { - Log.e("AlarmPlugin", "Error in handling delayed alarm", e) - } - } - - fun setWarningNotificationOnKill(context: Context) { - val serviceIntent = Intent(context, NotificationOnKillService::class.java) - serviceIntent.putExtra("title", notificationOnKillTitle) - serviceIntent.putExtra("body", notificationOnKillBody) - - context.startService(serviceIntent) - notifOnKillEnabled = true + var alarmTriggerApi: AlarmTriggerApi? = null } - fun disableWarningNotificationOnKill(context: Context) { - val serviceIntent = Intent(context, NotificationOnKillService::class.java) - context.stopService(serviceIntent) - notifOnKillEnabled = false + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + AlarmApi.setUp(binding.binaryMessenger, AlarmApiImpl(binding.applicationContext)) + alarmTriggerApi = AlarmTriggerApi(binding.binaryMessenger) } - override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { - methodChannel.setMethodCallHandler(null) - eventChannel.setStreamHandler(null) + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + alarmTriggerApi = null } -} \ No newline at end of file +} diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmReceiver.kt b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmReceiver.kt index 2fee1184..71e75d2d 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmReceiver.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmReceiver.kt @@ -5,7 +5,6 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.Build -import io.flutter.Log class AlarmReceiver : BroadcastReceiver() { companion object { @@ -24,7 +23,12 @@ class AlarmReceiver : BroadcastReceiver() { serviceIntent.putExtras(intent) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val pendingIntent = PendingIntent.getForegroundService(context, 1, serviceIntent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT) + val pendingIntent = PendingIntent.getForegroundService( + context, + 1, + serviceIntent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT + ) pendingIntent.send() } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.startForegroundService(serviceIntent) diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmService.kt b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmService.kt index fe2c864a..3f0f4f34 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmService.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmService.kt @@ -10,16 +10,15 @@ import com.google.gson.Gson import android.app.Service import android.app.PendingIntent import android.app.ForegroundServiceStartNotAllowedException +import android.app.Notification import android.content.Intent import android.content.Context import android.content.pm.ServiceInfo import android.os.IBinder import android.os.PowerManager import android.os.Build +import com.gdelataillade.alarm.services.NotificationHandler import io.flutter.Log -import io.flutter.embedding.engine.dart.DartExecutor -import io.flutter.embedding.engine.FlutterEngine -import org.json.JSONObject class AlarmService : Service() { private var audioService: AudioService? = null @@ -56,7 +55,8 @@ class AlarmService : Service() { // Build the notification val notificationHandler = NotificationHandler(this) - val appIntent = applicationContext.packageManager.getLaunchIntentForPackage(applicationContext.packageName) + val appIntent = + applicationContext.packageManager.getLaunchIntentForPackage(applicationContext.packageName) val pendingIntent = PendingIntent.getActivity( this, id, @@ -84,14 +84,16 @@ class AlarmService : Service() { // Start the service in the foreground try { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - startForeground(id, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + try { + startAlarmService(id, notification) + } catch (e: ForegroundServiceStartNotAllowedException) { + Log.e("AlarmService", "Foreground service start not allowed", e) + return START_NOT_STICKY + } } else { - startForeground(id, notification) + startAlarmService(id, notification) } - } catch (e: ForegroundServiceStartNotAllowedException) { - Log.e("AlarmService", "Foreground service start not allowed", e) - return START_NOT_STICKY } catch (e: SecurityException) { Log.e("AlarmService", "Security exception in starting foreground service", e) return START_NOT_STICKY @@ -113,15 +115,12 @@ class AlarmService : Service() { val fadeDuration = intent.getDoubleExtra("fadeDuration", 0.0) // Notify the plugin about the alarm ringing - AlarmPlugin.eventSink?.success( - mapOf( - "id" to id, - "method" to "ring" - ) - ) + AlarmPlugin.alarmTriggerApi?.alarmRang(id.toLong()) { + Log.d("AlarmService", "Flutter was notified that alarm $id is ringing.") + } // Set the volume if specified - if (volume >= 0.0 && volume <= 1.0) { + if (volume in 0.0..1.0) { volumeService?.setVolume(volume, volumeEnforced, showSystemUI) } @@ -156,16 +155,27 @@ class AlarmService : Service() { return START_STICKY } - fun unsaveAlarm(id: Int) { + private fun startAlarmService(id: Int, notification: Notification) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + id, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK + ) + } else { + startForeground(id, notification) + } + } + + private fun unsaveAlarm(id: Int) { AlarmStorage(this).unsaveAlarm(id) - AlarmPlugin.eventSink?.success(mapOf( - "id" to id, - "method" to "stop" - )) + AlarmPlugin.alarmTriggerApi?.alarmStopped(id.toLong()) { + Log.d("AlarmService", "Flutter was notified that alarm $id was stopped.") + } stopAlarm(id) } - fun stopAlarm(id: Int) { + private fun stopAlarm(id: Int) { try { val playingIds = audioService?.getPlayingMediaPlayersIds() ?: listOf() ringingAlarmIds = playingIds diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/BootReceiver.kt b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/BootReceiver.kt index 0aa18a44..e035172d 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/BootReceiver.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/BootReceiver.kt @@ -4,17 +4,8 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log -import com.gdelataillade.alarm.alarm.AlarmPlugin import com.gdelataillade.alarm.services.AlarmStorage -import com.google.gson.Gson -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel - -import android.app.NotificationChannel -import android.app.NotificationManager -import android.os.Build -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat +import com.gdelataillade.alarm.api.AlarmApiImpl class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -28,62 +19,20 @@ class BootReceiver : BroadcastReceiver() { private fun rescheduleAlarms(context: Context) { val alarmStorage = AlarmStorage(context) val storedAlarms = alarmStorage.getSavedAlarms() - - Log.d("BootReceiver", "Rescheduling ${storedAlarms.size} alarms") - + + Log.i("BootReceiver", "Rescheduling ${storedAlarms.size} alarms") + for (alarm in storedAlarms) { - if (alarm.notificationSettings == null) { - Log.d("BootReceiver", "Skipping alarm with ID: ${alarm.id} due to missing notificationSettings") - continue - } - - var alarmArgs: Map? = null - try { - // Create the arguments for the MethodCall - alarmArgs = mapOf( - "id" to alarm.id, - "dateTime" to alarm.dateTime.time, - "assetAudioPath" to (alarm.assetAudioPath ?: ""), - "loopAudio" to alarm.loopAudio, - "vibrate" to alarm.vibrate, - "fadeDuration" to alarm.fadeDuration, - "fullScreenIntent" to alarm.androidFullScreenIntent, - "notificationSettings" to mapOf( - "title" to alarm.notificationSettings.title, - "body" to alarm.notificationSettings.body, - "stopButton" to alarm.notificationSettings.stopButton, - "icon" to alarm.notificationSettings.icon - ) - ).toMutableMap() - - alarm.volume?.let { - (alarmArgs as MutableMap)[ "volume" ] = it - } - Log.d("BootReceiver", "Rescheduling alarm with ID: ${alarm.id}") - Log.d("BootReceiver", "Alarm arguments: $alarmArgs") - - // Simulate the MethodCall - val methodCall = MethodCall("setAlarm", alarmArgs) - + Log.d("BootReceiver", "Alarm details: $alarm") + // Call the setAlarm method in AlarmPlugin with the custom context - val alarmPlugin = AlarmPlugin() - alarmPlugin.setAlarm(methodCall, object : MethodChannel.Result { - override fun success(result: Any?) { - Log.d("BootReceiver", "Alarm rescheduled successfully for ID: ${alarm.id}") - } - - override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) { - Log.e("BootReceiver", "Failed to reschedule alarm for ID: ${alarm.id}, Error: $errorMessage") - } - - override fun notImplemented() { - Log.e("BootReceiver", "Method not implemented") - } - }, context) + val alarmApi = AlarmApiImpl(context) + alarmApi.setAlarm(alarm) + Log.d("BootReceiver", "Alarm rescheduled successfully for ID: ${alarm.id}") } catch (e: Exception) { - Log.e("BootReceiver", "Exception while rescheduling alarm with arguments: $alarmArgs", e) + Log.e("BootReceiver", "Exception while rescheduling alarm: $alarm", e) } } } diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/api/AlarmApiImpl.kt b/android/src/main/kotlin/com/gdelataillade/alarm/api/AlarmApiImpl.kt new file mode 100644 index 00000000..f3758740 --- /dev/null +++ b/android/src/main/kotlin/com/gdelataillade/alarm/api/AlarmApiImpl.kt @@ -0,0 +1,177 @@ +package com.gdelataillade.alarm.api + +import AlarmApi +import AlarmSettingsWire +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Handler +import android.os.Looper +import com.gdelataillade.alarm.alarm.AlarmReceiver +import com.gdelataillade.alarm.alarm.AlarmService +import com.gdelataillade.alarm.models.AlarmSettings +import com.gdelataillade.alarm.services.NotificationOnKillService +import com.google.gson.Gson +import io.flutter.Log + +class AlarmApiImpl(private val context: Context) : AlarmApi { + private val alarmIds: MutableList = mutableListOf() + private var notifyOnKillEnabled: Boolean = false + private var notificationOnKillTitle: String = "Your alarms may not ring" + private var notificationOnKillBody: String = + "You killed the app. Please reopen so your alarms can be rescheduled." + + override fun setAlarm(alarmSettings: AlarmSettingsWire) { + setAlarm(AlarmSettings.fromWire(alarmSettings)) + } + + override fun stopAlarm(alarmId: Long) { + val id = alarmId.toInt() + if (AlarmService.ringingAlarmIds.contains(id)) { + val stopIntent = Intent(context, AlarmService::class.java) + stopIntent.action = "STOP_ALARM" + stopIntent.putExtra("id", id) + context.stopService(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 or PendingIntent.FLAG_IMMUTABLE + ) + + // Cancel the future alarm using AlarmManager + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(pendingIntent) + + alarmIds.remove(id) + if (alarmIds.isEmpty() && notifyOnKillEnabled) { + disableWarningNotificationOnKill(context) + } + } + + override fun isRinging(alarmId: Long?): Boolean { + val ringingAlarmIds = AlarmService.ringingAlarmIds + if (alarmId == null) { + return ringingAlarmIds.isNotEmpty() + } + return ringingAlarmIds.contains(alarmId.toInt()) + } + + override fun setWarningNotificationOnKill(title: String, body: String) { + notificationOnKillTitle = title + notificationOnKillBody = body + } + + override fun disableWarningNotificationOnKill() { + disableWarningNotificationOnKill(context) + } + + fun setAlarm(alarm: AlarmSettings) { + val alarmIntent = createAlarmIntent(alarm) + val delayInSeconds = (alarm.dateTime.time - System.currentTimeMillis()) / 1000 + + if (delayInSeconds <= 5) { + handleImmediateAlarm(alarmIntent, delayInSeconds.toInt()) + } else { + handleDelayedAlarm( + alarmIntent, + delayInSeconds.toInt(), + alarm.id, + alarm.warningNotificationOnKill + ) + } + alarmIds.add(alarm.id) + } + + private fun createAlarmIntent(alarm: AlarmSettings): Intent { + val alarmIntent = Intent(context, AlarmReceiver::class.java) + setIntentExtras(alarmIntent, alarm) + return alarmIntent + } + + private fun setIntentExtras(intent: Intent, alarm: AlarmSettings) { + intent.putExtra("id", alarm.id) + intent.putExtra("assetAudioPath", alarm.assetAudioPath) + intent.putExtra("loopAudio", alarm.loopAudio) + intent.putExtra("vibrate", alarm.vibrate) + intent.putExtra("volume", alarm.volume) + intent.putExtra("volumeEnforced", alarm.volumeEnforced) + intent.putExtra("fadeDuration", alarm.fadeDuration) + intent.putExtra("fullScreenIntent", alarm.androidFullScreenIntent) + + val notificationSettingsMap = alarm.notificationSettings + val gson = Gson() + val notificationSettingsJson = gson.toJson(notificationSettingsMap) + intent.putExtra("notificationSettings", notificationSettingsJson) + } + + private fun handleImmediateAlarm(intent: Intent, delayInSeconds: Int) { + val handler = Handler(Looper.getMainLooper()) + handler.postDelayed({ + context.sendBroadcast(intent) + }, delayInSeconds * 1000L) + } + + private fun handleDelayedAlarm( + intent: Intent, + delayInSeconds: Int, + id: Int, + warningNotificationOnKill: Boolean + ) { + try { + val triggerTime = System.currentTimeMillis() + delayInSeconds * 1000L + val pendingIntent = PendingIntent.getBroadcast( + context, + id, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager + ?: throw IllegalStateException("AlarmManager not available") + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setExactAndAllowWhileIdle( + AlarmManager.RTC_WAKEUP, + triggerTime, + pendingIntent + ) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + alarmManager.setExact(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) + } else { + alarmManager.set(AlarmManager.RTC_WAKEUP, triggerTime, pendingIntent) + } + + if (warningNotificationOnKill && !notifyOnKillEnabled) { + setWarningNotificationOnKill(context) + } + } catch (e: ClassCastException) { + Log.e("AlarmPlugin", "AlarmManager service type casting failed", e) + } catch (e: IllegalStateException) { + Log.e("AlarmPlugin", "AlarmManager service not available", e) + } catch (e: Exception) { + Log.e("AlarmPlugin", "Error in handling delayed alarm", e) + } + } + + private fun setWarningNotificationOnKill(context: Context) { + val serviceIntent = Intent(context, NotificationOnKillService::class.java) + serviceIntent.putExtra("title", notificationOnKillTitle) + serviceIntent.putExtra("body", notificationOnKillBody) + + context.startService(serviceIntent) + notifyOnKillEnabled = true + } + + private fun disableWarningNotificationOnKill(context: Context) { + val serviceIntent = Intent(context, NotificationOnKillService::class.java) + context.stopService(serviceIntent) + notifyOnKillEnabled = false + } +} diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/generated/FlutterBindings.g.kt b/android/src/main/kotlin/com/gdelataillade/alarm/generated/FlutterBindings.g.kt new file mode 100644 index 00000000..eeb26f09 --- /dev/null +++ b/android/src/main/kotlin/com/gdelataillade/alarm/generated/FlutterBindings.g.kt @@ -0,0 +1,453 @@ +// Autogenerated from Pigeon (v22.6.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +@file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") + + +import android.util.Log +import io.flutter.plugin.common.BasicMessageChannel +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MessageCodec +import io.flutter.plugin.common.StandardMessageCodec +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +private fun wrapResult(result: Any?): List { + return listOf(result) +} + +private fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } +} + +private fun createConnectionError(channelName: String): FlutterError { + return FlutterError("channel-error", "Unable to establish connection on channel: '$channelName'.", "")} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : Throwable() + +/** Errors that can occur when interacting with the Alarm API. */ +enum class AlarmErrorCode(val raw: Int) { + UNKNOWN(0), + /** A plugin internal error. Please report these as bugs on GitHub. */ + PLUGIN_INTERNAL(1), + /** The arguments passed to the method are invalid. */ + INVALID_ARGUMENTS(2), + /** An error occurred while communicating with the native platform. */ + CHANNEL_ERROR(3), + /** + * The required notification permission was not granted. + * + * Please use an external permission manager such as "permission_handler" to + * request the permission from the user. + */ + MISSING_NOTIFICATION_PERMISSION(4); + + companion object { + fun ofRaw(raw: Int): AlarmErrorCode? { + return values().firstOrNull { it.raw == raw } + } + } +} + +/** + * [AlarmSettingsWire] is a model that contains all the settings to customize + * and set an alarm. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class AlarmSettingsWire ( + /** Unique identifier assiocated with the alarm. Cannot be 0 or -1; */ + val id: Long, + /** Instant (independent of timezone) when the alarm will be triggered. */ + val millisecondsSinceEpoch: Long, + /** + * Path to audio asset to be used as the alarm ringtone. Accepted formats: + * + * * **Project asset**: Specifies an asset bundled with your Flutter project. + * Use this format for assets that are included in your project's + * `pubspec.yaml` file. + * Example: `assets/audio.mp3`. + * * **Absolute file path**: Specifies a direct file system path to the + * audio file. This format is used for audio files stored outside the + * Flutter project, such as files saved in the device's internal + * or external storage. + * Example: `/path/to/your/audio.mp3`. + * * **Relative file path**: Specifies a file path relative to a predefined + * base directory in the app's internal storage. This format is convenient + * for referring to files that are stored within a specific directory of + * your app's internal storage without needing to specify the full path. + * Example: `Audios/audio.mp3`. + * + * If you want to use aboslute or relative file path, you must request + * android storage permission and add the following permission to your + * `AndroidManifest.xml`: + * `android.permission.READ_EXTERNAL_STORAGE` + */ + val assetAudioPath: String, + /** Settings for the notification. */ + val notificationSettings: NotificationSettingsWire, + /** If true, [assetAudioPath] will repeat indefinitely until alarm is stopped. */ + val loopAudio: Boolean, + /** + * If true, device will vibrate for 500ms, pause for 500ms and repeat until + * alarm is stopped. + * + * If [loopAudio] is set to false, vibrations will stop when audio ends. + */ + val vibrate: Boolean, + /** + * Specifies the system volume level to be set at the designated instant. + * + * Accepts a value between 0 (mute) and 1 (maximum volume). + * When the alarm is triggered, the system volume adjusts to his specified + * level. Upon stopping the alarm, the system volume reverts to its prior + * setting. + * + * If left unspecified or set to `null`, the current system volume + * at the time of the alarm will be used. + * Defaults to `null`. + */ + val volume: Double? = null, + /** + * If true, the alarm volume is enforced, automatically resetting to the + * original alarm [volume] if the user attempts to adjust it. + * This prevents the user from lowering the alarm volume. + * Won't work if app is killed. + * + * Defaults to false. + */ + val volumeEnforced: Boolean, + /** + * Duration, in seconds, over which to fade the alarm ringtone. + * Set to 0.0 by default, which means no fade. + */ + val fadeDuration: Double, + /** + * Whether to show a warning notification when application is killed by user. + * + * - **Android**: the alarm should still trigger even if the app is killed, + * if configured correctly and with the right permissions. + * - **iOS**: the alarm will not trigger if the app is killed. + * + * Recommended: set to `Platform.isIOS` to enable it only + * on iOS. Defaults to `true`. + */ + val warningNotificationOnKill: Boolean, + /** + * Whether to turn screen on and display full screen notification + * when android alarm notification is triggered. Enabled by default. + * + * Some devices will need the Autostart permission to show the full screen + * notification. You can check if the permission is granted and request it + * with the [auto_start_flutter](https://pub.dev/packages/auto_start_flutter) + * package. + */ + val androidFullScreenIntent: Boolean +) + { + companion object { + fun fromList(pigeonVar_list: List): AlarmSettingsWire { + val id = pigeonVar_list[0] as Long + val millisecondsSinceEpoch = pigeonVar_list[1] as Long + val assetAudioPath = pigeonVar_list[2] as String + val notificationSettings = pigeonVar_list[3] as NotificationSettingsWire + val loopAudio = pigeonVar_list[4] as Boolean + val vibrate = pigeonVar_list[5] as Boolean + val volume = pigeonVar_list[6] as Double? + val volumeEnforced = pigeonVar_list[7] as Boolean + val fadeDuration = pigeonVar_list[8] as Double + val warningNotificationOnKill = pigeonVar_list[9] as Boolean + val androidFullScreenIntent = pigeonVar_list[10] as Boolean + return AlarmSettingsWire(id, millisecondsSinceEpoch, assetAudioPath, notificationSettings, loopAudio, vibrate, volume, volumeEnforced, fadeDuration, warningNotificationOnKill, androidFullScreenIntent) + } + } + fun toList(): List { + return listOf( + id, + millisecondsSinceEpoch, + assetAudioPath, + notificationSettings, + loopAudio, + vibrate, + volume, + volumeEnforced, + fadeDuration, + warningNotificationOnKill, + androidFullScreenIntent, + ) + } +} + +/** + * Model for notification settings. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class NotificationSettingsWire ( + /** Title of the notification to be shown when alarm is triggered. */ + val title: String, + /** Body of the notification to be shown when alarm is triggered. */ + val body: String, + /** + * The text to display on the stop button of the notification. + * + * Won't work on iOS if app was killed. + * If null, button will not be shown. Null by default. + */ + val stopButton: String? = null, + /** + * The icon to display on the notification. + * + * **Only customizable for Android. On iOS, it will use app default icon.** + * + * This refers to the small icon that is displayed in the + * status bar and next to the notification content in both collapsed + * and expanded views. + * + * Note that the icon must be monochrome and on a transparent background and + * preferably 24x24 dp in size. + * + * **Only PNG and XML formats are supported at the moment. + * Please open an issue to request support for more formats.** + * + * You must add your icon to your Android project's `res/drawable` directory. + * Example: `android/app/src/main/res/drawable/notification_icon.png` + * + * And pass: `icon: notification_icon` without the file extension. + * + * If `null`, the default app icon will be used. + * Defaults to `null`. + */ + val icon: String? = null +) + { + companion object { + fun fromList(pigeonVar_list: List): NotificationSettingsWire { + val title = pigeonVar_list[0] as String + val body = pigeonVar_list[1] as String + val stopButton = pigeonVar_list[2] as String? + val icon = pigeonVar_list[3] as String? + return NotificationSettingsWire(title, body, stopButton, icon) + } + } + fun toList(): List { + return listOf( + title, + body, + stopButton, + icon, + ) + } +} +private open class FlutterBindingsPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return when (type) { + 129.toByte() -> { + return (readValue(buffer) as Long?)?.let { + AlarmErrorCode.ofRaw(it.toInt()) + } + } + 130.toByte() -> { + return (readValue(buffer) as? List)?.let { + AlarmSettingsWire.fromList(it) + } + } + 131.toByte() -> { + return (readValue(buffer) as? List)?.let { + NotificationSettingsWire.fromList(it) + } + } + else -> super.readValueOfType(type, buffer) + } + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + when (value) { + is AlarmErrorCode -> { + stream.write(129) + writeValue(stream, value.raw) + } + is AlarmSettingsWire -> { + stream.write(130) + writeValue(stream, value.toList()) + } + is NotificationSettingsWire -> { + stream.write(131) + writeValue(stream, value.toList()) + } + else -> super.writeValue(stream, value) + } + } +} + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface AlarmApi { + fun setAlarm(alarmSettings: AlarmSettingsWire) + fun stopAlarm(alarmId: Long) + fun isRinging(alarmId: Long?): Boolean + fun setWarningNotificationOnKill(title: String, body: String) + fun disableWarningNotificationOnKill() + + companion object { + /** The codec used by AlarmApi. */ + val codec: MessageCodec by lazy { + FlutterBindingsPigeonCodec() + } + /** Sets up an instance of `AlarmApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: AlarmApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.alarm.AlarmApi.setAlarm$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val alarmSettingsArg = args[0] as AlarmSettingsWire + val wrapped: List = try { + api.setAlarm(alarmSettingsArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.alarm.AlarmApi.stopAlarm$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val alarmIdArg = args[0] as Long + val wrapped: List = try { + api.stopAlarm(alarmIdArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.alarm.AlarmApi.isRinging$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val alarmIdArg = args[0] as Long? + val wrapped: List = try { + listOf(api.isRinging(alarmIdArg)) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.alarm.AlarmApi.setWarningNotificationOnKill$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val titleArg = args[0] as String + val bodyArg = args[1] as String + val wrapped: List = try { + api.setWarningNotificationOnKill(titleArg, bodyArg) + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.alarm.AlarmApi.disableWarningNotificationOnKill$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + api.disableWarningNotificationOnKill() + listOf(null) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} +/** Generated class from Pigeon that represents Flutter messages that can be called from Kotlin. */ +class AlarmTriggerApi(private val binaryMessenger: BinaryMessenger, private val messageChannelSuffix: String = "") { + companion object { + /** The codec used by AlarmTriggerApi. */ + val codec: MessageCodec by lazy { + FlutterBindingsPigeonCodec() + } + } + fun alarmRang(alarmIdArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmRang$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(alarmIdArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(createConnectionError(channelName))) + } + } + } + fun alarmStopped(alarmIdArg: Long, callback: (Result) -> Unit) +{ + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + val channelName = "dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmStopped$separatedMessageChannelSuffix" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(alarmIdArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(createConnectionError(channelName))) + } + } + } +} diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt b/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt index 7ee1a095..01233c7e 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt @@ -1,7 +1,7 @@ package com.gdelataillade.alarm.models +import AlarmSettingsWire import com.google.gson.* -import com.google.gson.annotations.SerializedName import java.util.Date import io.flutter.Log import java.lang.reflect.Type @@ -20,6 +20,22 @@ data class AlarmSettings( val androidFullScreenIntent: Boolean ) { companion object { + fun fromWire(e: AlarmSettingsWire): AlarmSettings { + return AlarmSettings( + e.id.toInt(), + Date(e.millisecondsSinceEpoch), + e.assetAudioPath, + NotificationSettings.fromWire(e.notificationSettings), + e.loopAudio, + e.vibrate, + e.volume, + e.volumeEnforced, + e.fadeDuration, + e.warningNotificationOnKill, + e.androidFullScreenIntent, + ) + } + fun fromJson(jsonString: String): AlarmSettings? { return try { val gson = GsonBuilder() @@ -33,6 +49,22 @@ data class AlarmSettings( } } + fun toWire(): AlarmSettingsWire { + return AlarmSettingsWire( + id.toLong(), + dateTime.time, + assetAudioPath, + notificationSettings.toWire(), + loopAudio, + vibrate, + volume, + volumeEnforced, + fadeDuration, + warningNotificationOnKill, + androidFullScreenIntent, + ) + } + fun toJson(): String { val gson = GsonBuilder() .registerTypeAdapter(Date::class.java, DateSerializer()) @@ -42,7 +74,11 @@ data class AlarmSettings( } class DateDeserializer : JsonDeserializer { - override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): Date { + override fun deserialize( + json: JsonElement?, + typeOfT: Type?, + context: JsonDeserializationContext? + ): Date { val dateTimeMicroseconds = json?.asLong ?: 0L val dateTimeMilliseconds = dateTimeMicroseconds / 1000 return Date(dateTimeMilliseconds) @@ -50,7 +86,11 @@ class DateDeserializer : JsonDeserializer { } class DateSerializer : JsonSerializer { - override fun serialize(src: Date?, typeOfSrc: Type?, context: JsonSerializationContext?): JsonElement { + override fun serialize( + src: Date?, + typeOfSrc: Type?, + context: JsonSerializationContext? + ): JsonElement { val dateTimeMicroseconds = src?.time?.times(1000) ?: 0L return JsonPrimitive(dateTimeMicroseconds) } diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/models/NotificationSettings.kt b/android/src/main/kotlin/com/gdelataillade/alarm/models/NotificationSettings.kt index 2a773f67..4e296744 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/models/NotificationSettings.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/models/NotificationSettings.kt @@ -1,5 +1,6 @@ package com.gdelataillade.alarm.models +import NotificationSettingsWire import com.google.gson.Gson data class NotificationSettings( @@ -8,6 +9,26 @@ data class NotificationSettings( val stopButton: String? = null, val icon: String? = null ) { + companion object { + fun fromWire(e: NotificationSettingsWire): NotificationSettings { + return NotificationSettings( + e.title, + e.body, + e.stopButton, + e.icon, + ) + } + } + + fun toWire(): NotificationSettingsWire { + return NotificationSettingsWire( + title, + body, + stopButton, + icon, + ) + } + fun toJson(): String { return Gson().toJson(this) } diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt b/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt index 7feae5b9..754fae55 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/services/AlarmStorage.kt @@ -12,12 +12,13 @@ import io.flutter.Log class AlarmStorage(context: Context) { companion object { - private const val PREFS_NAME = "alarm_prefs" private const val PREFIX = "flutter.__alarm_id__" } - private val prefs: SharedPreferences = context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + private val prefs: SharedPreferences = + context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + // TODO(gdelataillade): Ensure this function is called or remove it. fun saveAlarm(alarmSettings: AlarmSettings) { val key = "$PREFIX${alarmSettings.id}" val editor = prefs.edit() @@ -33,16 +34,11 @@ class AlarmStorage(context: Context) { } fun getSavedAlarms(): List { - val gsonBuilder = GsonBuilder().registerTypeAdapter(Date::class.java, JsonDeserializer { json, _, _ -> - Date(json.asJsonPrimitive.asLong) - }) - val gson: Gson = gsonBuilder.create() - val alarms = mutableListOf() prefs.all.forEach { (key, value) -> if (key.startsWith(PREFIX) && value is String) { try { - val alarm = gson.fromJson(value, AlarmSettings::class.java) + val alarm = AlarmSettings.fromJson(value) if (alarm != null) { alarms.add(alarm) } else { diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/services/AudioService.kt b/android/src/main/kotlin/com/gdelataillade/alarm/services/AudioService.kt index f23d1b3b..db5d0d50 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/services/AudioService.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/services/AudioService.kt @@ -28,7 +28,7 @@ class AudioService(private val context: Context) { fun playAudio(id: Int, filePath: String, loopAudio: Boolean, fadeDuration: Double?) { stopAudio(id) // Stop and release any existing MediaPlayer and Timer for this ID - val baseAppFlutterPath = context.filesDir.parent + "/app_flutter/" + val baseAppFlutterPath = (context.filesDir.parent ?: "") + "/app_flutter/" val adjustedFilePath = when { filePath.startsWith("assets/") -> "flutter_assets/$filePath" !filePath.startsWith("/") -> baseAppFlutterPath + filePath @@ -42,8 +42,13 @@ class AudioService(private val context: Context) { // It's an asset file val assetManager = context.assets val descriptor = assetManager.openFd(adjustedFilePath) - setDataSource(descriptor.fileDescriptor, descriptor.startOffset, descriptor.length) + setDataSource( + descriptor.fileDescriptor, + descriptor.startOffset, + descriptor.length + ) } + else -> { // Handle local files and adjusted paths setDataSource(adjustedFilePath) diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationOnKillService.kt b/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationOnKillService.kt index 6f4b6440..5cbf40be 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationOnKillService.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationOnKillService.kt @@ -6,8 +6,6 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent -import android.content.pm.PackageManager -import android.graphics.drawable.Drawable import android.provider.Settings import android.os.Build import android.os.IBinder @@ -16,14 +14,18 @@ import androidx.core.app.NotificationCompat import io.flutter.Log class NotificationOnKillService : Service() { + companion object { + private const val NOTIFICATION_ID = 88888 + private const val CHANNEL_ID = "com.gdelataillade.alarm.alarm_channel" + } + private lateinit var title: String private lateinit var body: String - private val NOTIFICATION_ID = 88888 - private val CHANNEL_ID = "com.gdelataillade.alarm.alarm_channel" override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { title = intent?.getStringExtra("title") ?: "Your alarms could not ring" - body = intent?.getStringExtra("body") ?: "You killed the app. Please reopen so your alarms can be rescheduled." + body = intent?.getStringExtra("body") + ?: "You killed the app. Please reopen so your alarms can be rescheduled." return START_STICKY } @@ -32,7 +34,12 @@ class NotificationOnKillService : Service() { override fun onTaskRemoved(rootIntent: Intent?) { try { val notificationIntent = packageManager.getLaunchIntentForPackage(packageName) - val pendingIntent = PendingIntent.getActivity(this, 0, notificationIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) + val pendingIntent = PendingIntent.getActivity( + this, + 0, + notificationIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) val appIconResId = packageManager.getApplicationInfo(packageName, 0).icon val notificationBuilder = NotificationCompat.Builder(this, CHANNEL_ID) @@ -45,7 +52,8 @@ class NotificationOnKillService : Service() { .setSound(Settings.System.DEFAULT_ALARM_ALERT_URI) val name = "Alarm notification service on application kill" - val descriptionText = "If an alarm was set and the app is killed, a notification will show to warn the user the alarm could not ring as long as the app is killed" + val descriptionText = + "If an alarm was set and the app is killed, a notification will show to warn the user the alarm could not ring as long as the app is killed" val importance = NotificationManager.IMPORTANCE_HIGH val channel = NotificationChannel(CHANNEL_ID, name, importance).apply { description = descriptionText diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationService.kt b/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationService.kt index 36694eb3..6533e242 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationService.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/services/NotificationService.kt @@ -1,5 +1,6 @@ -package com.gdelataillade.alarm.alarm +package com.gdelataillade.alarm.services +import android.annotation.SuppressLint import com.gdelataillade.alarm.models.NotificationSettings import android.app.Notification import android.app.NotificationChannel @@ -9,6 +10,7 @@ import android.content.Context import android.content.Intent import android.os.Build import androidx.core.app.NotificationCompat +import com.gdelataillade.alarm.alarm.AlarmReceiver class NotificationHandler(private val context: Context) { companion object { @@ -30,21 +32,29 @@ class NotificationHandler(private val context: Context) { setSound(null, null) } - val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val notificationManager = + context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } } + // We need to use [Resources.getIdentifier] because resources are registered by Flutter. + @SuppressLint("DiscouragedApi") fun buildNotification( notificationSettings: NotificationSettings, fullScreen: Boolean, pendingIntent: PendingIntent, alarmId: Int ): Notification { - val defaultIconResId = context.packageManager.getApplicationInfo(context.packageName, 0).icon + val defaultIconResId = + context.packageManager.getApplicationInfo(context.packageName, 0).icon val iconResId = if (notificationSettings.icon != null) { - val resId = context.resources.getIdentifier(notificationSettings.icon, "drawable", context.packageName) + val resId = context.resources.getIdentifier( + notificationSettings.icon, + "drawable", + context.packageName + ) if (resId != 0) resId else defaultIconResId } else { defaultIconResId diff --git a/android/src/main/kotlin/com/gdelataillade/alarm/services/VolumeService.kt b/android/src/main/kotlin/com/gdelataillade/alarm/services/VolumeService.kt index ffccf5a1..805d5184 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/services/VolumeService.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/services/VolumeService.kt @@ -22,8 +22,12 @@ class VolumeService(private val context: Context) { val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) previousVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) targetVolume = (round(volume * maxVolume)).toInt() - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, targetVolume, if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0) - + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + targetVolume, + if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0 + ) + if (volumeEnforced) { startVolumeEnforcement(showSystemUI) } @@ -34,7 +38,11 @@ class VolumeService(private val context: Context) { volumeCheckRunnable = Runnable { val currentVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) if (currentVolume != targetVolume) { - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, targetVolume, if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0) + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + targetVolume, + if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0 + ) } // Schedule the next check after 1000ms handler.postDelayed(volumeCheckRunnable!!, 1000) @@ -52,10 +60,14 @@ class VolumeService(private val context: Context) { fun restorePreviousVolume(showSystemUI: Boolean) { // Stop the volume enforcement if it's active stopVolumeEnforcement() - + // Restore the previous volume previousVolume?.let { prevVolume -> - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, prevVolume, if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0) + audioManager.setStreamVolume( + AudioManager.STREAM_MUSIC, + prevVolume, + if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0 + ) previousVolume = null } } @@ -67,9 +79,10 @@ class VolumeService(private val context: Context) { .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() - focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) - .setAudioAttributes(audioAttributes) - .build() + focusRequest = + AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes(audioAttributes) + .build() val result = audioManager.requestAudioFocus(focusRequest!!) if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 42f05711..bf24e806 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -26,7 +26,7 @@ android { namespace 'com.gdelataillade.alarm.alarm_example' compileSdkVersion 34 - ndkVersion "25.1.8937393" + ndkVersion "26.1.10909125" compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/example/android/gradle/wrapper/gradle-wrapper.properties b/example/android/gradle/wrapper/gradle-wrapper.properties index aa49780c..3c85cfe0 100644 --- a/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/example/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip diff --git a/example/android/settings.gradle b/example/android/settings.gradle index ba6291ac..e2f90904 100644 --- a/example/android/settings.gradle +++ b/example/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.3.0" apply false + id "com.android.application" version "8.5.0" apply false id "org.jetbrains.kotlin.android" version "1.8.0" apply false } diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index e88ecd56..422f6a5e 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -44,4 +44,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: e7940f0b797939de6f46d152874d3c9091c48dd2 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index a06ba510..f9809977 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -378,7 +378,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 63LD84R3KS; + DEVELOPMENT_TEAM = WQ65PJ26MP; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -514,7 +514,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 63LD84R3KS; + DEVELOPMENT_TEAM = WQ65PJ26MP; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -542,7 +542,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = 63LD84R3KS; + DEVELOPMENT_TEAM = WQ65PJ26MP; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( diff --git a/example/lib/screens/home.dart b/example/lib/screens/home.dart index bfd89bc9..5176f55e 100644 --- a/example/lib/screens/home.dart +++ b/example/lib/screens/home.dart @@ -19,7 +19,7 @@ class ExampleAlarmHomeScreen extends StatefulWidget { } class _ExampleAlarmHomeScreenState extends State { - late List alarms; + List alarms = []; static StreamSubscription? ringSubscription; static StreamSubscription? updateSubscription; @@ -31,17 +31,18 @@ class _ExampleAlarmHomeScreenState extends State { if (Alarm.android) { AlarmPermissions.checkAndroidScheduleExactAlarmPermission(); } - loadAlarms(); + unawaited(loadAlarms()); ringSubscription ??= Alarm.ringStream.stream.listen(navigateToRingScreen); updateSubscription ??= Alarm.updateStream.stream.listen((_) { - loadAlarms(); + unawaited(loadAlarms()); }); } - void loadAlarms() { + Future loadAlarms() async { + final updatedAlarms = await Alarm.getAlarms(); + updatedAlarms.sort((a, b) => a.dateTime.isBefore(b.dateTime) ? 0 : 1); setState(() { - alarms = Alarm.getAlarms(); - alarms.sort((a, b) => a.dateTime.isBefore(b.dateTime) ? 0 : 1); + alarms = updatedAlarms; }); } @@ -53,7 +54,7 @@ class _ExampleAlarmHomeScreenState extends State { ExampleAlarmRingScreen(alarmSettings: alarmSettings), ), ); - loadAlarms(); + unawaited(loadAlarms()); } Future navigateToAlarmScreen(AlarmSettings? settings) async { @@ -71,7 +72,7 @@ class _ExampleAlarmHomeScreenState extends State { }, ); - if (res != null && res == true) loadAlarms(); + if (res != null && res == true) unawaited(loadAlarms()); } Future launchReadmeUrl() async { diff --git a/example/pubspec.lock b/example/pubspec.lock index 66bb13c7..b8259384 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -76,10 +76,10 @@ packages: dependency: transitive description: name: file - sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" flutter: dependency: "direct main" description: flutter @@ -195,10 +195,10 @@ packages: dependency: transitive description: name: permission_handler_android - sha256: "76e4ab092c1b240d31177bb64d2b0bea43f43d0e23541ec866151b9f7b2490fa" + sha256: "71bbecfee799e65aff7c744761a57e817e73b738fedf62ab7afd5593da21f9f1" url: "https://pub.dev" source: hosted - version: "12.0.12" + version: "12.0.13" permission_handler_apple: dependency: transitive description: @@ -235,10 +235,10 @@ packages: dependency: transitive description: name: platform - sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65" + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" url: "https://pub.dev" source: hosted - version: "3.1.5" + version: "3.1.6" plugin_platform_interface: dependency: transitive description: @@ -251,26 +251,26 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "746e5369a43170c25816cc472ee016d3a66bc13fcf430c0bc41ad7b4b2922051" + sha256: "95f9997ca1fb9799d494d0cb2a780fd7be075818d59f00c43832ed112b158a82" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.3" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "480ba4345773f56acda9abf5f50bd966f581dac5d514e5fc4a18c62976bbba7e" + sha256: "3b9febd815c9ca29c9e3520d50ec32f49157711e143b7a4ca039eb87e8ade5ab" url: "https://pub.dev" source: hosted - version: "2.3.2" + version: "2.3.3" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: c4b35f6cb8f63c147312c054ce7c2254c8066745125264f0c88739c417fc9d9f + sha256: "07e050c7cd39bad516f8d64c455f04508d09df104be326d8c02551590a0d513d" url: "https://pub.dev" source: hosted - version: "2.5.2" + version: "2.5.3" shared_preferences_linux: dependency: transitive description: @@ -360,18 +360,18 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: "21b704ce5fa560ea9f3b525b43601c678728ba46725bab9b01187b4831377ed3" + sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603" url: "https://pub.dev" source: hosted - version: "6.3.0" + version: "6.3.1" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: e35a698ac302dd68e41f73250bd9517fe3ab5fa4f18fe4647a0872db61bacbab + sha256: "6fc2f56536ee873eeb867ad176ae15f304ccccc357848b351f6f0d8d4a40d193" url: "https://pub.dev" source: hosted - version: "6.3.10" + version: "6.3.14" url_launcher_ios: dependency: transitive description: @@ -384,10 +384,10 @@ packages: dependency: transitive description: name: url_launcher_linux - sha256: e2b9622b4007f97f504cd64c0128309dfb978ae66adbe944125ed9e1750f06af + sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" url: "https://pub.dev" source: hosted - version: "3.2.0" + version: "3.2.1" url_launcher_macos: dependency: transitive description: @@ -416,10 +416,10 @@ packages: dependency: transitive description: name: url_launcher_windows - sha256: "49c10f879746271804767cb45551ec5592cdab00ee105c06dddde1a98f73b185" + sha256: "44cf3aabcedde30f2dba119a9dea3b0f2672fbe6fa96e85536251d678216b3c4" url: "https://pub.dev" source: hosted - version: "3.1.2" + version: "3.1.3" vector_math: dependency: transitive description: @@ -432,10 +432,10 @@ packages: dependency: "direct dev" description: name: very_good_analysis - sha256: "9ae7f3a3bd5764fb021b335ca28a34f040cd0ab6eec00a1b213b445dae58a4b8" + sha256: "1fb637c0022034b1f19ea2acb42a3603cbd8314a470646a59a2fb01f5f3a8629" url: "https://pub.dev" source: hosted - version: "5.1.0" + version: "6.0.0" vm_service: dependency: transitive description: @@ -456,10 +456,10 @@ packages: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" sdks: dart: ">=3.5.0 <4.0.0" flutter: ">=3.24.0" diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e4bd5d72..77de5dc7 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -10,16 +10,16 @@ environment: dependencies: alarm: path: ../ - cupertino_icons: ^1.0.2 + cupertino_icons: ^1.0.8 flutter: sdk: flutter - permission_handler: ^11.1.0 - url_launcher: ^6.3.0 + permission_handler: ^11.3.1 + url_launcher: ^6.3.1 dev_dependencies: flutter_test: sdk: flutter - very_good_analysis: ^5.1.0 + very_good_analysis: ^6.0.0 flutter: diff --git a/ios/Classes/SwiftAlarmPlugin.swift b/ios/Classes/SwiftAlarmPlugin.swift index 31ed314b..68fe28db 100644 --- a/ios/Classes/SwiftAlarmPlugin.swift +++ b/ios/Classes/SwiftAlarmPlugin.swift @@ -1,477 +1,28 @@ -import Flutter -import UIKit -import AVFoundation -import AudioToolbox -import MediaPlayer import BackgroundTasks +import Flutter public class SwiftAlarmPlugin: NSObject, FlutterPlugin { - #if targetEnvironment(simulator) - private let isDevice = false - #else - private let isDevice = true - #endif - - private var registrar: FlutterPluginRegistrar! - static let shared = SwiftAlarmPlugin() static let backgroundTaskIdentifier: String = "com.gdelataillade.fetch" - private var channel: FlutterMethodChannel! - - public static func register(with registrar: FlutterPluginRegistrar) { - let channel = FlutterMethodChannel(name: "com.gdelataillade/alarm", binaryMessenger: registrar.messenger()) - let instance = SwiftAlarmPlugin.shared - - instance.channel = channel - instance.registrar = registrar - registrar.addMethodCallDelegate(instance, channel: channel) - } - - private var alarms: [Int: AlarmConfiguration] = [:] - - private var silentAudioPlayer: AVAudioPlayer? - - private var warningNotificationOnKill: Bool = false - private var notificationTitleOnKill: String? = nil - private var notificationBodyOnKill: String? = nil - - private var observerAdded = false - private var playSilent = false - private var previousVolume: Float? = nil - - private var vibratingAlarms: Set = [] - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - switch call.method { - case "setAlarm": - self.setAlarm(call: call, result: result) - case "stopAlarm": - guard let args = call.arguments as? [String: Any], let id = args["id"] as? Int else { - result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Error: id parameter is missing or invalid", details: nil)) - return - } - self.stopAlarm(id: id, cancelNotif: true, result: result) - case "isRinging": - if let arguments = call.arguments as? [String: Any], - let id = arguments["id"] as? Int { - let isPlaying = self.alarms[id]?.audioPlayer?.isPlaying ?? false - let currentTime = self.alarms[id]?.audioPlayer?.currentTime ?? 0.0 - result(isPlaying && currentTime > 0) - } else { - result(self.isAnyAlarmRinging()) - } - case "setWarningNotificationOnKill": - guard let args = call.arguments as? [String: Any] else { - result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Error: Arguments are not in the expected format for setWarningNotificationOnKill", details: nil)) - return - } - self.notificationTitleOnKill = (args["title"] as! String) - self.notificationBodyOnKill = (args["body"] as! String) - result(true) - default: - result(FlutterMethodNotImplemented) - } - } - - func unsaveAlarm(id: Int) { - AlarmStorage.shared.unsaveAlarm(id: id) - self.stopAlarm(id: id, cancelNotif: true, result: { _ in }) - channel.invokeMethod("alarmStoppedFromNotification", arguments: ["id": id]) - } - - private func setAlarm(call: FlutterMethodCall, result: FlutterResult) { - self.mixOtherAudios() - - guard let args = call.arguments as? [String: Any], - let alarmSettings = AlarmSettings.fromJson(json: args) else { - let argumentsDescription = "\(call.arguments ?? "nil")" - result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Arguments are not in the expected format: \(argumentsDescription)", details: nil)) - return - } - - NSLog("[SwiftAlarmPlugin] AlarmSettings: \(alarmSettings)") - - var volumeFloat: Float? = nil - if let volumeValue = alarmSettings.volume { - volumeFloat = Float(volumeValue) - } - - let id = alarmSettings.id - let delayInSeconds = alarmSettings.dateTime.timeIntervalSinceNow - - NSLog("[SwiftAlarmPlugin] Alarm scheduled in \(delayInSeconds) seconds") - - let alarmConfig = AlarmConfiguration( - id: id, - assetAudio: alarmSettings.assetAudioPath, - vibrationsEnabled: alarmSettings.vibrate, - loopAudio: alarmSettings.loopAudio, - fadeDuration: alarmSettings.fadeDuration, - volume: volumeFloat, - volumeEnforced: alarmSettings.volumeEnforced - ) - - self.alarms[id] = alarmConfig - - if delayInSeconds >= 1.0 { - NotificationManager.shared.scheduleNotification(id: id, delayInSeconds: Int(floor(delayInSeconds)), notificationSettings: alarmSettings.notificationSettings) { error in - if let error = error { - NSLog("[SwiftAlarmPlugin] Error scheduling notification: \(error.localizedDescription)") - } - } - } - - warningNotificationOnKill = (args["warningNotificationOnKill"] as! Bool) - if warningNotificationOnKill && !observerAdded { - observerAdded = true - NotificationCenter.default.addObserver(self, selector: #selector(applicationWillTerminate(_:)), name: UIApplication.willTerminateNotification, object: nil) - } - - if let audioPlayer = self.loadAudioPlayer(withAsset: alarmSettings.assetAudioPath, forId: id) { - let currentTime = audioPlayer.deviceCurrentTime - let time = currentTime + delayInSeconds - let dateTime = Date().addingTimeInterval(delayInSeconds) - - if alarmSettings.loopAudio { - audioPlayer.numberOfLoops = -1 - } - - audioPlayer.prepareToPlay() - - if !self.playSilent { - self.startSilentSound() - } - - audioPlayer.play(atTime: time + 0.5) - - self.alarms[id]?.audioPlayer = audioPlayer - self.alarms[id]?.triggerTime = dateTime - self.alarms[id]?.task = DispatchWorkItem(block: { - self.handleAlarmAfterDelay(id: id) - }) - - self.alarms[id]?.timer = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) - SwiftAlarmPlugin.scheduleAppRefresh() - - result(true) - } else { - result(FlutterError(code: "NATIVE_ERR", message: "[SwiftAlarmPlugin] Failed to load audio for asset: \(alarmSettings.assetAudioPath)", details: nil)) - return - } - } - - private func loadAudioPlayer(withAsset assetAudio: String, forId id: Int) -> AVAudioPlayer? { - let audioURL: URL - if assetAudio.hasPrefix("assets/") || assetAudio.hasPrefix("asset/") { - let filename = registrar.lookupKey(forAsset: assetAudio) - guard let audioPath = Bundle.main.path(forResource: filename, ofType: nil) else { - NSLog("[SwiftAlarmPlugin] Audio file not found: \(assetAudio)") - return nil - } - audioURL = URL(fileURLWithPath: audioPath) - } else { - let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! - audioURL = documentsDirectory.appendingPathComponent(assetAudio) - } - - do { - return try AVAudioPlayer(contentsOf: audioURL) - } catch { - NSLog("[SwiftAlarmPlugin] Error loading audio player: \(error.localizedDescription)") - return nil - } - } - - @objc func executeTask(_ timer: Timer) { - if let id = timer.userInfo as? Int, let task = alarms[id]?.task { - task.perform() - } - } - - private func startSilentSound() { - let filename = registrar.lookupKey(forAsset: "assets/long_blank.mp3", fromPackage: "alarm") - if let audioPath = Bundle.main.path(forResource: filename, ofType: nil) { - let audioUrl = URL(fileURLWithPath: audioPath) - do { - self.silentAudioPlayer = try AVAudioPlayer(contentsOf: audioUrl) - self.silentAudioPlayer?.numberOfLoops = -1 - self.silentAudioPlayer?.volume = 0.1 - self.playSilent = true - self.silentAudioPlayer?.play() - NotificationCenter.default.addObserver(self, selector: #selector(handleInterruption), name: AVAudioSession.interruptionNotification, object: nil) - } catch { - NSLog("[SwiftAlarmPlugin] Error: Could not create and play silent audio player: \(error)") - } - } else { - NSLog("[SwiftAlarmPlugin] Error: Could not find silent audio file") - } - } - - @objc func handleInterruption(notification: Notification) { - guard let info = notification.userInfo, - let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, - let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { - return - } - - switch type { - case .began: - self.silentAudioPlayer?.play() - NSLog("[SwiftAlarmPlugin] Interruption began") - case .ended: - self.silentAudioPlayer?.play() - NSLog("[SwiftAlarmPlugin] Interruption ended") - default: - break - } - } - - private func loopSilentSound() { - self.silentAudioPlayer?.play() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.silentAudioPlayer?.pause() - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { - if self.playSilent { - self.loopSilentSound() - } - } - } - } - - private func isAnyAlarmRinging() -> Bool { - for (_, alarmConfig) in self.alarms { - if let audioPlayer = alarmConfig.audioPlayer, audioPlayer.isPlaying, audioPlayer.currentTime > 0 { - return true - } - } - return false - } - private func handleAlarmAfterDelay(id: Int) { - if self.isAnyAlarmRinging() { - NSLog("[SwiftAlarmPlugin] Ignoring alarm with id \(id) because another alarm is already ringing.") - self.unsaveAlarm(id: id) - return - } - - guard let alarm = self.alarms[id], let audioPlayer = alarm.audioPlayer else { - return - } - - self.duckOtherAudios() - - if !audioPlayer.isPlaying || audioPlayer.currentTime == 0.0 { - audioPlayer.play() - } - - if alarm.vibrationsEnabled { - self.vibratingAlarms.insert(id) - if self.vibratingAlarms.count == 1 { - self.triggerVibrations() - } - } - - if !alarm.loopAudio { - let audioDuration = audioPlayer.duration - DispatchQueue.main.asyncAfter(deadline: .now() + audioDuration) { - self.stopAlarm(id: id, cancelNotif: false, result: { _ in }) - } - } - - let currentSystemVolume = self.getSystemVolume() - let targetSystemVolume: Float - - if let volumeValue = alarm.volume { - targetSystemVolume = volumeValue - self.setVolume(volume: targetSystemVolume, enable: true) - } else { - targetSystemVolume = currentSystemVolume - } - - if alarm.fadeDuration > 0.0 { - audioPlayer.volume = 0.01 - fadeVolume(audioPlayer: audioPlayer, duration: alarm.fadeDuration) - } else { - audioPlayer.volume = 1.0 - } - - if alarm.volumeEnforced { - alarm.volumeEnforcementTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] timer in - guard let self = self else { return } - let currentSystemVolume = self.getSystemVolume() - if abs(currentSystemVolume - targetSystemVolume) > 0.01 { - self.setVolume(volume: targetSystemVolume, enable: false) - } - } - } - } - - private func getSystemVolume() -> Float { - let audioSession = AVAudioSession.sharedInstance() - return audioSession.outputVolume - } - - private func fadeVolume(audioPlayer: AVAudioPlayer, duration: TimeInterval) { - let fadeInterval: TimeInterval = 0.2 - let currentVolume = audioPlayer.volume - let volumeDifference = 1.0 - currentVolume - let steps = Int(duration / fadeInterval) - let volumeIncrement = volumeDifference / Float(steps) - - var currentStep = 0 - Timer.scheduledTimer(withTimeInterval: fadeInterval, repeats: true) { timer in - if !audioPlayer.isPlaying { - timer.invalidate() - NSLog("[SwiftAlarmPlugin] Volume fading stopped as audioPlayer is no longer playing.") - return - } - - NSLog("[SwiftAlarmPlugin] Fading volume: \(100 * currentStep / steps)%%") - if currentStep >= steps { - timer.invalidate() - audioPlayer.volume = 1.0 - } else { - audioPlayer.volume += volumeIncrement - currentStep += 1 - } - } - } - - private func stopAlarm(id: Int, cancelNotif: Bool, result: FlutterResult) { - if cancelNotif { - NotificationManager.shared.cancelNotification(id: id) - } - NotificationManager.shared.dismissNotification(id: id) - - self.mixOtherAudios() - - self.vibratingAlarms.remove(id) - - if let previousVolume = self.previousVolume { - self.setVolume(volume: previousVolume, enable: false) - } - - if let alarm = self.alarms[id] { - alarm.timer?.invalidate() - alarm.task?.cancel() - alarm.audioPlayer?.stop() - alarm.volumeEnforcementTimer?.invalidate() - self.alarms.removeValue(forKey: id) - } - - self.stopSilentSound() - self.stopNotificationOnKillService() - - result(true) - } - - private func stopSilentSound() { - self.mixOtherAudios() - - if self.alarms.isEmpty { - self.playSilent = false - self.silentAudioPlayer?.stop() - NotificationCenter.default.removeObserver(self) - SwiftAlarmPlugin.cancelBackgroundTasks() - } - } - - private func triggerVibrations() { - if !self.vibratingAlarms.isEmpty && isDevice { - AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.triggerVibrations() - } - } - } - - public func setVolume(volume: Float, enable: Bool) { - let volumeView = MPVolumeView() - - DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) { - if let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { - self.previousVolume = enable ? slider.value : nil - slider.value = volume - } - volumeView.removeFromSuperview() - } - } - - private func backgroundFetch() { - self.mixOtherAudios() - - self.silentAudioPlayer?.pause() - self.silentAudioPlayer?.play() - - let ids = Array(self.alarms.keys) + private static var api: AlarmApiImpl? = nil - for id in ids { - NSLog("[SwiftAlarmPlugin] Background check alarm with id \(id)") - if let audioPlayer = self.alarms[id]?.audioPlayer, let dateTime = self.alarms[id]?.triggerTime { - let currentTime = audioPlayer.deviceCurrentTime - let time = currentTime + dateTime.timeIntervalSinceNow - audioPlayer.play(atTime: time) - } - - if let alarm = self.alarms[id], let delayInSeconds = alarm.triggerTime?.timeIntervalSinceNow { - alarm.timer = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) - } - } - } - - private func stopNotificationOnKillService() { - if self.alarms.isEmpty && self.observerAdded { - NotificationCenter.default.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - self.observerAdded = false - } - } - - // Show notification on app kill - @objc func applicationWillTerminate(_ notification: Notification) { - let content = UNMutableNotificationContent() - content.title = notificationTitleOnKill ?? "Your alarms may not ring" - content.body = notificationBodyOnKill ?? "You killed the app. Please reopen so your alarms can be rescheduled." - - let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) - let request = UNNotificationRequest(identifier: "notification on app kill immediate", content: content, trigger: trigger) - - UNUserNotificationCenter.current().add(request) { (error) in - if let error = error { - NSLog("[SwiftAlarmPlugin] Failed to show immediate notification on app kill => error: \(error.localizedDescription)") - } else { - NSLog("[SwiftAlarmPlugin] Triggered immediate notification on app kill") - } - } - } - - // Mix with other audio sources - private func mixOtherAudios() { - do { - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playback, mode: .default, options: [.mixWithOthers]) - try audioSession.setActive(true) - } catch { - NSLog("[SwiftAlarmPlugin] Error setting up audio session with option mixWithOthers: \(error.localizedDescription)") - } + public static func register(with registrar: FlutterPluginRegistrar) { + self.api = AlarmApiImpl(registrar: registrar) + AlarmApiSetup.setUp(binaryMessenger: registrar.messenger(), api: self.api) + NSLog("[SwiftAlarmPlugin] AlarmApi initialized.") } - // Lower other audio sources - private func duckOtherAudios() { - do { - let audioSession = AVAudioSession.sharedInstance() - try audioSession.setCategory(.playback, mode: .default, options: [.duckOthers]) - try audioSession.setActive(true) - } catch { - NSLog("[SwiftAlarmPlugin] Error setting up audio session with option duckOthers: \(error.localizedDescription)") - } + public func applicationWillTerminate(_ application: UIApplication) { + SwiftAlarmPlugin.api?.sendWarningNotification() } /// Runs from AppDelegate when the app is launched - static public func registerBackgroundTasks() { + public static func registerBackgroundTasks() { if #available(iOS 13.0, *) { BGTaskScheduler.shared.register(forTaskWithIdentifier: backgroundTaskIdentifier, using: nil) { task in self.scheduleAppRefresh() DispatchQueue.main.async { - shared.backgroundFetch() + SwiftAlarmPlugin.api?.backgroundFetch() } task.setTaskCompleted(success: true) } @@ -480,6 +31,10 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } } + static func unsaveAlarm(id: Int) { + SwiftAlarmPlugin.api?.unsaveAlarm(id: id) + } + /// Enables background fetch static func scheduleAppRefresh() { if #available(iOS 13.0, *) { @@ -504,4 +59,4 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { NSLog("[SwiftAlarmPlugin] BGTaskScheduler not available for your version of iOS lower than 13.0") } } -} \ No newline at end of file +} diff --git a/ios/Classes/api/AlarmApiImpl.swift b/ios/Classes/api/AlarmApiImpl.swift new file mode 100644 index 00000000..94cdca14 --- /dev/null +++ b/ios/Classes/api/AlarmApiImpl.swift @@ -0,0 +1,428 @@ +import AVFoundation +import MediaPlayer + +public class AlarmApiImpl: NSObject, AlarmApi { + #if targetEnvironment(simulator) + private let isDevice = false + #else + private let isDevice = true + #endif + + private var registrar: FlutterPluginRegistrar! + + private var alarms: [Int: AlarmConfiguration] = [:] + + private var silentAudioPlayer: AVAudioPlayer? + + private var warningNotificationOnKill: Bool = false + private var notificationTitleOnKill: String? = nil + private var notificationBodyOnKill: String? = nil + + private var observerAdded = false + private var playSilent = false + private var previousVolume: Float? = nil + + private var vibratingAlarms: Set = [] + + init(registrar: FlutterPluginRegistrar) { + self.registrar = registrar + } + + func setAlarm(alarmSettings: AlarmSettingsWire) throws { + self.mixOtherAudios() + + let alarmSettings = AlarmSettings.from(wire: alarmSettings) + + NSLog("[SwiftAlarmPlugin] AlarmSettings: \(String(describing: alarmSettings))") + + var volumeFloat: Float? = nil + if let volumeValue = alarmSettings.volume { + volumeFloat = Float(volumeValue) + } + + let id = alarmSettings.id + let delayInSeconds = alarmSettings.dateTime.timeIntervalSinceNow + + NSLog("[SwiftAlarmPlugin] Alarm scheduled in \(delayInSeconds) seconds") + + let alarmConfig = AlarmConfiguration( + id: id, + assetAudio: alarmSettings.assetAudioPath, + vibrationsEnabled: alarmSettings.vibrate, + loopAudio: alarmSettings.loopAudio, + fadeDuration: alarmSettings.fadeDuration, + volume: volumeFloat, + volumeEnforced: alarmSettings.volumeEnforced + ) + + self.alarms[id] = alarmConfig + + if delayInSeconds >= 1.0 { + NotificationManager.shared.scheduleNotification(id: id, delayInSeconds: Int(floor(delayInSeconds)), notificationSettings: alarmSettings.notificationSettings) { error in + if let error = error { + NSLog("[SwiftAlarmPlugin] Error scheduling notification: \(error.localizedDescription)") + } + } + } + + self.warningNotificationOnKill = alarmSettings.warningNotificationOnKill + if self.warningNotificationOnKill && !self.observerAdded { + self.observerAdded = true + NotificationCenter.default.addObserver(self, selector: #selector(self.appWillTerminate(notification:)), name: UIApplication.willTerminateNotification, object: nil) + } + + if let audioPlayer = self.loadAudioPlayer(withAsset: alarmSettings.assetAudioPath, forId: id) { + let currentTime = audioPlayer.deviceCurrentTime + let time = currentTime + delayInSeconds + let dateTime = Date().addingTimeInterval(delayInSeconds) + + if alarmSettings.loopAudio { + audioPlayer.numberOfLoops = -1 + } + + audioPlayer.prepareToPlay() + + if !self.playSilent { + self.startSilentSound() + } + + audioPlayer.play(atTime: time + 0.5) + + self.alarms[id]?.audioPlayer = audioPlayer + self.alarms[id]?.triggerTime = dateTime + self.alarms[id]?.task = DispatchWorkItem(block: { + self.handleAlarmAfterDelay(id: id) + }) + + self.alarms[id]?.timer = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) + SwiftAlarmPlugin.scheduleAppRefresh() + } else { + throw PigeonError(code: String(AlarmErrorCode.invalidArguments.rawValue), message: "Failed to load audio for asset: \(alarmSettings.assetAudioPath)", details: nil) + } + } + + func stopAlarm(alarmId: Int64) throws { + self.stopAlarmInternal(id: Int(truncatingIfNeeded: alarmId), cancelNotif: true) + } + + func isRinging(alarmId: Int64?) throws -> Bool { + if let alarmId = alarmId { + let id = Int(truncatingIfNeeded: alarmId) + let isPlaying = self.alarms[id]?.audioPlayer?.isPlaying ?? false + let currentTime = self.alarms[id]?.audioPlayer?.currentTime ?? 0.0 + return isPlaying && currentTime > 0 + } else { + return self.isAnyAlarmRinging() + } + } + + func setWarningNotificationOnKill(title: String, body: String) throws { + self.notificationTitleOnKill = title + self.notificationBodyOnKill = body + } + + func disableWarningNotificationOnKill() throws { + throw PigeonError(code: String(AlarmErrorCode.pluginInternal.rawValue), message: "Method disableWarningNotificationOnKill not implemented", details: nil) + } + + public func unsaveAlarm(id: Int) { + AlarmStorage.shared.unsaveAlarm(id: id) + self.stopAlarmInternal(id: id, cancelNotif: true) + } + + public func backgroundFetch() { + self.mixOtherAudios() + + self.silentAudioPlayer?.pause() + self.silentAudioPlayer?.play() + + let ids = Array(self.alarms.keys) + + for id in ids { + NSLog("[SwiftAlarmPlugin] Background check alarm with id \(id)") + if let audioPlayer = self.alarms[id]?.audioPlayer, let dateTime = self.alarms[id]?.triggerTime { + let currentTime = audioPlayer.deviceCurrentTime + let time = currentTime + dateTime.timeIntervalSinceNow + audioPlayer.play(atTime: time) + } + + if let alarm = self.alarms[id], let delayInSeconds = alarm.triggerTime?.timeIntervalSinceNow { + alarm.timer = Timer.scheduledTimer(timeInterval: delayInSeconds, target: self, selector: #selector(self.executeTask(_:)), userInfo: id, repeats: false) + } + } + } + + public func sendWarningNotification() { + let content = UNMutableNotificationContent() + content.title = self.notificationTitleOnKill ?? "Your alarms may not ring" + content.body = self.notificationBodyOnKill ?? "You killed the app. Please reopen so your alarms can be rescheduled." + + let trigger = UNTimeIntervalNotificationTrigger(timeInterval: 2, repeats: false) + let request = UNNotificationRequest(identifier: "notification on app kill immediate", content: content, trigger: trigger) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + NSLog("[SwiftAlarmPlugin] Failed to show immediate notification on app kill => error: \(error.localizedDescription)") + } else { + NSLog("[SwiftAlarmPlugin] Triggered immediate notification on app kill") + } + } + } + + @objc private func appWillTerminate(notification: Notification) { + self.sendWarningNotification() + } + + // Mix with other audio sources + func mixOtherAudios() { + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default, options: [.mixWithOthers]) + try audioSession.setActive(true) + } catch { + NSLog("[SwiftAlarmPlugin] Error setting up audio session with option mixWithOthers: \(error.localizedDescription)") + } + } + + // Lower other audio sources + private func duckOtherAudios() { + do { + let audioSession = AVAudioSession.sharedInstance() + try audioSession.setCategory(.playback, mode: .default, options: [.duckOthers]) + try audioSession.setActive(true) + } catch { + NSLog("[SwiftAlarmPlugin] Error setting up audio session with option duckOthers: \(error.localizedDescription)") + } + } + + private func loadAudioPlayer(withAsset assetAudio: String, forId id: Int) -> AVAudioPlayer? { + let audioURL: URL + if assetAudio.hasPrefix("assets/") || assetAudio.hasPrefix("asset/") { + let filename = self.registrar.lookupKey(forAsset: assetAudio) + guard let audioPath = Bundle.main.path(forResource: filename, ofType: nil) else { + NSLog("[SwiftAlarmPlugin] Audio file not found: \(assetAudio)") + return nil + } + audioURL = URL(fileURLWithPath: audioPath) + } else { + let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! + audioURL = documentsDirectory.appendingPathComponent(assetAudio) + } + + do { + return try AVAudioPlayer(contentsOf: audioURL) + } catch { + NSLog("[SwiftAlarmPlugin] Error loading audio player: \(error.localizedDescription)") + return nil + } + } + + private func startSilentSound() { + let filename = self.registrar.lookupKey(forAsset: "assets/long_blank.mp3", fromPackage: "alarm") + if let audioPath = Bundle.main.path(forResource: filename, ofType: nil) { + let audioUrl = URL(fileURLWithPath: audioPath) + do { + self.silentAudioPlayer = try AVAudioPlayer(contentsOf: audioUrl) + self.silentAudioPlayer?.numberOfLoops = -1 + self.silentAudioPlayer?.volume = 0.1 + self.playSilent = true + self.silentAudioPlayer?.play() + NotificationCenter.default.addObserver(self, selector: #selector(self.handleInterruption), name: AVAudioSession.interruptionNotification, object: nil) + } catch { + NSLog("[SwiftAlarmPlugin] Error: Could not create and play silent audio player: \(error)") + } + } else { + NSLog("[SwiftAlarmPlugin] Error: Could not find silent audio file") + } + } + + private func isAnyAlarmRinging() -> Bool { + for (_, alarmConfig) in self.alarms { + if let audioPlayer = alarmConfig.audioPlayer, audioPlayer.isPlaying, audioPlayer.currentTime > 0 { + return true + } + } + return false + } + + private func handleAlarmAfterDelay(id: Int) { + if self.isAnyAlarmRinging() { + NSLog("[SwiftAlarmPlugin] Ignoring alarm with id \(id) because another alarm is already ringing.") + self.unsaveAlarm(id: id) + return + } + + guard let alarm = self.alarms[id], let audioPlayer = alarm.audioPlayer else { + return + } + + self.duckOtherAudios() + + if !audioPlayer.isPlaying || audioPlayer.currentTime == 0.0 { + audioPlayer.play() + } + + if alarm.vibrationsEnabled { + self.vibratingAlarms.insert(id) + if self.vibratingAlarms.count == 1 { + self.triggerVibrations() + } + } + + if !alarm.loopAudio { + let audioDuration = audioPlayer.duration + DispatchQueue.main.asyncAfter(deadline: .now() + audioDuration) { + self.stopAlarmInternal(id: id, cancelNotif: false) + } + } + + let currentSystemVolume = self.getSystemVolume() + let targetSystemVolume: Float + + if let volumeValue = alarm.volume { + targetSystemVolume = volumeValue + self.setVolume(volume: targetSystemVolume, enable: true) + } else { + targetSystemVolume = currentSystemVolume + } + + if alarm.fadeDuration > 0.0 { + audioPlayer.volume = 0.01 + self.fadeVolume(audioPlayer: audioPlayer, duration: alarm.fadeDuration) + } else { + audioPlayer.volume = 1.0 + } + + if alarm.volumeEnforced { + alarm.volumeEnforcementTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + let currentSystemVolume = self.getSystemVolume() + if abs(currentSystemVolume - targetSystemVolume) > 0.01 { + self.setVolume(volume: targetSystemVolume, enable: false) + } + } + } + } + + private func triggerVibrations() { + if !self.vibratingAlarms.isEmpty && self.isDevice { + AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.triggerVibrations() + } + } + } + + private func getSystemVolume() -> Float { + let audioSession = AVAudioSession.sharedInstance() + return audioSession.outputVolume + } + + public func setVolume(volume: Float, enable: Bool) { + let volumeView = MPVolumeView() + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.1) { + if let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { + self.previousVolume = enable ? slider.value : nil + slider.value = volume + } + volumeView.removeFromSuperview() + } + } + + private func fadeVolume(audioPlayer: AVAudioPlayer, duration: TimeInterval) { + let fadeInterval: TimeInterval = 0.2 + let currentVolume = audioPlayer.volume + let volumeDifference = 1.0 - currentVolume + let steps = Int(duration / fadeInterval) + let volumeIncrement = volumeDifference / Float(steps) + + var currentStep = 0 + Timer.scheduledTimer(withTimeInterval: fadeInterval, repeats: true) { timer in + if !audioPlayer.isPlaying { + timer.invalidate() + NSLog("[SwiftAlarmPlugin] Volume fading stopped as audioPlayer is no longer playing.") + return + } + + NSLog("[SwiftAlarmPlugin] Fading volume: \(100 * currentStep / steps)%%") + if currentStep >= steps { + timer.invalidate() + audioPlayer.volume = 1.0 + } else { + audioPlayer.volume += volumeIncrement + currentStep += 1 + } + } + } + + private func stopAlarmInternal(id: Int, cancelNotif: Bool) { + if cancelNotif { + NotificationManager.shared.cancelNotification(id: id) + } + NotificationManager.shared.dismissNotification(id: id) + + self.mixOtherAudios() + + self.vibratingAlarms.remove(id) + + if let previousVolume = self.previousVolume { + self.setVolume(volume: previousVolume, enable: false) + } + + if let alarm = self.alarms[id] { + alarm.timer?.invalidate() + alarm.task?.cancel() + alarm.audioPlayer?.stop() + alarm.volumeEnforcementTimer?.invalidate() + self.alarms.removeValue(forKey: id) + } + + self.stopSilentSound() + self.stopNotificationOnKillService() + } + + private func stopSilentSound() { + self.mixOtherAudios() + + if self.alarms.isEmpty { + self.playSilent = false + self.silentAudioPlayer?.stop() + NotificationCenter.default.removeObserver(self) + SwiftAlarmPlugin.cancelBackgroundTasks() + } + } + + private func stopNotificationOnKillService() { + if self.alarms.isEmpty && self.observerAdded { + NotificationCenter.default.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) + self.observerAdded = false + } + } + + @objc func executeTask(_ timer: Timer) { + if let id = timer.userInfo as? Int, let task = alarms[id]?.task { + task.perform() + } + } + + @objc func handleInterruption(notification: Notification) { + guard let info = notification.userInfo, + let typeValue = info[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) + else { + return + } + + switch type { + case .began: + self.silentAudioPlayer?.play() + NSLog("[SwiftAlarmPlugin] Interruption began") + case .ended: + self.silentAudioPlayer?.play() + NSLog("[SwiftAlarmPlugin] Interruption ended") + default: + break + } + } +} diff --git a/ios/Classes/generated/FlutterBindings.g.swift b/ios/Classes/generated/FlutterBindings.g.swift new file mode 100644 index 00000000..8731bb78 --- /dev/null +++ b/ios/Classes/generated/FlutterBindings.g.swift @@ -0,0 +1,467 @@ +// Autogenerated from Pigeon (v22.6.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Any? + + init(code: String, message: String?, details: Any?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func createConnectionError(withChannelName channelName: String) -> PigeonError { + return PigeonError(code: "channel-error", message: "Unable to establish connection on channel: '\(channelName)'.", details: "") +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// Errors that can occur when interacting with the Alarm API. +enum AlarmErrorCode: Int { + case unknown = 0 + /// A plugin internal error. Please report these as bugs on GitHub. + case pluginInternal = 1 + /// The arguments passed to the method are invalid. + case invalidArguments = 2 + /// An error occurred while communicating with the native platform. + case channelError = 3 + /// The required notification permission was not granted. + /// + /// Please use an external permission manager such as "permission_handler" to + /// request the permission from the user. + case missingNotificationPermission = 4 +} + +/// [AlarmSettingsWire] is a model that contains all the settings to customize +/// and set an alarm. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct AlarmSettingsWire { + /// Unique identifier assiocated with the alarm. Cannot be 0 or -1; + var id: Int64 + /// Instant (independent of timezone) when the alarm will be triggered. + var millisecondsSinceEpoch: Int64 + /// Path to audio asset to be used as the alarm ringtone. Accepted formats: + /// + /// * **Project asset**: Specifies an asset bundled with your Flutter project. + /// Use this format for assets that are included in your project's + /// `pubspec.yaml` file. + /// Example: `assets/audio.mp3`. + /// * **Absolute file path**: Specifies a direct file system path to the + /// audio file. This format is used for audio files stored outside the + /// Flutter project, such as files saved in the device's internal + /// or external storage. + /// Example: `/path/to/your/audio.mp3`. + /// * **Relative file path**: Specifies a file path relative to a predefined + /// base directory in the app's internal storage. This format is convenient + /// for referring to files that are stored within a specific directory of + /// your app's internal storage without needing to specify the full path. + /// Example: `Audios/audio.mp3`. + /// + /// If you want to use aboslute or relative file path, you must request + /// android storage permission and add the following permission to your + /// `AndroidManifest.xml`: + /// `android.permission.READ_EXTERNAL_STORAGE` + var assetAudioPath: String + /// Settings for the notification. + var notificationSettings: NotificationSettingsWire + /// If true, [assetAudioPath] will repeat indefinitely until alarm is stopped. + var loopAudio: Bool + /// If true, device will vibrate for 500ms, pause for 500ms and repeat until + /// alarm is stopped. + /// + /// If [loopAudio] is set to false, vibrations will stop when audio ends. + var vibrate: Bool + /// Specifies the system volume level to be set at the designated instant. + /// + /// Accepts a value between 0 (mute) and 1 (maximum volume). + /// When the alarm is triggered, the system volume adjusts to his specified + /// level. Upon stopping the alarm, the system volume reverts to its prior + /// setting. + /// + /// If left unspecified or set to `null`, the current system volume + /// at the time of the alarm will be used. + /// Defaults to `null`. + var volume: Double? = nil + /// If true, the alarm volume is enforced, automatically resetting to the + /// original alarm [volume] if the user attempts to adjust it. + /// This prevents the user from lowering the alarm volume. + /// Won't work if app is killed. + /// + /// Defaults to false. + var volumeEnforced: Bool + /// Duration, in seconds, over which to fade the alarm ringtone. + /// Set to 0.0 by default, which means no fade. + var fadeDuration: Double + /// Whether to show a warning notification when application is killed by user. + /// + /// - **Android**: the alarm should still trigger even if the app is killed, + /// if configured correctly and with the right permissions. + /// - **iOS**: the alarm will not trigger if the app is killed. + /// + /// Recommended: set to `Platform.isIOS` to enable it only + /// on iOS. Defaults to `true`. + var warningNotificationOnKill: Bool + /// Whether to turn screen on and display full screen notification + /// when android alarm notification is triggered. Enabled by default. + /// + /// Some devices will need the Autostart permission to show the full screen + /// notification. You can check if the permission is granted and request it + /// with the [auto_start_flutter](https://pub.dev/packages/auto_start_flutter) + /// package. + var androidFullScreenIntent: Bool + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> AlarmSettingsWire? { + let id = pigeonVar_list[0] as! Int64 + let millisecondsSinceEpoch = pigeonVar_list[1] as! Int64 + let assetAudioPath = pigeonVar_list[2] as! String + let notificationSettings = pigeonVar_list[3] as! NotificationSettingsWire + let loopAudio = pigeonVar_list[4] as! Bool + let vibrate = pigeonVar_list[5] as! Bool + let volume: Double? = nilOrValue(pigeonVar_list[6]) + let volumeEnforced = pigeonVar_list[7] as! Bool + let fadeDuration = pigeonVar_list[8] as! Double + let warningNotificationOnKill = pigeonVar_list[9] as! Bool + let androidFullScreenIntent = pigeonVar_list[10] as! Bool + + return AlarmSettingsWire( + id: id, + millisecondsSinceEpoch: millisecondsSinceEpoch, + assetAudioPath: assetAudioPath, + notificationSettings: notificationSettings, + loopAudio: loopAudio, + vibrate: vibrate, + volume: volume, + volumeEnforced: volumeEnforced, + fadeDuration: fadeDuration, + warningNotificationOnKill: warningNotificationOnKill, + androidFullScreenIntent: androidFullScreenIntent + ) + } + func toList() -> [Any?] { + return [ + id, + millisecondsSinceEpoch, + assetAudioPath, + notificationSettings, + loopAudio, + vibrate, + volume, + volumeEnforced, + fadeDuration, + warningNotificationOnKill, + androidFullScreenIntent, + ] + } +} + +/// Model for notification settings. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct NotificationSettingsWire { + /// Title of the notification to be shown when alarm is triggered. + var title: String + /// Body of the notification to be shown when alarm is triggered. + var body: String + /// The text to display on the stop button of the notification. + /// + /// Won't work on iOS if app was killed. + /// If null, button will not be shown. Null by default. + var stopButton: String? = nil + /// The icon to display on the notification. + /// + /// **Only customizable for Android. On iOS, it will use app default icon.** + /// + /// This refers to the small icon that is displayed in the + /// status bar and next to the notification content in both collapsed + /// and expanded views. + /// + /// Note that the icon must be monochrome and on a transparent background and + /// preferably 24x24 dp in size. + /// + /// **Only PNG and XML formats are supported at the moment. + /// Please open an issue to request support for more formats.** + /// + /// You must add your icon to your Android project's `res/drawable` directory. + /// Example: `android/app/src/main/res/drawable/notification_icon.png` + /// + /// And pass: `icon: notification_icon` without the file extension. + /// + /// If `null`, the default app icon will be used. + /// Defaults to `null`. + var icon: String? = nil + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> NotificationSettingsWire? { + let title = pigeonVar_list[0] as! String + let body = pigeonVar_list[1] as! String + let stopButton: String? = nilOrValue(pigeonVar_list[2]) + let icon: String? = nilOrValue(pigeonVar_list[3]) + + return NotificationSettingsWire( + title: title, + body: body, + stopButton: stopButton, + icon: icon + ) + } + func toList() -> [Any?] { + return [ + title, + body, + stopButton, + icon, + ] + } +} + +private class FlutterBindingsPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return AlarmErrorCode(rawValue: enumResultAsInt) + } + return nil + case 130: + return AlarmSettingsWire.fromList(self.readValue() as! [Any?]) + case 131: + return NotificationSettingsWire.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class FlutterBindingsPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? AlarmErrorCode { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? AlarmSettingsWire { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? NotificationSettingsWire { + super.writeByte(131) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class FlutterBindingsPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return FlutterBindingsPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return FlutterBindingsPigeonCodecWriter(data: data) + } +} + +class FlutterBindingsPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = FlutterBindingsPigeonCodec(readerWriter: FlutterBindingsPigeonCodecReaderWriter()) +} + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol AlarmApi { + func setAlarm(alarmSettings: AlarmSettingsWire) throws + func stopAlarm(alarmId: Int64) throws + func isRinging(alarmId: Int64?) throws -> Bool + func setWarningNotificationOnKill(title: String, body: String) throws + func disableWarningNotificationOnKill() throws +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class AlarmApiSetup { + static var codec: FlutterStandardMessageCodec { FlutterBindingsPigeonCodec.shared } + /// Sets up an instance of `AlarmApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: AlarmApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let setAlarmChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.alarm.AlarmApi.setAlarm\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setAlarmChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let alarmSettingsArg = args[0] as! AlarmSettingsWire + do { + try api.setAlarm(alarmSettings: alarmSettingsArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setAlarmChannel.setMessageHandler(nil) + } + let stopAlarmChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.alarm.AlarmApi.stopAlarm\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + stopAlarmChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let alarmIdArg = args[0] as! Int64 + do { + try api.stopAlarm(alarmId: alarmIdArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + stopAlarmChannel.setMessageHandler(nil) + } + let isRingingChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.alarm.AlarmApi.isRinging\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + isRingingChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let alarmIdArg: Int64? = nilOrValue(args[0]) + do { + let result = try api.isRinging(alarmId: alarmIdArg) + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + isRingingChannel.setMessageHandler(nil) + } + let setWarningNotificationOnKillChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.alarm.AlarmApi.setWarningNotificationOnKill\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setWarningNotificationOnKillChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let titleArg = args[0] as! String + let bodyArg = args[1] as! String + do { + try api.setWarningNotificationOnKill(title: titleArg, body: bodyArg) + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + setWarningNotificationOnKillChannel.setMessageHandler(nil) + } + let disableWarningNotificationOnKillChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.alarm.AlarmApi.disableWarningNotificationOnKill\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + disableWarningNotificationOnKillChannel.setMessageHandler { _, reply in + do { + try api.disableWarningNotificationOnKill() + reply(wrapResult(nil)) + } catch { + reply(wrapError(error)) + } + } + } else { + disableWarningNotificationOnKillChannel.setMessageHandler(nil) + } + } +} +/// Generated protocol from Pigeon that represents Flutter messages that can be called from Swift. +protocol AlarmTriggerApiProtocol { + func alarmRang(alarmId alarmIdArg: Int64, completion: @escaping (Result) -> Void) + func alarmStopped(alarmId alarmIdArg: Int64, completion: @escaping (Result) -> Void) +} +class AlarmTriggerApi: AlarmTriggerApiProtocol { + private let binaryMessenger: FlutterBinaryMessenger + private let messageChannelSuffix: String + init(binaryMessenger: FlutterBinaryMessenger, messageChannelSuffix: String = "") { + self.binaryMessenger = binaryMessenger + self.messageChannelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + } + var codec: FlutterBindingsPigeonCodec { + return FlutterBindingsPigeonCodec.shared + } + func alarmRang(alarmId alarmIdArg: Int64, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmRang\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([alarmIdArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(Void())) + } + } + } + func alarmStopped(alarmId alarmIdArg: Int64, completion: @escaping (Result) -> Void) { + let channelName: String = "dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmStopped\(messageChannelSuffix)" + let channel = FlutterBasicMessageChannel(name: channelName, binaryMessenger: binaryMessenger, codec: codec) + channel.sendMessage([alarmIdArg] as [Any?]) { response in + guard let listResponse = response as? [Any?] else { + completion(.failure(createConnectionError(withChannelName: channelName))) + return + } + if listResponse.count > 1 { + let code: String = listResponse[0] as! String + let message: String? = nilOrValue(listResponse[1]) + let details: String? = nilOrValue(listResponse[2]) + completion(.failure(PigeonError(code: code, message: message, details: details))) + } else { + completion(.success(Void())) + } + } + } +} diff --git a/ios/Classes/models/AlarmSettings.swift b/ios/Classes/models/AlarmSettings.swift index f5e9113f..7075f216 100644 --- a/ios/Classes/models/AlarmSettings.swift +++ b/ios/Classes/models/AlarmSettings.swift @@ -13,6 +13,22 @@ struct AlarmSettings: Codable { let notificationSettings: NotificationSettings let volumeEnforced: Bool + static func from(wire: AlarmSettingsWire) -> AlarmSettings { + return AlarmSettings( + id: Int(truncatingIfNeeded: wire.id), + dateTime: Date(timeIntervalSince1970: TimeInterval(wire.millisecondsSinceEpoch / 1_000)), + assetAudioPath: wire.assetAudioPath, + loopAudio: wire.loopAudio, + vibrate: wire.vibrate, + volume: wire.volume, + fadeDuration: wire.fadeDuration, + warningNotificationOnKill: wire.warningNotificationOnKill, + androidFullScreenIntent: wire.androidFullScreenIntent, + notificationSettings: NotificationSettings.from(wire: wire.notificationSettings), + volumeEnforced: wire.volumeEnforced + ) + } + static func fromJson(json: [String: Any]) -> AlarmSettings? { guard let id = json["id"] as? Int, let dateTimeMicros = json["dateTime"] as? Int64, @@ -22,15 +38,16 @@ struct AlarmSettings: Codable { let fadeDuration = json["fadeDuration"] as? Double, let warningNotificationOnKill = json["warningNotificationOnKill"] as? Bool, let androidFullScreenIntent = json["androidFullScreenIntent"] as? Bool, - let notificationSettingsDict = json["notificationSettings"] as? [String: Any] else { + let notificationSettingsDict = json["notificationSettings"] as? [String: Any] + else { return nil } // Ensure the dateTimeMicros is within a valid range - let maxValidMicroseconds: Int64 = 9223372036854775 // Corresponding to year 2262 + let maxValidMicroseconds: Int64 = 9_223_372_036_854_775 // Corresponding to year 2262 let safeDateTimeMicros = min(dateTimeMicros, maxValidMicroseconds) - - let dateTime: Date = Date(timeIntervalSince1970: TimeInterval(safeDateTimeMicros) / 1_000_000) + + let dateTime = Date(timeIntervalSince1970: TimeInterval(safeDateTimeMicros) / 1_000_000) let volume: Double? = json["volume"] as? Double let notificationSettings = NotificationSettings.fromJson(json: notificationSettingsDict) let volumeEnforced: Bool = json["volumeEnforced"] as? Bool ?? false @@ -54,9 +71,9 @@ struct AlarmSettings: Codable { let timestamp = alarmSettings.dateTime.timeIntervalSince1970 let microsecondsPerSecond: Double = 1_000_000 let dateTimeMicros = timestamp * microsecondsPerSecond - + // Ensure the microseconds value does not overflow Int64 and is within a valid range - let maxValidMicroseconds: Double = 9223372036854775 + let maxValidMicroseconds: Double = 9_223_372_036_854_775 let safeDateTimeMicros = dateTimeMicros <= maxValidMicroseconds ? Int64(dateTimeMicros) : Int64(maxValidMicroseconds) return [ diff --git a/ios/Classes/models/NotificationSettings.swift b/ios/Classes/models/NotificationSettings.swift index 557e8c23..94572f6d 100644 --- a/ios/Classes/models/NotificationSettings.swift +++ b/ios/Classes/models/NotificationSettings.swift @@ -5,6 +5,16 @@ struct NotificationSettings: Codable { var body: String var stopButton: String? + static func from(wire: NotificationSettingsWire) -> NotificationSettings { + // NotificationSettingsWire.icon is ignored since we can't modify the + // notification icon on iOS. + return NotificationSettings( + title: wire.title, + body: wire.body, + stopButton: wire.stopButton + ) + } + static func fromJson(json: [String: Any]) -> NotificationSettings { return NotificationSettings( title: json["title"] as! String, @@ -20,4 +30,4 @@ struct NotificationSettings: Codable { "stopButton": notificationSettings.stopButton as Any ] } -} \ No newline at end of file +} diff --git a/ios/Classes/services/AlarmStorage.swift b/ios/Classes/services/AlarmStorage.swift index a5238fa3..e515d83c 100644 --- a/ios/Classes/services/AlarmStorage.swift +++ b/ios/Classes/services/AlarmStorage.swift @@ -51,4 +51,4 @@ class AlarmStorage { } return alarms } -} \ No newline at end of file +} diff --git a/ios/Classes/services/NotificationManager.swift b/ios/Classes/services/NotificationManager.swift index edd9a2c3..54978afe 100644 --- a/ios/Classes/services/NotificationManager.swift +++ b/ios/Classes/services/NotificationManager.swift @@ -7,7 +7,7 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { private let categoryWithoutActionIdentifier = "ALARM_CATEGORY_NO_ACTION" private var registeredActionCategories: Set = [] - private override init() { + override private init() { super.init() setupNotificationCategories() UNUserNotificationCenter.current().delegate = self @@ -82,7 +82,7 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { switch identifier { case "STOP_ACTION": NSLog("[NotificationManager] Stop action triggered for notification: \(notification.request.identifier)") - SwiftAlarmPlugin.shared.unsaveAlarm(id: id) + SwiftAlarmPlugin.unsaveAlarm(id: id) default: break } @@ -96,4 +96,4 @@ class NotificationManager: NSObject, UNUserNotificationCenterDelegate { func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { completionHandler([.alert, .sound]) } -} \ No newline at end of file +} diff --git a/lib/alarm.dart b/lib/alarm.dart index cb63f2dc..fcd6513c 100644 --- a/lib/alarm.dart +++ b/lib/alarm.dart @@ -4,7 +4,9 @@ import 'dart:async'; import 'package:alarm/model/alarm_settings.dart'; import 'package:alarm/service/alarm_storage.dart'; +import 'package:alarm/src/alarm_trigger_api_impl.dart'; import 'package:alarm/src/android_alarm.dart'; +import 'package:alarm/src/generated/platform_bindings.g.dart'; import 'package:alarm/src/ios_alarm.dart'; import 'package:alarm/utils/alarm_exception.dart'; import 'package:alarm/utils/extensions.dart'; @@ -41,8 +43,8 @@ class Alarm { if (showDebugLogs) print('[Alarm] $message'); }; - if (android) AndroidAlarm.init(); - if (iOS) IOSAlarm.init(); + AlarmTriggerApiImpl.ensureInitialized(); + await AlarmStorage.init(); await checkAlarm(); @@ -51,7 +53,7 @@ class Alarm { /// Checks if some alarms were set on previous session. /// If it's the case then reschedules them. static Future checkAlarm() async { - final alarms = getAlarms(); + final alarms = await getAlarms(); if (iOS) await stopAll(); @@ -73,7 +75,9 @@ class Alarm { static Future set({required AlarmSettings alarmSettings}) async { alarmSettingsValidation(alarmSettings); - for (final alarm in getAlarms()) { + final alarms = await getAlarms(); + + for (final alarm in alarms) { if (alarm.id == alarmSettings.id || alarm.dateTime.isSameSecond(alarmSettings.dateTime)) { await Alarm.stop(alarm.id); @@ -82,40 +86,54 @@ class Alarm { await AlarmStorage.saveAlarm(alarmSettings); - if (iOS) return IOSAlarm.setAlarm(alarmSettings); - if (android) return AndroidAlarm.set(alarmSettings); + final success = iOS + ? await IOSAlarm.setAlarm(alarmSettings) + : await AndroidAlarm.set(alarmSettings); - updateStream.add(alarmSettings.id); + if (success) { + updateStream.add(alarmSettings.id); + } - return false; + return success; } /// Validates [alarmSettings] fields. static void alarmSettingsValidation(AlarmSettings alarmSettings) { if (alarmSettings.id == 0 || alarmSettings.id == -1) { throw AlarmException( - 'Alarm id cannot be 0 or -1. Provided: ${alarmSettings.id}', + AlarmErrorCode.invalidArguments, + message: 'Alarm id cannot be 0 or -1. Provided: ${alarmSettings.id}', ); } if (alarmSettings.id > 2147483647) { throw AlarmException( - '''Alarm id cannot be set larger than Int max value (2147483647). Provided: ${alarmSettings.id}''', + AlarmErrorCode.invalidArguments, + message: + 'Alarm id cannot be set larger than Int max value (2147483647). ' + 'Provided: ${alarmSettings.id}', ); } if (alarmSettings.id < -2147483648) { throw AlarmException( - '''Alarm id cannot be set smaller than Int min value (-2147483648). Provided: ${alarmSettings.id}''', + AlarmErrorCode.invalidArguments, + message: + 'Alarm id cannot be set smaller than Int min value (-2147483648). ' + 'Provided: ${alarmSettings.id}', ); } if (alarmSettings.volume != null && (alarmSettings.volume! < 0 || alarmSettings.volume! > 1)) { throw AlarmException( - 'Volume must be between 0 and 1. Provided: ${alarmSettings.volume}', + AlarmErrorCode.invalidArguments, + message: 'Volume must be between 0 and 1. ' + 'Provided: ${alarmSettings.volume}', ); } if (alarmSettings.fadeDuration < 0) { throw AlarmException( - '''Fade duration must be positive. Provided: ${alarmSettings.fadeDuration}''', + AlarmErrorCode.invalidArguments, + message: 'Fade duration must be positive. ' + 'Provided: ${alarmSettings.fadeDuration}', ); } } @@ -129,9 +147,12 @@ class Alarm { /// /// [body] default value is `You killed the app. /// Please reopen so your alarm can ring.` - static void setWarningNotificationOnKill(String title, String body) { - if (iOS) IOSAlarm.setWarningNotificationOnKill(title, body); - if (android) AndroidAlarm.setWarningNotificationOnKill(title, body); + static Future setWarningNotificationOnKill( + String title, + String body, + ) async { + if (iOS) await IOSAlarm.setWarningNotificationOnKill(title, body); + if (android) await AndroidAlarm.setWarningNotificationOnKill(title, body); } /// Stops alarm. @@ -144,7 +165,7 @@ class Alarm { /// Stops all the alarms. static Future stopAll() async { - final alarms = getAlarms(); + final alarms = await getAlarms(); for (final alarm in alarms) { await stop(alarm.id); @@ -160,11 +181,11 @@ class Alarm { iOS ? await IOSAlarm.isRinging(id) : await AndroidAlarm.isRinging(id); /// Whether an alarm is set. - static bool hasAlarm() => AlarmStorage.hasAlarm(); + static Future hasAlarm() => AlarmStorage.hasAlarm(); /// Returns alarm by given id. Returns null if not found. - static AlarmSettings? getAlarm(int id) { - final alarms = getAlarms(); + static Future getAlarm(int id) async { + final alarms = await getAlarms(); for (final alarm in alarms) { if (alarm.id == id) return alarm; @@ -175,12 +196,14 @@ class Alarm { } /// Returns all the alarms. - static List getAlarms() => AlarmStorage.getSavedAlarms(); + static Future> getAlarms() => + AlarmStorage.getSavedAlarms(); /// Reloads the shared preferences instance in the case modifications /// were made in the native code, after a notification action. static Future reload(int id) async { - await AlarmStorage.prefs.reload(); + // TODO(orkun1675): Remove this function and publish stream updates for + // alarm start/stop events. updateStream.add(id); } } diff --git a/lib/model/alarm_settings.dart b/lib/model/alarm_settings.dart index c27184d6..36ce7bca 100644 --- a/lib/model/alarm_settings.dart +++ b/lib/model/alarm_settings.dart @@ -1,10 +1,10 @@ import 'package:alarm/model/notification_settings.dart'; +import 'package:alarm/src/generated/platform_bindings.g.dart'; import 'package:flutter/widgets.dart'; -@immutable - /// [AlarmSettings] is a model that contains all the settings to customize /// and set an alarm. +@immutable class AlarmSettings { /// Constructs an instance of `AlarmSettings`. const AlarmSettings({ @@ -21,6 +21,22 @@ class AlarmSettings { this.androidFullScreenIntent = true, }); + /// Converts from wire datatype. + AlarmSettings.fromWire(AlarmSettingsWire wire) + : id = wire.id, + dateTime = + DateTime.fromMillisecondsSinceEpoch(wire.millisecondsSinceEpoch), + assetAudioPath = wire.assetAudioPath, + notificationSettings = + NotificationSettings.fromWire(wire.notificationSettings), + loopAudio = wire.loopAudio, + vibrate = wire.vibrate, + volume = wire.volume, + volumeEnforced = wire.volumeEnforced, + fadeDuration = wire.fadeDuration, + warningNotificationOnKill = wire.warningNotificationOnKill, + androidFullScreenIntent = wire.androidFullScreenIntent; + /// Constructs an `AlarmSettings` instance from the given JSON data. factory AlarmSettings.fromJson(Map json) { NotificationSettings notificationSettings; @@ -145,6 +161,21 @@ class AlarmSettings { /// package. final bool androidFullScreenIntent; + /// Converts to wire datatype which is used for host platform communication. + AlarmSettingsWire toWire() => AlarmSettingsWire( + id: id, + millisecondsSinceEpoch: dateTime.millisecondsSinceEpoch, + assetAudioPath: assetAudioPath, + notificationSettings: notificationSettings.toWire(), + loopAudio: loopAudio, + vibrate: vibrate, + volume: volume, + volumeEnforced: volumeEnforced, + fadeDuration: fadeDuration, + warningNotificationOnKill: warningNotificationOnKill, + androidFullScreenIntent: androidFullScreenIntent, + ); + /// Returns a hash code for this `AlarmSettings` instance using /// Jenkins hash function. @override diff --git a/lib/model/notification_settings.dart b/lib/model/notification_settings.dart index 88c908de..51565071 100644 --- a/lib/model/notification_settings.dart +++ b/lib/model/notification_settings.dart @@ -1,8 +1,8 @@ +import 'package:alarm/src/generated/platform_bindings.g.dart'; import 'package:flutter/widgets.dart'; -@immutable - /// Model for notification settings. +@immutable class NotificationSettings { /// Constructs an instance of `NotificationSettings`. /// @@ -14,6 +14,13 @@ class NotificationSettings { this.icon, }); + /// Converts from wire datatype. + NotificationSettings.fromWire(NotificationSettingsWire wire) + : title = wire.title, + body = wire.body, + stopButton = wire.stopButton, + icon = wire.icon; + /// Constructs an instance of `NotificationSettings` from a JSON object. factory NotificationSettings.fromJson(Map json) => NotificationSettings( @@ -58,6 +65,14 @@ class NotificationSettings { /// Defaults to `null`. final String? icon; + /// Converts to wire datatype which is used for host platform communication. + NotificationSettingsWire toWire() => NotificationSettingsWire( + title: title, + body: body, + stopButton: stopButton, + icon: icon, + ); + /// Converts the `NotificationSettings` instance to a JSON object. Map toJson() => { 'title': title, diff --git a/lib/service/alarm_storage.dart b/lib/service/alarm_storage.dart index 2d9a6e0a..621c9749 100644 --- a/lib/service/alarm_storage.dart +++ b/lib/service/alarm_storage.dart @@ -22,35 +22,53 @@ class AlarmStorage { /// notification on app kill body. static const notificationOnAppKillBody = 'notificationOnAppKillBody'; + /// Shared preferences instance. + static late SharedPreferences _prefs; + /// Stream subscription to listen to foreground/background events. - static late StreamSubscription fgbgSubscription; + static late StreamSubscription _fgbgSubscription; - /// Shared preferences instance. - static late SharedPreferences prefs; + static bool _initialized = false; /// Initializes shared preferences instance. static Future init() async { - prefs = await SharedPreferences.getInstance(); + _prefs = await SharedPreferences.getInstance(); /// Reloads the shared preferences instance in the case modifications /// were made in the native code, after a notification action. - fgbgSubscription = - FGBGEvents.instance.stream.listen((event) => prefs.reload()); + _fgbgSubscription = + FGBGEvents.instance.stream.listen((event) => _prefs.reload()); + + _initialized = true; + } + + static Future _waitUntilInitialized() async { + while (!_initialized) { + await Future.delayed(const Duration(milliseconds: 100)); + } } /// Saves alarm info in local storage so we can restore it later /// in the case app is terminated. - static Future saveAlarm(AlarmSettings alarmSettings) => prefs.setString( - '$prefix${alarmSettings.id}', - json.encode(alarmSettings.toJson()), - ); + static Future saveAlarm(AlarmSettings alarmSettings) async { + await _waitUntilInitialized(); + await _prefs.setString( + '$prefix${alarmSettings.id}', + json.encode(alarmSettings.toJson()), + ); + } /// Removes alarm from local storage. - static Future unsaveAlarm(int id) => prefs.remove('$prefix$id'); + static Future unsaveAlarm(int id) async { + await _waitUntilInitialized(); + await _prefs.remove('$prefix$id'); + } /// Whether at least one alarm is set. - static bool hasAlarm() { - final keys = prefs.getKeys(); + static Future hasAlarm() async { + await _waitUntilInitialized(); + + final keys = _prefs.getKeys(); for (final key in keys) { if (key.startsWith(prefix)) return true; @@ -61,13 +79,15 @@ class AlarmStorage { /// Returns all alarms info from local storage in the case app is terminated /// and we need to restore previously scheduled alarms. - static List getSavedAlarms() { + static Future> getSavedAlarms() async { + await _waitUntilInitialized(); + final alarms = []; - final keys = prefs.getKeys(); + final keys = _prefs.getKeys(); for (final key in keys) { if (key.startsWith(prefix)) { - final res = prefs.getString(key); + final res = _prefs.getString(key); alarms.add( AlarmSettings.fromJson(json.decode(res!) as Map), ); @@ -79,6 +99,6 @@ class AlarmStorage { /// Dispose the fgbg subscription to avoid memory leaks. static void dispose() { - fgbgSubscription.cancel(); + _fgbgSubscription.cancel(); } } diff --git a/lib/src/alarm_trigger_api_impl.dart b/lib/src/alarm_trigger_api_impl.dart new file mode 100644 index 00000000..8f3575f7 --- /dev/null +++ b/lib/src/alarm_trigger_api_impl.dart @@ -0,0 +1,32 @@ +import 'package:alarm/alarm.dart'; +import 'package:alarm/src/generated/platform_bindings.g.dart'; + +/// Implements the API that handles calls coming from the host platform. +class AlarmTriggerApiImpl extends AlarmTriggerApi { + AlarmTriggerApiImpl._() { + AlarmTriggerApi.setUp(this); + } + + /// Cached instance of [AlarmTriggerApiImpl] + static AlarmTriggerApiImpl? _instance; + + /// Ensures that this Dart isolate is listening for method calls that may come + /// from the host platform. + static void ensureInitialized() { + _instance ??= AlarmTriggerApiImpl._(); + } + + @override + Future alarmRang(int alarmId) async { + final settings = await Alarm.getAlarm(alarmId); + if (settings == null) { + return; + } + Alarm.ringStream.add(settings); + } + + @override + Future alarmStopped(int alarmId) async { + await Alarm.reload(alarmId); + } +} diff --git a/lib/src/android_alarm.dart b/lib/src/android_alarm.dart index e9c42852..8810ecef 100644 --- a/lib/src/android_alarm.dart +++ b/lib/src/android_alarm.dart @@ -1,59 +1,23 @@ import 'dart:async'; + import 'package:alarm/alarm.dart'; +import 'package:alarm/src/generated/platform_bindings.g.dart'; import 'package:alarm/utils/alarm_exception.dart'; -import 'package:flutter/services.dart'; +import 'package:alarm/utils/alarm_handler.dart'; /// Uses method channel to interact with the native platform. class AndroidAlarm { - /// Method channel for the alarm operations. - static const methodChannel = MethodChannel('com.gdelataillade.alarm/alarm'); - - /// Event channel for the alarm events. - static const eventChannel = EventChannel('com.gdelataillade.alarm/events'); + static final AlarmApi _api = AlarmApi(); /// Whether there are other alarms set. - static bool get hasOtherAlarms => Alarm.getAlarms().length > 1; - - /// Starts listening to the native alarm events. - static void init() => listenToNativeEvents(); - - /// Listens to the alarm events. - static void listenToNativeEvents() { - eventChannel.receiveBroadcastStream().listen( - (dynamic event) async { - try { - final eventMap = Map.from(event as Map); - final id = eventMap['id'] as int?; - final method = eventMap['method'] as String?; - if (id == null || method == null) return; - - switch (method) { - case 'stop': - await Alarm.reload(id); - case 'ring': - final settings = Alarm.getAlarm(id); - if (settings != null) Alarm.ringStream.add(settings); - } - } catch (e) { - alarmPrint('Error receiving alarm events: $e'); - } - }, - onError: (dynamic error, StackTrace stackTrace) { - alarmPrint('Error listening to alarm events: $error, $stackTrace'); - }, - ); - } + static Future get hasOtherAlarms => + Alarm.getAlarms().then((alarms) => alarms.length > 1); /// Schedules a native alarm with given [settings] with its notification. static Future set(AlarmSettings settings) async { - try { - await methodChannel.invokeMethod( - 'setAlarm', - settings.toJson(), - ); - } catch (e) { - throw AlarmException('AndroidAlarm.setAlarm error: $e'); - } + await _api + .setAlarm(alarmSettings: settings.toWire()) + .catchError(AlarmExceptionHandlers.catchError); alarmPrint( '''Alarm with id ${settings.id} scheduled at ${settings.dateTime}''', @@ -66,12 +30,12 @@ class AndroidAlarm { /// can stop playing and dispose. static Future stop(int id) async { try { - final res = - await methodChannel.invokeMethod('stopAlarm', {'id': id}) as bool; - if (res) alarmPrint('Alarm with id $id stopped'); - if (!hasOtherAlarms) await disableWarningNotificationOnKill(); - return res; - } catch (e) { + await _api + .stopAlarm(alarmId: id) + .catchError(AlarmExceptionHandlers.catchError); + if (!(await hasOtherAlarms)) await disableWarningNotificationOnKill(); + return true; + } on AlarmException catch (e) { alarmPrint('Failed to stop alarm: $e'); return false; } @@ -80,11 +44,11 @@ class AndroidAlarm { /// Checks whether an alarm or any alarm (if id is null) is ringing. static Future isRinging([int? id]) async { try { - final res = - await methodChannel.invokeMethod('isRinging', {'id': id}) ?? - false; + final res = await _api + .isRinging(alarmId: id) + .catchError(AlarmExceptionHandlers.catchError); return res; - } catch (e) { + } on AlarmException catch (e) { alarmPrint('Failed to check if alarm is ringing: $e'); return false; } @@ -92,17 +56,15 @@ class AndroidAlarm { /// Sets the native notification on app kill title and body. static Future setWarningNotificationOnKill(String title, String body) => - methodChannel.invokeMethod( - 'setWarningNotificationOnKill', - {'title': title, 'body': body}, - ); + _api + .setWarningNotificationOnKill( + title: title, + body: body, + ) + .catchError(AlarmExceptionHandlers.catchError); /// Disable the notification on kill service. - static Future disableWarningNotificationOnKill() async { - try { - await methodChannel.invokeMethod('disableWarningNotificationOnKill'); - } catch (e) { - throw AlarmException('NotificationOnKillService error: $e'); - } - } + static Future disableWarningNotificationOnKill() => _api + .disableWarningNotificationOnKill() + .catchError(AlarmExceptionHandlers.catchError); } diff --git a/lib/src/generated/platform_bindings.g.dart b/lib/src/generated/platform_bindings.g.dart new file mode 100644 index 00000000..3a50b4ad --- /dev/null +++ b/lib/src/generated/platform_bindings.g.dart @@ -0,0 +1,499 @@ +// Autogenerated from Pigeon (v22.6.1), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse( + {Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +/// Errors that can occur when interacting with the Alarm API. +enum AlarmErrorCode { + unknown, + + /// A plugin internal error. Please report these as bugs on GitHub. + pluginInternal, + + /// The arguments passed to the method are invalid. + invalidArguments, + + /// An error occurred while communicating with the native platform. + channelError, + + /// The required notification permission was not granted. + /// + /// Please use an external permission manager such as "permission_handler" to + /// request the permission from the user. + missingNotificationPermission, +} + +/// [AlarmSettingsWire] is a model that contains all the settings to customize +/// and set an alarm. +class AlarmSettingsWire { + AlarmSettingsWire({ + required this.id, + required this.millisecondsSinceEpoch, + required this.assetAudioPath, + required this.notificationSettings, + this.loopAudio = true, + this.vibrate = true, + this.volume, + this.volumeEnforced = false, + this.fadeDuration = 0.0, + this.warningNotificationOnKill = true, + this.androidFullScreenIntent = true, + }); + + /// Unique identifier assiocated with the alarm. Cannot be 0 or -1; + int id; + + /// Instant (independent of timezone) when the alarm will be triggered. + int millisecondsSinceEpoch; + + /// Path to audio asset to be used as the alarm ringtone. Accepted formats: + /// + /// * **Project asset**: Specifies an asset bundled with your Flutter project. + /// Use this format for assets that are included in your project's + /// `pubspec.yaml` file. + /// Example: `assets/audio.mp3`. + /// * **Absolute file path**: Specifies a direct file system path to the + /// audio file. This format is used for audio files stored outside the + /// Flutter project, such as files saved in the device's internal + /// or external storage. + /// Example: `/path/to/your/audio.mp3`. + /// * **Relative file path**: Specifies a file path relative to a predefined + /// base directory in the app's internal storage. This format is convenient + /// for referring to files that are stored within a specific directory of + /// your app's internal storage without needing to specify the full path. + /// Example: `Audios/audio.mp3`. + /// + /// If you want to use aboslute or relative file path, you must request + /// android storage permission and add the following permission to your + /// `AndroidManifest.xml`: + /// `android.permission.READ_EXTERNAL_STORAGE` + String assetAudioPath; + + /// Settings for the notification. + NotificationSettingsWire notificationSettings; + + /// If true, [assetAudioPath] will repeat indefinitely until alarm is stopped. + bool loopAudio; + + /// If true, device will vibrate for 500ms, pause for 500ms and repeat until + /// alarm is stopped. + /// + /// If [loopAudio] is set to false, vibrations will stop when audio ends. + bool vibrate; + + /// Specifies the system volume level to be set at the designated instant. + /// + /// Accepts a value between 0 (mute) and 1 (maximum volume). + /// When the alarm is triggered, the system volume adjusts to his specified + /// level. Upon stopping the alarm, the system volume reverts to its prior + /// setting. + /// + /// If left unspecified or set to `null`, the current system volume + /// at the time of the alarm will be used. + /// Defaults to `null`. + double? volume; + + /// If true, the alarm volume is enforced, automatically resetting to the + /// original alarm [volume] if the user attempts to adjust it. + /// This prevents the user from lowering the alarm volume. + /// Won't work if app is killed. + /// + /// Defaults to false. + bool volumeEnforced; + + /// Duration, in seconds, over which to fade the alarm ringtone. + /// Set to 0.0 by default, which means no fade. + double fadeDuration; + + /// Whether to show a warning notification when application is killed by user. + /// + /// - **Android**: the alarm should still trigger even if the app is killed, + /// if configured correctly and with the right permissions. + /// - **iOS**: the alarm will not trigger if the app is killed. + /// + /// Recommended: set to `Platform.isIOS` to enable it only + /// on iOS. Defaults to `true`. + bool warningNotificationOnKill; + + /// Whether to turn screen on and display full screen notification + /// when android alarm notification is triggered. Enabled by default. + /// + /// Some devices will need the Autostart permission to show the full screen + /// notification. You can check if the permission is granted and request it + /// with the [auto_start_flutter](https://pub.dev/packages/auto_start_flutter) + /// package. + bool androidFullScreenIntent; + + Object encode() { + return [ + id, + millisecondsSinceEpoch, + assetAudioPath, + notificationSettings, + loopAudio, + vibrate, + volume, + volumeEnforced, + fadeDuration, + warningNotificationOnKill, + androidFullScreenIntent, + ]; + } + + static AlarmSettingsWire decode(Object result) { + result as List; + return AlarmSettingsWire( + id: result[0]! as int, + millisecondsSinceEpoch: result[1]! as int, + assetAudioPath: result[2]! as String, + notificationSettings: result[3]! as NotificationSettingsWire, + loopAudio: result[4]! as bool, + vibrate: result[5]! as bool, + volume: result[6] as double?, + volumeEnforced: result[7]! as bool, + fadeDuration: result[8]! as double, + warningNotificationOnKill: result[9]! as bool, + androidFullScreenIntent: result[10]! as bool, + ); + } +} + +/// Model for notification settings. +class NotificationSettingsWire { + NotificationSettingsWire({ + required this.title, + required this.body, + this.stopButton, + this.icon, + }); + + /// Title of the notification to be shown when alarm is triggered. + String title; + + /// Body of the notification to be shown when alarm is triggered. + String body; + + /// The text to display on the stop button of the notification. + /// + /// Won't work on iOS if app was killed. + /// If null, button will not be shown. Null by default. + String? stopButton; + + /// The icon to display on the notification. + /// + /// **Only customizable for Android. On iOS, it will use app default icon.** + /// + /// This refers to the small icon that is displayed in the + /// status bar and next to the notification content in both collapsed + /// and expanded views. + /// + /// Note that the icon must be monochrome and on a transparent background and + /// preferably 24x24 dp in size. + /// + /// **Only PNG and XML formats are supported at the moment. + /// Please open an issue to request support for more formats.** + /// + /// You must add your icon to your Android project's `res/drawable` directory. + /// Example: `android/app/src/main/res/drawable/notification_icon.png` + /// + /// And pass: `icon: notification_icon` without the file extension. + /// + /// If `null`, the default app icon will be used. + /// Defaults to `null`. + String? icon; + + Object encode() { + return [ + title, + body, + stopButton, + icon, + ]; + } + + static NotificationSettingsWire decode(Object result) { + result as List; + return NotificationSettingsWire( + title: result[0]! as String, + body: result[1]! as String, + stopButton: result[2] as String?, + icon: result[3] as String?, + ); + } +} + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is AlarmErrorCode) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is AlarmSettingsWire) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is NotificationSettingsWire) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : AlarmErrorCode.values[value]; + case 130: + return AlarmSettingsWire.decode(readValue(buffer)!); + case 131: + return NotificationSettingsWire.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class AlarmApi { + /// Constructor for [AlarmApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + AlarmApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future setAlarm({required AlarmSettingsWire alarmSettings}) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.alarm.AlarmApi.setAlarm$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = await pigeonVar_channel + .send([alarmSettings]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future stopAlarm({required int alarmId}) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.alarm.AlarmApi.stopAlarm$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([alarmId]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future isRinging({required int? alarmId}) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.alarm.AlarmApi.isRinging$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([alarmId]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + Future setWarningNotificationOnKill( + {required String title, required String body}) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.alarm.AlarmApi.setWarningNotificationOnKill$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([title, body]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } + + Future disableWarningNotificationOnKill() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.alarm.AlarmApi.disableWarningNotificationOnKill$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } +} + +abstract class AlarmTriggerApi { + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + void alarmRang(int alarmId); + + Future alarmStopped(int alarmId); + + static void setUp( + AlarmTriggerApi? api, { + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) { + messageChannelSuffix = + messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmRang$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmRang was null.'); + final List args = (message as List?)!; + final int? arg_alarmId = (args[0] as int?); + assert(arg_alarmId != null, + 'Argument for dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmRang was null, expected non-null int.'); + try { + api.alarmRang(arg_alarmId!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmStopped$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmStopped was null.'); + final List args = (message as List?)!; + final int? arg_alarmId = (args[0] as int?); + assert(arg_alarmId != null, + 'Argument for dev.flutter.pigeon.alarm.AlarmTriggerApi.alarmStopped was null, expected non-null int.'); + try { + await api.alarmStopped(arg_alarmId!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +} diff --git a/lib/src/ios_alarm.dart b/lib/src/ios_alarm.dart index 31395046..b3a26f4e 100644 --- a/lib/src/ios_alarm.dart +++ b/lib/src/ios_alarm.dart @@ -1,15 +1,15 @@ import 'dart:async'; import 'package:alarm/alarm.dart'; +import 'package:alarm/src/generated/platform_bindings.g.dart'; import 'package:alarm/utils/alarm_exception.dart'; +import 'package:alarm/utils/alarm_handler.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_fgbg/flutter_fgbg.dart'; /// Uses method channel to interact with the native platform. class IOSAlarm { - /// Method channel for the alarm. - static const methodChannel = MethodChannel('com.gdelataillade/alarm'); + static final AlarmApi _api = AlarmApi(); /// Map of alarm timers. static Map timers = {}; @@ -17,16 +17,6 @@ class IOSAlarm { /// Map of foreground/background subscriptions. static Map?> fgbgSubscriptions = {}; - /// Initializes the method call handler. - static void init() => methodChannel.setMethodCallHandler(handleMethodCall); - - /// Handles incoming method calls from the native platform. - static Future handleMethodCall(MethodCall call) async { - final arguments = (call.arguments as Map).cast(); - final id = arguments['id'] as int?; - if (id != null) await Alarm.reload(id); - } - /// Calls the native function `setAlarm` and listens to alarm ring state. /// /// Also set periodic timer and listens for app state changes to trigger @@ -34,20 +24,15 @@ class IOSAlarm { static Future setAlarm(AlarmSettings settings) async { final id = settings.id; try { - final res = await methodChannel.invokeMethod( - 'setAlarm', - settings.toJson(), - ) ?? - false; - + await _api + .setAlarm(alarmSettings: settings.toWire()) + .catchError(AlarmExceptionHandlers.catchError); alarmPrint( - '''Alarm with id $id scheduled ${res ? 'successfully' : 'failed'} at ${settings.dateTime}''', + 'Alarm with id $id scheduled successfully at ${settings.dateTime}', ); - - if (!res) return false; - } catch (e) { + } on AlarmException catch (_) { await Alarm.stop(id); - throw AlarmException(e.toString()); + rethrow; } if (timers[id] != null && timers[id]!.isActive) timers[id]!.cancel(); @@ -86,39 +71,26 @@ class IOSAlarm { /// and calls the native `stopAlarm` function. static Future stopAlarm(int id) async { disposeAlarm(id); - - final res = await methodChannel.invokeMethod( - 'stopAlarm', - {'id': id}, - ) ?? - false; - - if (res) alarmPrint('Alarm with id $id stopped'); - - return res; - } - - /// Returns the list of saved alarms stored locally. - static Future> getSavedAlarms() async { - final res = await methodChannel - .invokeMethod?>('getSavedAlarms') ?? - []; - - return res - .map((e) => AlarmSettings.fromJson(e as Map)) - .toList(); + try { + await _api + .stopAlarm(alarmId: id) + .catchError(AlarmExceptionHandlers.catchError); + alarmPrint('Alarm with id $id stopped'); + return true; + } on AlarmException catch (e) { + alarmPrint('Failed to stop alarm $id. $e'); + return false; + } } /// Checks whether an alarm or any alarm (if id is null) is ringing. static Future isRinging([int? id]) async { try { - final res = await methodChannel.invokeMethod( - 'isRinging', - {'id': id}, - ); - - return res ?? false; - } catch (e) { + final res = await _api + .isRinging(alarmId: id) + .catchError(AlarmExceptionHandlers.catchError); + return res; + } on AlarmException catch (e) { debugPrint('Error checking if alarm is ringing: $e'); return false; } @@ -148,10 +120,9 @@ class IOSAlarm { /// Sets the native notification on app kill title and body. static Future setWarningNotificationOnKill(String title, String body) => - methodChannel.invokeMethod( - 'setWarningNotificationOnKill', - {'title': title, 'body': body}, - ); + _api + .setWarningNotificationOnKill(title: title, body: body) + .catchError(AlarmExceptionHandlers.catchError); /// Disposes alarm timer. static void disposeTimer(int id) { diff --git a/lib/utils/alarm_exception.dart b/lib/utils/alarm_exception.dart index 12d919d6..63a9992a 100644 --- a/lib/utils/alarm_exception.dart +++ b/lib/utils/alarm_exception.dart @@ -1,11 +1,20 @@ +import 'package:alarm/src/generated/platform_bindings.g.dart'; + /// Custom exception for the alarm. class AlarmException implements Exception { /// Creates an [AlarmException] with the given error [message]. - const AlarmException(this.message); + const AlarmException(this.code, {this.message, this.stacktrace}); + + /// The type/category of error. + final AlarmErrorCode code; /// Exception message. - final String message; + final String? message; + + /// The Stacktrace when the exception occured. + final String? stacktrace; @override - String toString() => message; + String toString() => + '${code.name}: $message${stacktrace != null ? '\n$stacktrace' : ''}'; } diff --git a/lib/utils/alarm_handler.dart b/lib/utils/alarm_handler.dart new file mode 100644 index 00000000..c3e95664 --- /dev/null +++ b/lib/utils/alarm_handler.dart @@ -0,0 +1,59 @@ +import 'package:alarm/src/generated/platform_bindings.g.dart'; +import 'package:alarm/utils/alarm_exception.dart'; +import 'package:flutter/services.dart'; + +/// Handlers for parsing runtime exceptions as an AlarmException. +extension AlarmExceptionHandlers on AlarmException { + /// Wraps a PlatformException within an AlarmException. + static AlarmException fromPlatformException(PlatformException ex) { + return AlarmException( + ex.code == 'channel-error' + ? AlarmErrorCode.channelError + : AlarmErrorCode.values.firstWhere( + (e) => e.index == (int.tryParse(ex.code) ?? 0), + orElse: () => AlarmErrorCode.unknown, + ), + message: ex.message, + stacktrace: ex.stacktrace, + ); + } + + /// Wraps a Exception within an AlarmException. + static AlarmException fromException( + Exception ex, [ + StackTrace? stacktrace, + ]) { + return AlarmException( + AlarmErrorCode.unknown, + message: ex.toString(), + stacktrace: stacktrace?.toString() ?? StackTrace.current.toString(), + ); + } + + /// Wraps a dynamic error within an AlarmException. + static AlarmException fromError( + dynamic error, [ + StackTrace? stacktrace, + ]) { + if (error is AlarmException) { + return error; + } + if (error is PlatformException) { + return fromPlatformException(error); + } + if (error is Exception) { + return fromException(error, stacktrace); + } + return AlarmException( + AlarmErrorCode.unknown, + message: error.toString(), + stacktrace: stacktrace?.toString() ?? StackTrace.current.toString(), + ); + } + + /// Utility method that can be used for wrapping errors thrown by Futures + /// in an AlarmException. + static T catchError(dynamic error, StackTrace stacktrace) { + throw fromError(error, stacktrace); + } +} diff --git a/pigeons/alarm_api.dart b/pigeons/alarm_api.dart new file mode 100644 index 00000000..2a2c142c --- /dev/null +++ b/pigeons/alarm_api.dart @@ -0,0 +1,210 @@ +import 'package:pigeon/pigeon.dart'; + +// After modifying this file run: +// dart run pigeon --input pigeons/alarm_api.dart && dart format . + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/src/generated/platform_bindings.g.dart', + dartPackageName: 'alarm', + swiftOut: 'ios/Classes/generated/FlutterBindings.g.swift', + kotlinOut: + 'android/src/main/kotlin/com/gdelataillade/alarm/generated/FlutterBindings.g.kt', + ), +) + +/// [AlarmSettingsWire] is a model that contains all the settings to customize +/// and set an alarm. +class AlarmSettingsWire { + /// Constructs an instance of [AlarmSettingsWire]. + const AlarmSettingsWire({ + required this.id, + required this.millisecondsSinceEpoch, + required this.assetAudioPath, + required this.notificationSettings, + this.loopAudio = true, + this.vibrate = true, + this.volume, + this.volumeEnforced = false, + this.fadeDuration = 0.0, + this.warningNotificationOnKill = true, + this.androidFullScreenIntent = true, + }); + + /// Unique identifier assiocated with the alarm. Cannot be 0 or -1; + final int id; + + /// Instant (independent of timezone) when the alarm will be triggered. + final int millisecondsSinceEpoch; + + /// Path to audio asset to be used as the alarm ringtone. Accepted formats: + /// + /// * **Project asset**: Specifies an asset bundled with your Flutter project. + /// Use this format for assets that are included in your project's + /// `pubspec.yaml` file. + /// Example: `assets/audio.mp3`. + /// * **Absolute file path**: Specifies a direct file system path to the + /// audio file. This format is used for audio files stored outside the + /// Flutter project, such as files saved in the device's internal + /// or external storage. + /// Example: `/path/to/your/audio.mp3`. + /// * **Relative file path**: Specifies a file path relative to a predefined + /// base directory in the app's internal storage. This format is convenient + /// for referring to files that are stored within a specific directory of + /// your app's internal storage without needing to specify the full path. + /// Example: `Audios/audio.mp3`. + /// + /// If you want to use aboslute or relative file path, you must request + /// android storage permission and add the following permission to your + /// `AndroidManifest.xml`: + /// `android.permission.READ_EXTERNAL_STORAGE` + final String assetAudioPath; + + /// Settings for the notification. + final NotificationSettingsWire notificationSettings; + + /// If true, [assetAudioPath] will repeat indefinitely until alarm is stopped. + final bool loopAudio; + + /// If true, device will vibrate for 500ms, pause for 500ms and repeat until + /// alarm is stopped. + /// + /// If [loopAudio] is set to false, vibrations will stop when audio ends. + final bool vibrate; + + /// Specifies the system volume level to be set at the designated instant. + /// + /// Accepts a value between 0 (mute) and 1 (maximum volume). + /// When the alarm is triggered, the system volume adjusts to his specified + /// level. Upon stopping the alarm, the system volume reverts to its prior + /// setting. + /// + /// If left unspecified or set to `null`, the current system volume + /// at the time of the alarm will be used. + /// Defaults to `null`. + final double? volume; + + /// If true, the alarm volume is enforced, automatically resetting to the + /// original alarm [volume] if the user attempts to adjust it. + /// This prevents the user from lowering the alarm volume. + /// Won't work if app is killed. + /// + /// Defaults to false. + final bool volumeEnforced; + + /// Duration, in seconds, over which to fade the alarm ringtone. + /// Set to 0.0 by default, which means no fade. + final double fadeDuration; + + /// Whether to show a warning notification when application is killed by user. + /// + /// - **Android**: the alarm should still trigger even if the app is killed, + /// if configured correctly and with the right permissions. + /// - **iOS**: the alarm will not trigger if the app is killed. + /// + /// Recommended: set to `Platform.isIOS` to enable it only + /// on iOS. Defaults to `true`. + final bool warningNotificationOnKill; + + /// Whether to turn screen on and display full screen notification + /// when android alarm notification is triggered. Enabled by default. + /// + /// Some devices will need the Autostart permission to show the full screen + /// notification. You can check if the permission is granted and request it + /// with the [auto_start_flutter](https://pub.dev/packages/auto_start_flutter) + /// package. + final bool androidFullScreenIntent; +} + +/// Model for notification settings. +class NotificationSettingsWire { + /// Constructs an instance of [NotificationSettingsWire]. + /// + /// Open PR if you want more features. + const NotificationSettingsWire({ + required this.title, + required this.body, + this.stopButton, + this.icon, + }); + + /// Title of the notification to be shown when alarm is triggered. + final String title; + + /// Body of the notification to be shown when alarm is triggered. + final String body; + + /// The text to display on the stop button of the notification. + /// + /// Won't work on iOS if app was killed. + /// If null, button will not be shown. Null by default. + final String? stopButton; + + /// The icon to display on the notification. + /// + /// **Only customizable for Android. On iOS, it will use app default icon.** + /// + /// This refers to the small icon that is displayed in the + /// status bar and next to the notification content in both collapsed + /// and expanded views. + /// + /// Note that the icon must be monochrome and on a transparent background and + /// preferably 24x24 dp in size. + /// + /// **Only PNG and XML formats are supported at the moment. + /// Please open an issue to request support for more formats.** + /// + /// You must add your icon to your Android project's `res/drawable` directory. + /// Example: `android/app/src/main/res/drawable/notification_icon.png` + /// + /// And pass: `icon: notification_icon` without the file extension. + /// + /// If `null`, the default app icon will be used. + /// Defaults to `null`. + final String? icon; +} + +/// Errors that can occur when interacting with the Alarm API. +enum AlarmErrorCode { + unknown, + + /// A plugin internal error. Please report these as bugs on GitHub. + pluginInternal, + + /// The arguments passed to the method are invalid. + invalidArguments, + + /// An error occurred while communicating with the native platform. + channelError, + + /// The required notification permission was not granted. + /// + /// Please use an external permission manager such as "permission_handler" to + /// request the permission from the user. + missingNotificationPermission, +} + +@HostApi() +abstract class AlarmApi { + void setAlarm({required AlarmSettingsWire alarmSettings}); + + void stopAlarm({required int alarmId}); + + bool isRinging({required int? alarmId}); + + void setWarningNotificationOnKill({ + required String title, + required String body, + }); + + void disableWarningNotificationOnKill(); +} + +@FlutterApi() +abstract class AlarmTriggerApi { + @async + void alarmRang(int alarmId); + + @async + void alarmStopped(int alarmId); +} diff --git a/pubspec.yaml b/pubspec.yaml index cb04501e..74c570c1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,12 +11,14 @@ dependencies: flutter: sdk: flutter flutter_fgbg: ^0.6.0 - shared_preferences: ^2.3.2 + plugin_platform_interface: ^2.1.8 + shared_preferences: ^2.3.3 dev_dependencies: flutter_test: sdk: flutter - very_good_analysis: ^5.1.0 + pigeon: ^22.6.1 + very_good_analysis: ^6.0.0 flutter: assets: