Public Access
232 lines
7.8 KiB
C#
232 lines
7.8 KiB
C#
#if WINDOWS
|
|
using System.Collections.Concurrent;
|
|
using CommunityToolkit.WinUI.Notifications;
|
|
using MeetingAssistant.Notifications;
|
|
|
|
namespace MeetingAssistant.Recording;
|
|
|
|
public sealed class WindowsMeetingInactivityPromptService : IMeetingInactivityPromptService, IHostedService, IDisposable
|
|
{
|
|
private const string NotificationSource = "meeting-inactivity-safeguard";
|
|
private const string NotificationGroup = "meeting-inactivity";
|
|
private readonly ConcurrentDictionary<string, PendingPrompt> pendingPrompts = new(StringComparer.OrdinalIgnoreCase);
|
|
private readonly ILogger<WindowsMeetingInactivityPromptService> logger;
|
|
private readonly object registrationGate = new();
|
|
private bool registered;
|
|
|
|
public WindowsMeetingInactivityPromptService(ILogger<WindowsMeetingInactivityPromptService> logger)
|
|
{
|
|
this.logger = logger;
|
|
}
|
|
|
|
public Task ShowStopPromptAsync(
|
|
MeetingInactivityPromptRequest request,
|
|
Func<MeetingInactivityPromptResponse, CancellationToken, Task> handleResponseAsync,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
|
|
{
|
|
logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
if (cancellationToken.IsCancellationRequested)
|
|
{
|
|
return Task.FromCanceled(cancellationToken);
|
|
}
|
|
|
|
try
|
|
{
|
|
EnsureRegistered();
|
|
var promptId = Guid.NewGuid().ToString("N");
|
|
pendingPrompts[promptId] = new PendingPrompt(handleResponseAsync);
|
|
logger.LogInformation(
|
|
"Registered native Windows inactivity notification {PromptId} response callback at threshold {Threshold}",
|
|
promptId,
|
|
request.Threshold);
|
|
var notification = BuildNotification(promptId, request);
|
|
notification.Show(toast =>
|
|
{
|
|
toast.Group = NotificationGroup;
|
|
toast.Tag = promptId;
|
|
toast.ExpirationTime = MeetingToastExpirationPolicy.StopReminderExpiration(DateTimeOffset.Now);
|
|
});
|
|
logger.LogInformation(
|
|
"Displayed native Windows inactivity notification {PromptId} at threshold {Threshold}",
|
|
promptId,
|
|
request.Threshold);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(exception, "Failed to display native Windows inactivity notification");
|
|
}
|
|
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763))
|
|
{
|
|
logger.LogWarning("Windows app notifications require Windows 10 version 1809 or later");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
EnsureRegistered();
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (!registered)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
ToastNotificationManagerCompat.OnActivated -= OnNotificationInvoked;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogWarning(exception, "Failed to detach native Windows toast notification activation handler");
|
|
}
|
|
}
|
|
|
|
private void EnsureRegistered()
|
|
{
|
|
if (registered)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (registrationGate)
|
|
{
|
|
if (registered)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ToastNotificationManagerCompat.OnActivated += OnNotificationInvoked;
|
|
registered = true;
|
|
logger.LogInformation("Registered native Windows toast notification activation handler");
|
|
}
|
|
}
|
|
|
|
private static ToastContentBuilder BuildNotification(
|
|
string promptId,
|
|
MeetingInactivityPromptRequest request)
|
|
{
|
|
var yesButton = new ToastButton()
|
|
.SetContent("Yes")
|
|
.AddArgument("source", NotificationSource)
|
|
.AddArgument("promptId", promptId)
|
|
.AddArgument("response", "stop")
|
|
.SetBackgroundActivation();
|
|
|
|
var noButton = new ToastButton()
|
|
.SetContent("No")
|
|
.AddArgument("source", NotificationSource)
|
|
.AddArgument("promptId", promptId)
|
|
.AddArgument("response", "continue")
|
|
.SetBackgroundActivation();
|
|
|
|
return new ToastContentBuilder()
|
|
.AddArgument("source", NotificationSource)
|
|
.AddArgument("promptId", promptId)
|
|
.SetToastScenario(ToastScenario.Reminder)
|
|
.SetToastDuration(ToastDuration.Long)
|
|
.AddText("Stop meeting?")
|
|
.AddText($"No transcript text has arrived for {FormatDuration(request.InactivityDuration)}.")
|
|
.AddButton(yesButton)
|
|
.AddButton(noButton);
|
|
}
|
|
|
|
private void OnNotificationInvoked(ToastNotificationActivatedEventArgsCompat args)
|
|
{
|
|
logger.LogInformation(
|
|
"Native Windows inactivity notification activation received with arguments {Arguments}",
|
|
args.Argument);
|
|
|
|
var arguments = NotificationActivationArguments.Parse(args.Argument);
|
|
if (!TryGetArgument(arguments, "source", out var source) ||
|
|
!string.Equals(source, NotificationSource, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
logger.LogDebug(
|
|
"Ignoring native Windows notification activation for unrelated source {Source}",
|
|
source);
|
|
return;
|
|
}
|
|
|
|
if (!TryGetArgument(arguments, "promptId", out var promptId))
|
|
{
|
|
logger.LogWarning("Ignoring native Windows inactivity notification activation without prompt id");
|
|
return;
|
|
}
|
|
|
|
if (!pendingPrompts.TryRemove(promptId, out var pendingPrompt))
|
|
{
|
|
logger.LogWarning(
|
|
"Ignoring native Windows inactivity notification {PromptId} activation because no pending prompt callback exists",
|
|
promptId);
|
|
return;
|
|
}
|
|
|
|
var response = TryGetArgument(arguments, "response", out var responseValue) &&
|
|
string.Equals(responseValue, "stop", StringComparison.OrdinalIgnoreCase)
|
|
? MeetingInactivityPromptResponse.Stop
|
|
: MeetingInactivityPromptResponse.Continue;
|
|
_ = Task.Run(async () =>
|
|
{
|
|
try
|
|
{
|
|
logger.LogInformation(
|
|
"Native Windows inactivity notification {PromptId} activated with response {Response}",
|
|
promptId,
|
|
response);
|
|
await pendingPrompt.HandleResponseAsync(response, CancellationToken.None);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
logger.LogError(
|
|
exception,
|
|
"Failed to handle native Windows inactivity notification response {Response}",
|
|
response);
|
|
}
|
|
});
|
|
}
|
|
|
|
private static bool TryGetArgument(
|
|
IReadOnlyDictionary<string, string> arguments,
|
|
string key,
|
|
out string value)
|
|
{
|
|
if (arguments.TryGetValue(key, out value!))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
value = "";
|
|
return false;
|
|
}
|
|
|
|
private static string FormatDuration(TimeSpan duration)
|
|
{
|
|
if (duration.TotalMinutes >= 1)
|
|
{
|
|
return $"{Math.Round(duration.TotalMinutes):0} minutes";
|
|
}
|
|
|
|
return $"{Math.Max(1, Math.Round(duration.TotalSeconds)):0} seconds";
|
|
}
|
|
|
|
private sealed record PendingPrompt(
|
|
Func<MeetingInactivityPromptResponse, CancellationToken, Task> HandleResponseAsync);
|
|
}
|
|
#endif
|