Skip to content

Commit 02f1dee

Browse files
committed
Fix background timer: native AlarmManager schedules phase alarms, service plays audio independently of Dart isolate
1 parent 28b543c commit 02f1dee

6 files changed

Lines changed: 176 additions & 14 deletions

File tree

work_timer/android/app/src/main/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
44
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
55
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
6+
<uses-permission android:name="android.permission.WAKE_LOCK"/>
7+
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" android:maxSdkVersion="32"/>
8+
<uses-permission android:name="android.permission.USE_EXACT_ALARM"/>
69
<application
710
android:label="Sift"
811
android:name="${applicationName}"
@@ -37,6 +40,9 @@
3740
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
3841
android:value="This foreground service keeps the work session timer running so the alarm can ring at each phase boundary even when the app is in the background."/>
3942
</service>
43+
<receiver
44+
android:name=".AlarmReceiver"
45+
android:exported="false"/>
4046
<!-- Don't delete the meta-data below.
4147
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
4248
<meta-data
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.utsapoddar.sift
2+
3+
import android.content.BroadcastReceiver
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.os.Build
7+
8+
class AlarmReceiver : BroadcastReceiver() {
9+
override fun onReceive(context: Context, intent: Intent) {
10+
val serviceIntent = Intent(context, TimerService::class.java).apply {
11+
action = TimerService.ACTION_ALARM_FIRED
12+
putExtra(TimerService.EXTRA_PHASE_NAME, intent.getStringExtra(TimerService.EXTRA_PHASE_NAME) ?: "")
13+
}
14+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
15+
context.startForegroundService(serviceIntent)
16+
} else {
17+
context.startService(serviceIntent)
18+
}
19+
}
20+
}

