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 6c0c2175..0cd80c5b 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmPlugin.kt @@ -176,6 +176,7 @@ class AlarmPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { 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("fullScreenIntent") ?: true) 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 d9cd4b39..ace6213b 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmService.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/alarm/AlarmService.kt @@ -109,8 +109,11 @@ class AlarmService : Service() { val loopAudio = intent.getBooleanExtra("loopAudio", true) val vibrate = intent.getBooleanExtra("vibrate", true) val volume = intent.getDoubleExtra("volume", -1.0) + val volumeEnforced = intent.getBooleanExtra("volumeEnforced", false) val fadeDuration = intent.getDoubleExtra("fadeDuration", 0.0) + Log.d("AlarmService", "volume enforced: $volumeEnforced") + // Notify the plugin about the alarm ringing AlarmPlugin.eventSink?.success( mapOf( @@ -121,7 +124,7 @@ class AlarmService : Service() { // Set the volume if specified if (volume >= 0.0 && volume <= 1.0) { - volumeService?.setVolume(volume, showSystemUI) + volumeService?.setVolume(volume, volumeEnforced, showSystemUI) } // Request audio focus 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 d9fec451..7ee1a095 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/models/AlarmSettings.kt @@ -14,6 +14,7 @@ data class AlarmSettings( val loopAudio: Boolean, val vibrate: Boolean, val volume: Double?, + val volumeEnforced: Boolean = false, val fadeDuration: Double, val warningNotificationOnKill: Boolean, val androidFullScreenIntent: Boolean 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 1d9fbda9..ffccf5a1 100644 --- a/android/src/main/kotlin/com/gdelataillade/alarm/services/VolumeService.kt +++ b/android/src/main/kotlin/com/gdelataillade/alarm/services/VolumeService.kt @@ -5,6 +5,8 @@ import android.media.AudioManager import android.media.AudioAttributes import android.media.AudioFocusRequest import android.os.Build +import android.os.Handler +import android.os.Looper import kotlin.math.round import io.flutter.Log @@ -12,15 +14,46 @@ class VolumeService(private val context: Context) { private var previousVolume: Int? = null private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager private var focusRequest: AudioFocusRequest? = null + private val handler = Handler(Looper.getMainLooper()) + private var targetVolume: Int = 0 + private var volumeCheckRunnable: Runnable? = null - fun setVolume(volume: Double, showSystemUI: Boolean) { + fun setVolume(volume: Double, volumeEnforced: Boolean, showSystemUI: Boolean) { val maxVolume = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) previousVolume = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) - val _volume = (round(volume * maxVolume)).toInt() - audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, _volume, if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0) + targetVolume = (round(volume * maxVolume)).toInt() + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, targetVolume, if (showSystemUI) AudioManager.FLAG_SHOW_UI else 0) + + if (volumeEnforced) { + startVolumeEnforcement(showSystemUI) + } + } + + private fun startVolumeEnforcement(showSystemUI: Boolean) { + // Define the Runnable that checks and enforces the volume level + 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) + } + // Schedule the next check after 1000ms + handler.postDelayed(volumeCheckRunnable!!, 1000) + } + // Start the first run + handler.post(volumeCheckRunnable!!) + } + + private fun stopVolumeEnforcement() { + // Remove callbacks to stop enforcing volume + volumeCheckRunnable?.let { handler.removeCallbacks(it) } + volumeCheckRunnable = null } 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) previousVolume = null diff --git a/example/lib/screens/ring.dart b/example/lib/screens/ring.dart index ee1c97ec..cde6849e 100644 --- a/example/lib/screens/ring.dart +++ b/example/lib/screens/ring.dart @@ -1,39 +1,11 @@ -import 'dart:async'; - import 'package:alarm/alarm.dart'; import 'package:flutter/material.dart'; -class ExampleAlarmRingScreen extends StatefulWidget { +class ExampleAlarmRingScreen extends StatelessWidget { const ExampleAlarmRingScreen({required this.alarmSettings, super.key}); final AlarmSettings alarmSettings; - @override - State createState() => _ExampleAlarmRingScreenState(); -} - -class _ExampleAlarmRingScreenState extends State { - @override - void initState() { - super.initState(); - Timer.periodic(const Duration(seconds: 1), (timer) async { - if (!mounted) { - timer.cancel(); - return; - } - - final isRinging = await Alarm.isRinging(widget.alarmSettings.id); - if (isRinging) { - alarmPrint('Alarm ${widget.alarmSettings.id} is still ringing...'); - return; - } - - alarmPrint('Alarm ${widget.alarmSettings.id} stopped ringing.'); - timer.cancel(); - if (mounted) Navigator.pop(context); - }); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -42,7 +14,7 @@ class _ExampleAlarmRingScreenState extends State { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ Text( - 'You alarm (${widget.alarmSettings.id}) is ringing...', + 'You alarm (${alarmSettings.id}) is ringing...', style: Theme.of(context).textTheme.titleLarge, ), const Text('🔔', style: TextStyle(fontSize: 50)), @@ -53,7 +25,7 @@ class _ExampleAlarmRingScreenState extends State { onPressed: () { final now = DateTime.now(); Alarm.set( - alarmSettings: widget.alarmSettings.copyWith( + alarmSettings: alarmSettings.copyWith( dateTime: DateTime( now.year, now.month, @@ -73,7 +45,7 @@ class _ExampleAlarmRingScreenState extends State { ), RawMaterialButton( onPressed: () { - Alarm.stop(widget.alarmSettings.id).then((_) { + Alarm.stop(alarmSettings.id).then((_) { if (context.mounted) Navigator.pop(context); }); }, diff --git a/ios/Classes/AlarmConfiguration.swift b/ios/Classes/AlarmConfiguration.swift index b40d29ee..e044b5f4 100644 --- a/ios/Classes/AlarmConfiguration.swift +++ b/ios/Classes/AlarmConfiguration.swift @@ -7,17 +7,20 @@ class AlarmConfiguration { let loopAudio: Bool let fadeDuration: Double let volume: Float? + var volumeEnforced: Bool + var volumeEnforcementTimer: Timer? var triggerTime: Date? var audioPlayer: AVAudioPlayer? var timer: Timer? var task: DispatchWorkItem? - init(id: Int, assetAudio: String, vibrationsEnabled: Bool, loopAudio: Bool, fadeDuration: Double, volume: Float?) { + init(id: Int, assetAudio: String, vibrationsEnabled: Bool, loopAudio: Bool, fadeDuration: Double, volume: Float?, volumeEnforced: Bool) { self.id = id self.assetAudio = assetAudio self.vibrationsEnabled = vibrationsEnabled self.loopAudio = loopAudio self.fadeDuration = fadeDuration self.volume = volume + self.volumeEnforced = volumeEnforced } } \ No newline at end of file diff --git a/ios/Classes/SwiftAlarmPlugin.swift b/ios/Classes/SwiftAlarmPlugin.swift index 54358e95..e03ba8df 100644 --- a/ios/Classes/SwiftAlarmPlugin.swift +++ b/ios/Classes/SwiftAlarmPlugin.swift @@ -104,7 +104,8 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { vibrationsEnabled: alarmSettings.vibrate, loopAudio: alarmSettings.loopAudio, fadeDuration: alarmSettings.fadeDuration, - volume: volumeFloat + volume: volumeFloat, + volumeEnforced: alarmSettings.volumeEnforced ) self.alarms[id] = alarmConfig @@ -290,6 +291,16 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { } 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 { @@ -341,6 +352,7 @@ public class SwiftAlarmPlugin: NSObject, FlutterPlugin { alarm.timer?.invalidate() alarm.task?.cancel() alarm.audioPlayer?.stop() + alarm.volumeEnforcementTimer?.invalidate() self.alarms.removeValue(forKey: id) } diff --git a/ios/Classes/models/AlarmSettings.swift b/ios/Classes/models/AlarmSettings.swift index 18cac66a..f5e9113f 100644 --- a/ios/Classes/models/AlarmSettings.swift +++ b/ios/Classes/models/AlarmSettings.swift @@ -11,6 +11,7 @@ struct AlarmSettings: Codable { let warningNotificationOnKill: Bool let androidFullScreenIntent: Bool let notificationSettings: NotificationSettings + let volumeEnforced: Bool static func fromJson(json: [String: Any]) -> AlarmSettings? { guard let id = json["id"] as? Int, @@ -29,10 +30,11 @@ struct AlarmSettings: Codable { let maxValidMicroseconds: Int64 = 9223372036854775 // Corresponding to year 2262 let safeDateTimeMicros = min(dateTimeMicros, maxValidMicroseconds) - let dateTime = Date(timeIntervalSince1970: TimeInterval(safeDateTimeMicros) / 1_000_000) - let volume = json["volume"] as? Double + let dateTime: Date = 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 + return AlarmSettings( id: id, dateTime: dateTime, @@ -43,7 +45,8 @@ struct AlarmSettings: Codable { fadeDuration: fadeDuration, warningNotificationOnKill: warningNotificationOnKill, androidFullScreenIntent: androidFullScreenIntent, - notificationSettings: notificationSettings + notificationSettings: notificationSettings, + volumeEnforced: volumeEnforced ) } @@ -63,10 +66,11 @@ struct AlarmSettings: Codable { "loopAudio": alarmSettings.loopAudio, "vibrate": alarmSettings.vibrate, "volume": alarmSettings.volume, + "volumeEnforced": alarmSettings.volumeEnforced, "fadeDuration": alarmSettings.fadeDuration, "warningNotificationOnKill": alarmSettings.warningNotificationOnKill, "androidFullScreenIntent": alarmSettings.androidFullScreenIntent, "notificationSettings": NotificationSettings.toJson(notificationSettings: alarmSettings.notificationSettings) ] } -} \ No newline at end of file +} diff --git a/lib/model/alarm_settings.dart b/lib/model/alarm_settings.dart index fc0eb00e..c27184d6 100644 --- a/lib/model/alarm_settings.dart +++ b/lib/model/alarm_settings.dart @@ -15,6 +15,7 @@ class AlarmSettings { this.loopAudio = true, this.vibrate = true, this.volume, + this.volumeEnforced = false, this.fadeDuration = 0.0, this.warningNotificationOnKill = true, this.androidFullScreenIntent = true, @@ -53,6 +54,7 @@ class AlarmSettings { loopAudio: json['loopAudio'] as bool? ?? true, vibrate: json['vibrate'] as bool? ?? true, volume: json['volume'] as double?, + volumeEnforced: json['volumeEnforced'] as bool? ?? false, fadeDuration: json['fadeDuration'] as double? ?? 0.0, warningNotificationOnKill: warningNotificationOnKill, androidFullScreenIntent: json['androidFullScreenIntent'] as bool? ?? true, @@ -112,6 +114,14 @@ class AlarmSettings { /// 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; @@ -148,6 +158,7 @@ class AlarmSettings { hash = hash ^ loopAudio.hashCode; hash = hash ^ vibrate.hashCode; hash = hash ^ volume.hashCode; + hash = hash ^ volumeEnforced.hashCode; hash = hash ^ fadeDuration.hashCode; hash = hash ^ warningNotificationOnKill.hashCode; hash = hash ^ androidFullScreenIntent.hashCode; @@ -166,6 +177,7 @@ class AlarmSettings { bool? loopAudio, bool? vibrate, double? volume, + bool? volumeEnforced, double? fadeDuration, String? notificationTitle, String? notificationBody, @@ -180,6 +192,7 @@ class AlarmSettings { loopAudio: loopAudio ?? this.loopAudio, vibrate: vibrate ?? this.vibrate, volume: volume ?? this.volume, + volumeEnforced: volumeEnforced ?? this.volumeEnforced, fadeDuration: fadeDuration ?? this.fadeDuration, warningNotificationOnKill: warningNotificationOnKill ?? this.warningNotificationOnKill, @@ -197,6 +210,7 @@ class AlarmSettings { 'loopAudio': loopAudio, 'vibrate': vibrate, 'volume': volume, + 'volumeEnforced': volumeEnforced, 'fadeDuration': fadeDuration, 'warningNotificationOnKill': warningNotificationOnKill, 'androidFullScreenIntent': androidFullScreenIntent,