Skip to content

Commit 1a85679

Browse files
committed
Fix windows scheduler & notificaiton services
1 parent 8b89f6f commit 1a85679

File tree

6 files changed

+287
-18
lines changed

6 files changed

+287
-18
lines changed

src/Bible.Alarm/App.xaml.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,13 @@ protected override void OnStart()
8686

8787
var mediaIndexService = _serviceProvider.GetRequiredService<MediaIndexService>();
8888
await mediaIndexService.UpdateIndexIfAvailable();
89+
90+
#if WINDOWS
91+
// Reschedule any enabled alarms that may have fired while app was closed
92+
// This is a fallback for WinUI 3 which doesn't have background tasks
93+
var schedulerService = _serviceProvider.GetRequiredService<Services.Scheduler.Interfaces.ISchedulerService>();
94+
await schedulerService.HandleAsync();
95+
#endif
8996
}
9097
catch (Exception e)
9198
{
@@ -119,6 +126,13 @@ protected override void OnResume()
119126

120127
var mediaIndexService = _serviceProvider.GetRequiredService<MediaIndexService>();
121128
await mediaIndexService.UpdateIndexIfAvailable();
129+
130+
#if WINDOWS
131+
// Reschedule any enabled alarms that may have fired while app was in background
132+
// This is a fallback for WinUI 3 which doesn't have background tasks
133+
var schedulerService = _serviceProvider.GetRequiredService<Services.Scheduler.Interfaces.ISchedulerService>();
134+
await schedulerService.HandleAsync();
135+
#endif
122136
}
123137
catch (Exception e)
124138
{

src/Bible.Alarm/Platforms/Windows/App.xaml.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ private static void HandleActivation(string arguments)
106106

107107
var alarmHandler = MauiAppHolder.Services.GetRequiredService<WindowsAlarmHandler>();
108108
await alarmHandler.HandleAsync(scheduleId, true);
109+
110+
// Reschedule the next occurrence for recurring alarms
111+
// WinUI 3 doesn't have background tasks, so we reschedule immediately when notification fires
112+
var schedulerService = MauiAppHolder.Services.GetRequiredService<Bible.Alarm.Services.Scheduler.Interfaces.ISchedulerService>();
113+
await schedulerService.RescheduleNextOccurrenceAsync(scheduleId);
109114
}
110115
catch (Exception e)
111116
{

src/Bible.Alarm/Platforms/Windows/Services/UI/WindowsNotificationService.cs

Lines changed: 218 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
#nullable enable
2+
13
using Bible.Alarm.Common.Interfaces.UI;
24
using Bible.Alarm.Models.Schedule;
35
using Bible.Alarm.Platforms.Windows.Helpers;
46
using Bible.Alarm.Platforms.Windows.Services.Handlers;
5-
// Removed UWP toast notification APIs - using WinUI 3 alternatives
7+
using System.Linq;
8+
using Windows.ApplicationModel;
9+
using Windows.Data.Xml.Dom;
10+
using Windows.UI.Notifications;
611

712
namespace Bible.Alarm.Platforms.Windows.Services.UI
813
{
@@ -18,37 +23,235 @@ public async Task ShowNotificationAsync(int scheduleId)
1823
public Task ScheduleNotificationAsync(AlarmSchedule schedule,
1924
string title, string body)
2025
{
21-
// For WinUI 3 desktop apps, we can't use UWP toast notifications
22-
// This functionality would need to be implemented using alternative approaches
23-
// such as Windows Task Scheduler, Windows Notifications API, or a custom solution
24-
// For now, we'll return a completed task without scheduling
25-
// TODO: Implement proper notification scheduling for WinUI 3 desktop apps
26+
try
27+
{
28+
var scheduleId = schedule.Id;
29+
var time = schedule.NextFireDate();
30+
31+
if (time <= DateTimeOffset.Now)
32+
{
33+
Serilog.Log.Warning("Cannot schedule notification for schedule {ScheduleId}: time {Time} is in the past", scheduleId, time);
34+
return Task.CompletedTask;
35+
}
36+
37+
Serilog.Log.Information("Scheduling notification for schedule {ScheduleId} at {Time}", scheduleId, time);
38+
39+
var notifier = GetToastNotifier();
40+
if (notifier == null)
41+
{
42+
Serilog.Log.Error("Failed to create toast notifier for schedule {ScheduleId}. App may not be properly registered for notifications.", scheduleId);
43+
return Task.CompletedTask;
44+
}
45+
46+
var toast = CreateScheduledToast(scheduleId, title, body, time);
47+
notifier.AddToSchedule(toast);
48+
49+
if (IsNotificationScheduled(notifier, scheduleId))
50+
{
51+
Serilog.Log.Information("Successfully scheduled notification for schedule {ScheduleId} at {Time}", scheduleId, time);
52+
}
53+
else
54+
{
55+
Serilog.Log.Warning("Notification may not have been scheduled for schedule {ScheduleId}. Check Windows notification settings.", scheduleId);
56+
}
57+
}
58+
catch (Exception ex)
59+
{
60+
Serilog.Log.Error(ex, "Error scheduling notification for schedule {ScheduleId}", schedule.Id);
61+
}
62+
2663
return Task.CompletedTask;
2764
}
2865

2966
public Task RemoveAsync(int scheduleId)
3067
{
31-
// For WinUI 3 desktop apps, we can't use UWP toast notifications
32-
// This functionality would need to be implemented using alternative approaches
33-
// For now, we'll return a completed task without removing
34-
// TODO: Implement proper notification removal for WinUI 3 desktop apps
68+
try
69+
{
70+
var notifier = GetToastNotifier();
71+
if (notifier == null) return Task.CompletedTask;
72+
73+
var toRemove = FindScheduledToast(notifier, scheduleId);
74+
if (toRemove != null)
75+
{
76+
notifier.RemoveFromSchedule(toRemove);
77+
}
78+
}
79+
catch (Exception ex)
80+
{
81+
Serilog.Log.Error(ex, "Error removing notification for schedule {ScheduleId}", scheduleId);
82+
}
83+
3584
return Task.CompletedTask;
3685
}
3786

3887
public Task<bool> IsScheduledAsync(int scheduleId)
3988
{
40-
// For WinUI 3 desktop apps, we can't use UWP toast notifications
41-
// This functionality would need to be implemented using alternative approaches
42-
// For now, we'll return false
43-
// TODO: Implement proper notification checking for WinUI 3 desktop apps
44-
return Task.FromResult(false);
89+
try
90+
{
91+
var notifier = GetToastNotifier();
92+
if (notifier == null) return Task.FromResult(false);
93+
94+
return Task.FromResult(IsNotificationScheduled(notifier, scheduleId));
95+
}
96+
catch (Exception ex)
97+
{
98+
Serilog.Log.Error(ex, "Error checking if notification is scheduled for schedule {ScheduleId}", scheduleId);
99+
return Task.FromResult(false);
100+
}
45101
}
46102

47103
public Task<bool> CanScheduleAsync()
48104
{
49105
return Task.FromResult(WindowsBootstrapHelper.IsBackgroundTaskEnabled);
50106
}
51107

108+
private static ScheduledToastNotification CreateScheduledToast(int scheduleId, string title, string body, DateTimeOffset time)
109+
{
110+
var toastXml = CreateToastXml(title, body, scheduleId);
111+
return new ScheduledToastNotification(toastXml, time)
112+
{
113+
Id = scheduleId.ToString()
114+
};
115+
}
116+
117+
private static XmlDocument CreateToastXml(string title, string body, int scheduleId)
118+
{
119+
var toastXml = ToastNotificationManager.GetTemplateContent(ToastTemplateType.ToastText02);
120+
121+
var textElements = toastXml.GetElementsByTagName("text");
122+
if (textElements.Length > 0)
123+
{
124+
textElements[0].AppendChild(toastXml.CreateTextNode(title));
125+
}
126+
127+
if (textElements.Length > 1)
128+
{
129+
textElements[1].AppendChild(toastXml.CreateTextNode(body));
130+
}
131+
132+
var toastNode = toastXml.SelectSingleNode("/toast");
133+
if (toastNode?.Attributes != null)
134+
{
135+
var launchAttribute = toastXml.CreateAttribute("launch");
136+
launchAttribute.Value = scheduleId.ToString();
137+
toastNode.Attributes.SetNamedItem(launchAttribute);
138+
}
139+
140+
var audioNode = toastXml.CreateElement("audio");
141+
audioNode.SetAttribute("src", "ms-winsoundevent:Notification.Default");
142+
toastNode?.AppendChild(audioNode);
143+
144+
return toastXml;
145+
}
146+
147+
private static bool IsNotificationScheduled(ToastNotifier notifier, int scheduleId)
148+
{
149+
var scheduledToasts = notifier.GetScheduledToastNotifications();
150+
return scheduledToasts.Any(t => t.Id == scheduleId.ToString());
151+
}
152+
153+
private static ScheduledToastNotification? FindScheduledToast(ToastNotifier notifier, int scheduleId)
154+
{
155+
var scheduledToasts = notifier.GetScheduledToastNotifications();
156+
return scheduledToasts.FirstOrDefault(t => t.Id == scheduleId.ToString());
157+
}
158+
159+
private static ToastNotifier? GetToastNotifier()
160+
{
161+
var notifier = TryCreateNotifierWithoutParameters();
162+
if (notifier != null) return notifier;
163+
164+
notifier = TryCreateNotifierWithAumid();
165+
if (notifier != null) return notifier;
166+
167+
Serilog.Log.Error(
168+
"Unable to create toast notifier. Scheduled notifications will not work. " +
169+
"This is common in debug mode. Try running the app from an installed package instead of Visual Studio.");
170+
171+
return null;
172+
}
173+
174+
private static ToastNotifier? TryCreateNotifierWithoutParameters()
175+
{
176+
try
177+
{
178+
Serilog.Log.Debug("Attempting to create toast notifier without parameters...");
179+
var notifier = ToastNotificationManager.CreateToastNotifier();
180+
if (notifier != null)
181+
{
182+
Serilog.Log.Debug("Successfully created toast notifier without parameters");
183+
return notifier;
184+
}
185+
Serilog.Log.Warning("ToastNotificationManager.CreateToastNotifier() returned null");
186+
}
187+
catch (System.Runtime.InteropServices.COMException ex) when (ex.HResult == unchecked((int)0x80070490))
188+
{
189+
Serilog.Log.Warning("Failed to create toast notifier without parameters (0x80070490). Trying with AUMID...");
190+
}
191+
catch (Exception ex)
192+
{
193+
Serilog.Log.Warning(ex, "Exception creating toast notifier without parameters. HResult: 0x{HR:X8}", ex.HResult);
194+
}
195+
return null;
196+
}
197+
198+
private static ToastNotifier? TryCreateNotifierWithAumid()
199+
{
200+
try
201+
{
202+
var package = Package.Current;
203+
var packageId = package.Id;
204+
205+
var aumidFormats = new[]
206+
{
207+
$"{packageId.FamilyName}!App",
208+
packageId.FamilyName,
209+
packageId.Name,
210+
};
211+
212+
foreach (var aumid in aumidFormats)
213+
{
214+
var notifier = TryCreateNotifierWithAumid(aumid);
215+
if (notifier != null) return notifier;
216+
}
217+
218+
Serilog.Log.Warning(
219+
"Failed to create toast notifier with any AUMID format. " +
220+
"Package: {PackageName}, FamilyName: {FamilyName}, Publisher: {Publisher}",
221+
packageId.Name, packageId.FamilyName, packageId.Publisher);
222+
}
223+
catch (InvalidOperationException)
224+
{
225+
Serilog.Log.Warning(
226+
"Package.Current is not available. This is expected in debug mode or unpackaged WinUI 3 apps. " +
227+
"Scheduled notifications require the app to be properly packaged and installed.");
228+
}
229+
catch (Exception ex)
230+
{
231+
Serilog.Log.Error(ex, "Exception while trying to create toast notifier with AUMID");
232+
}
233+
return null;
234+
}
235+
236+
private static ToastNotifier? TryCreateNotifierWithAumid(string aumid)
237+
{
238+
try
239+
{
240+
Serilog.Log.Debug("Trying to create toast notifier with AUMID: {AUMID}", aumid);
241+
var notifier = ToastNotificationManager.CreateToastNotifier(aumid);
242+
if (notifier != null)
243+
{
244+
Serilog.Log.Information("Successfully created toast notifier with AUMID: {AUMID}", aumid);
245+
return notifier;
246+
}
247+
}
248+
catch (Exception ex)
249+
{
250+
Serilog.Log.Debug(ex, "Failed to create toast notifier with AUMID '{AUMID}'. HResult: 0x{HR:X8}", aumid, ex.HResult);
251+
}
252+
return null;
253+
}
254+
52255
public void Dispose()
53256
{
54257
}

src/Bible.Alarm/Platforms/Windows/Services/UI/WindowsToastService.cs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
using System.Linq;
1+
#nullable enable
2+
3+
using System.Linq;
24
using Bible.Alarm.Services.UI;
35
using Microsoft.Maui.ApplicationModel;
46
using Microsoft.Maui.Controls;
@@ -15,7 +17,7 @@ public class WindowsToastService : ToastService
1517
{
1618
private static readonly SemaphoreSlim Lock = new SemaphoreSlim(1);
1719

18-
private static TaskCompletionSource<bool> clearRequest;
20+
private static TaskCompletionSource<bool>? clearRequest;
1921
private readonly TaskScheduler _taskScheduler;
2022

2123
public WindowsToastService(TaskScheduler taskScheduler)
@@ -161,7 +163,14 @@ private static async Task ShowFlyoutAsync(Flyout flyout, FrameworkElement target
161163
flyout.OverlayInputPassThroughElement = targetElement;
162164
flyout.ShowAt(targetElement);
163165

164-
await Task.WhenAny(clearRequest.Task, Task.Delay((int)(seconds * 1000))).ConfigureAwait(true);
166+
if (clearRequest != null)
167+
{
168+
await Task.WhenAny(clearRequest.Task, Task.Delay((int)(seconds * 1000))).ConfigureAwait(true);
169+
}
170+
else
171+
{
172+
await Task.Delay((int)(seconds * 1000));
173+
}
165174
flyout.Hide();
166175
}
167176

src/Bible.Alarm/Services/Scheduler/Interfaces/ISchedulerService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ public interface ISchedulerService
44
{
55
Task ProcessScheduledTasksAsync();
66
Task<bool> HandleAsync();
7+
Task RescheduleNextOccurrenceAsync(int scheduleId);
78
}

src/Bible.Alarm/Services/Scheduler/SchedulerService.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,43 @@ public async Task<bool> HandleAsync()
7676
return downloaded;
7777
}
7878

79+
/// <summary>
80+
/// Reschedules the next occurrence of a recurring alarm.
81+
/// This is necessary for WinUI 3 which doesn't support UWP background tasks.
82+
/// </summary>
83+
public async Task RescheduleNextOccurrenceAsync(int scheduleId)
84+
{
85+
try
86+
{
87+
using var scope = _scopeFactory.CreateScope();
88+
var scheduleDbContext = scope.ServiceProvider.GetRequiredService<ScheduleDbContext>();
89+
var alarmService = scope.ServiceProvider.GetRequiredService<IAlarmService>();
90+
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
91+
92+
// Load the schedule with all includes
93+
var schedule = await scheduleDbContext.AlarmSchedules
94+
.Include(x => x.BibleReadingSchedule)
95+
.Include(x => x.Music)
96+
.FirstOrDefaultAsync(x => x.Id == scheduleId);
97+
98+
if (schedule != null && schedule.IsEnabled)
99+
{
100+
// Check if notification is already scheduled (shouldn't be, but check anyway)
101+
var isScheduled = await notificationService.IsScheduledAsync(scheduleId);
102+
if (!isScheduled)
103+
{
104+
// Reschedule the next occurrence
105+
await alarmService.Create(schedule);
106+
_logger.Information("Rescheduled next occurrence for schedule {ScheduleId}", scheduleId);
107+
}
108+
}
109+
}
110+
catch (Exception ex)
111+
{
112+
_logger.Error(ex, "Error rescheduling next occurrence for schedule {ScheduleId}", scheduleId);
113+
}
114+
}
115+
79116
public void Dispose()
80117
{
81118
// Note: DbContext is now created via IServiceScopeFactory and disposed by the scope

0 commit comments

Comments
 (0)