work_timer/android/app/src/main/kotlin/com/utsapoddar/sift/MainActivity.kt

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ class MainActivity : FlutterActivity() {
2323
val action = when (intent.action) {
2424
TimerService.ACTION_STOP -> "stop"
2525
TimerService.ACTION_SILENCE -> "silence"
26+
"com.sift.timer.alarm_notify" -> "alarm"
2627
else -> return
2728
}
2829
runOnUiThread {
@@ -38,7 +39,18 @@ class MainActivity : FlutterActivity() {
3839
ch.setMethodCallHandler { call, result ->
3940
when (call.method) {
4041
"startTimerService" -> {
41-
val intent = Intent(this, TimerService::class.java)
42+
@Suppress("UNCHECKED_CAST")
43+
val args = call.arguments as? Map<String, Any> ?: emptyMap<String, Any>()
44+
val phaseNames = (args["phaseNames"] as? List<*>)
45+
?.filterIsInstance<String>()?.toTypedArray() ?: emptyArray()
46+
val phaseEndTimes = (args["phaseEndTimes"] as? List<*>)
47+
?.map { (it as Number).toLong() }?.toLongArray() ?: LongArray(0)
48+
49+
val intent = Intent(this, TimerService::class.java).apply {
50+
action = TimerService.ACTION_SCHEDULE_ALARMS
51+
putExtra(TimerService.EXTRA_PHASE_NAMES, phaseNames)
52+
putExtra(TimerService.EXTRA_PHASE_END_TIMES, phaseEndTimes)
53+
}
4254
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
4355
startForegroundService(intent)
4456
} else {
@@ -47,7 +59,10 @@ class MainActivity : FlutterActivity() {
4759
result.success(null)
4860
}
4961
"stopTimerService" -> {
50-
stopService(Intent(this, TimerService::class.java))
62+
val intent = Intent(this, TimerService::class.java).apply {
63+
action = TimerService.ACTION_CANCEL_ALARMS
64+
}
65+
startService(intent)
5166
result.success(null)
5267
}
5368
else -> result.notImplemented()
@@ -57,7 +72,7 @@ class MainActivity : FlutterActivity() {
5772

5873
override fun onCreate(savedInstanceState: Bundle?) {
5974
super.onCreate(savedInstanceState)
60-
// Request notification permission on launch so it's settled before the timer starts
75+
// Request notification permission on launch so it's settled before timer starts
6176
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
6277
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
6378
!= PackageManager.PERMISSION_GRANTED) {
@@ -67,6 +82,7 @@ class MainActivity : FlutterActivity() {
6782
val filter = IntentFilter().apply {
6883
addAction(TimerService.ACTION_STOP)
6984
addAction(TimerService.ACTION_SILENCE)
85+
addAction("com.sift.timer.alarm_notify")
7086
}
7187
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
7288
registerReceiver(actionReceiver, filter, RECEIVER_NOT_EXPORTED)

work_timer/android/app/src/main/kotlin/com/utsapoddar/sift/TimerService.kt

Lines changed: 114 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.utsapoddar.sift
22

3+
import android.app.AlarmManager
34
import android.app.Notification
45
import android.app.NotificationChannel
56
import android.app.NotificationManager
@@ -8,9 +9,13 @@ import android.app.Service
89
import android.content.Context
910
import android.content.Intent
1011
import android.content.pm.ServiceInfo
12+
import android.media.AudioAttributes
13+
import android.media.MediaPlayer
14+
import android.net.Uri
1115
import android.os.Build
1216
import android.os.IBinder
1317
import androidx.core.app.NotificationCompat
18+
import java.io.File
1419

1520
class TimerService : Service() {
1621

@@ -19,8 +24,17 @@ class TimerService : Service() {
1924
const val NOTIF_ID = 42
2025
const val ACTION_STOP = "com.sift.timer.stop"
2126
const val ACTION_SILENCE = "com.sift.timer.silence"
27+
const val ACTION_ALARM_FIRED = "com.sift.timer.alarm_fired"
28+
const val ACTION_SCHEDULE_ALARMS = "com.sift.timer.schedule_alarms"
29+
const val ACTION_CANCEL_ALARMS = "com.sift.timer.cancel_alarms"
30+
const val EXTRA_PHASE_NAMES = "phase_names"
31+
const val EXTRA_PHASE_END_TIMES = "phase_end_times"
32+
const val EXTRA_PHASE_NAME = "phase_name"
2233
}
2334

35+
private var mediaPlayer: MediaPlayer? = null
36+
private var currentPhaseName: String = "Work"
37+
2438
override fun onBind(intent: Intent?): IBinder? = null
2539

2640
override fun onCreate() {
@@ -34,11 +48,35 @@ class TimerService : Service() {
3448
}
3549

3650
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
51+
when (intent?.action) {
52+
ACTION_ALARM_FIRED -> {
53+
currentPhaseName = intent.getStringExtra(EXTRA_PHASE_NAME) ?: currentPhaseName
54+
updateNotification()
55+
playAlarm()
56+
// Notify Flutter if it's alive
57+
sendBroadcast(Intent(ACTION_STOP).setPackage(packageName).apply {
58+
action = "com.sift.timer.alarm_notify"
59+
})
60+
}
61+
ACTION_SCHEDULE_ALARMS -> {
62+
val names = intent.getStringArrayExtra(EXTRA_PHASE_NAMES) ?: return START_STICKY
63+
val times = intent.getLongArrayExtra(EXTRA_PHASE_END_TIMES) ?: return START_STICKY
64+
currentPhaseName = if (names.isNotEmpty()) names[0] else "Work"
65+
updateNotification()
66+
scheduleAlarms(names, times)
67+
}
68+
ACTION_CANCEL_ALARMS -> {
69+
cancelAlarms()
70+
stopAlarmSound()
71+
stopSelf()
72+
}
73+
}
3774
return START_STICKY
3875
}
3976

4077
override fun onDestroy() {
4178
super.onDestroy()
79+
stopAlarmSound()
4280
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
4381
stopForeground(STOP_FOREGROUND_REMOVE)
4482
} else {
@@ -47,6 +85,79 @@ class TimerService : Service() {
4785
}
4886
}
4987

88+
private fun scheduleAlarms(names: Array<String>, times: LongArray) {
89+
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
90+
times.forEachIndexed { i, timeMs ->
91+
val alarmIntent = Intent(this, AlarmReceiver::class.java).apply {
92+
putExtra(EXTRA_PHASE_NAME, if (i + 1 < names.size) names[i + 1] else "Done")
93+
}
94+
val pi = PendingIntent.getBroadcast(
95+
this, i, alarmIntent,
96+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
97+
)
98+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
99+
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, timeMs, pi)
100+
} else {
101+
alarmManager.setExact(AlarmManager.RTC_WAKEUP, timeMs, pi)
102+
}
103+
}
104+
}
105+
106+
private fun cancelAlarms() {
107+
val alarmManager = getSystemService(Context.ALARM_SERVICE) as AlarmManager
108+
for (i in 0..6) {
109+
val pi = PendingIntent.getBroadcast(
110+
this, i, Intent(this, AlarmReceiver::class.java),
111+
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_NO_CREATE
112+
)
113+
pi?.let { alarmManager.cancel(it) }
114+
}
115+
}
116+
117+
private fun playAlarm() {
118+
stopAlarmSound()
119+
val prefs = getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE)
120+
val ringtonePath = prefs.getString("flutter.ringtone_path", null)
121+
122+
try {
123+
mediaPlayer = if (ringtonePath != null && File(ringtonePath).exists()) {
124+
MediaPlayer().apply {
125+
setAudioAttributes(AudioAttributes.Builder()
126+
.setUsage(AudioAttributes.USAGE_ALARM)
127+
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
128+
.build())
129+
setDataSource(ringtonePath)
130+
prepare()
131+
}
132+
} else {
133+
val afd = assets.openFd("flutter_assets/assets/alarm.mp3")
134+
MediaPlayer().apply {
135+
setAudioAttributes(AudioAttributes.Builder()
136+
.setUsage(AudioAttributes.USAGE_ALARM)
137+
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
138+
.build())
139+
setDataSource(afd.fileDescriptor, afd.startOffset, afd.length)
140+
prepare()
141+
}
142+
}
143+
mediaPlayer?.apply {
144+
isLooping = false
145+
setOnCompletionListener { mp -> mp.release(); mediaPlayer = null }
146+
start()
147+
}
148+
} catch (_: Exception) {}
149+
}
150+
151+
private fun stopAlarmSound() {
152+
mediaPlayer?.apply { if (isPlaying) stop(); release() }
153+
mediaPlayer = null
154+
}
155+
156+
private fun updateNotification() {
157+
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
158+
nm.notify(NOTIF_ID, buildNotification())
159+
}
160+
50161
private fun buildNotification(): Notification {
51162
val stopIntent = Intent(ACTION_STOP).setPackage(packageName)
52163
val stopPi = PendingIntent.getBroadcast(
@@ -59,7 +170,7 @@ class TimerService : Service() {
59170
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
60171
)
61172
return NotificationCompat.Builder(this, CHANNEL_ID)
62-
.setContentTitle("Sift")
173+
.setContentTitle("Sift$currentPhaseName")
63174
.setContentText("Timer running")
64175
.setSmallIcon(R.drawable.ic_notification)
65176
.setOngoing(true)
@@ -70,12 +181,8 @@ class TimerService : Service() {
70181

71182
private fun createChannel() {
72183
val channel = NotificationChannel(
73-
CHANNEL_ID,
74-
"Timer Service",
75-
NotificationManager.IMPORTANCE_LOW
76-
).apply {
77-
description = "Keeps timer running in background"
78-
}
184+
CHANNEL_ID, "Timer Service", NotificationManager.IMPORTANCE_LOW
185+
).apply { description = "Keeps timer running in background" }
79186
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
80187
nm.createNotificationChannel(channel)
81188
}

work_timer/lib/screens/home_screen.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ class _HomeScreenState extends State<HomeScreen>
7272
_stop();
7373
} else if (action == 'silence') {
7474
_stopAlarm();
75+
} else if (action == 'alarm') {
76+
_tick(); // sync UI with current phase
7577
}
7678
}
7779
});
@@ -315,7 +317,10 @@ class _HomeScreenState extends State<HomeScreen>
315317
_ticker = Timer.periodic(const Duration(seconds: 1), (_) => _tick());
316318
_tick();
317319
unawaited(startTimerAudio());
318-
unawaited(startTimerService());
320+
unawaited(startTimerService(
321+
phaseNames: schedule.phases.map((p) => p.phase.name).toList(),
322+
phaseEndTimes: schedule.phases.map((p) => p.endTime).toList(),
323+
));
319324
final phase = schedule.phases.first;
320325
final laErr = await startLiveActivity(
321326
phaseName: phase.phase.name,

work_timer/lib/services/live_activity.dart

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,18 @@ Future<void> stopTimerAudio() async {
7070
try { await _channel.invokeMethod('stopTimerAudio'); } catch (_) {}
7171
}
7272

73-
/// Start Android foreground service — keeps process alive so alarm fires even on silent.
74-
Future<void> startTimerService() async {
73+
/// Start Android foreground service and schedule exact alarms at each phase boundary.
74+
Future<void> startTimerService({
75+
required List<String> phaseNames,
76+
required List<DateTime> phaseEndTimes,
77+
}) async {
7578
if (!Platform.isAndroid) return;
76-
try { await _channel.invokeMethod('startTimerService'); } catch (_) {}
79+
try {
80+
await _channel.invokeMethod('startTimerService', {
81+
'phaseNames': phaseNames,
82+
'phaseEndTimes': phaseEndTimes.map((t) => t.millisecondsSinceEpoch).toList(),
83+
});
84+
} catch (_) {}
7785
}
7886

7987
/// Stop the Android foreground service.

0 commit comments

Comments
 (0)