Public Access
Preserve recovery transcripts and add user-approved agent requests
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
PR and Push Build/Test / build-and-test (push) Successful in 12m0s
Append successful offline recovery with a duplicate-warning marker and retain failed replay sources. Offer newly available microphones through confirmation notifications. Add project discovery metadata and blocking association approval, plus nonblocking workflow requests that start a separate settings-agent conversation. Include regression tests, OpenSpec changes, and verified local Release startup.
This commit is contained in:
@@ -2,5 +2,7 @@ namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMicrophoneCaptureSourceFactory
|
||||
{
|
||||
// The caller owns the returned source and disposes it when it implements IDisposable,
|
||||
// including when capture is canceled before enumeration starts.
|
||||
IMeetingAudioSource CreateCapture(MeetingAssistantOptions options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public interface IMicrophoneSwitchPromptService
|
||||
{
|
||||
Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NoopMicrophoneSwitchPromptService : IMicrophoneSwitchPromptService
|
||||
{
|
||||
public Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(false);
|
||||
}
|
||||
@@ -33,6 +33,17 @@ public interface ITranscriptStore
|
||||
string line,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
async Task AppendLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> lines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (var line in lines)
|
||||
{
|
||||
await AppendLineAsync(session, line, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
|
||||
@@ -416,6 +416,12 @@ public sealed class MeetingRecordingCoordinator
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
if (run.ContextState is AssistantContextState.Summarizing or AssistantContextState.Finished or AssistantContextState.Error)
|
||||
{
|
||||
logger.LogInformation("Recording has reached summarization; continuing remaining processing in the background after the stop wait expired");
|
||||
return CurrentStatus;
|
||||
}
|
||||
|
||||
if (IsAzureSpeechRun(run))
|
||||
{
|
||||
logger.LogWarning("Timed out while draining Azure Speech transcription after recording stop; queueing offline transcription backlog item");
|
||||
|
||||
@@ -8,22 +8,30 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
private readonly IMicrophoneCaptureSourceFactory captureSources;
|
||||
private readonly ILogger<MicrophoneAudioSource> logger;
|
||||
private readonly TimeSpan recoveryDelay;
|
||||
private readonly MicrophoneSwitchMonitor? switchMonitor;
|
||||
private readonly MicrophoneDeviceSelection? selection;
|
||||
|
||||
public MicrophoneAudioSource(
|
||||
IMicrophoneCaptureSourceFactory captureSources,
|
||||
ILogger<MicrophoneAudioSource> logger)
|
||||
: this(captureSources, logger, DefaultRecoveryDelay)
|
||||
ILogger<MicrophoneAudioSource> logger,
|
||||
MicrophoneSwitchMonitor? switchMonitor = null,
|
||||
MicrophoneDeviceSelection? selection = null)
|
||||
: this(captureSources, logger, DefaultRecoveryDelay, switchMonitor, selection)
|
||||
{
|
||||
}
|
||||
|
||||
internal MicrophoneAudioSource(
|
||||
IMicrophoneCaptureSourceFactory captureSources,
|
||||
ILogger<MicrophoneAudioSource> logger,
|
||||
TimeSpan recoveryDelay)
|
||||
TimeSpan recoveryDelay,
|
||||
MicrophoneSwitchMonitor? switchMonitor = null,
|
||||
MicrophoneDeviceSelection? selection = null)
|
||||
{
|
||||
this.captureSources = captureSources;
|
||||
this.logger = logger;
|
||||
this.recoveryDelay = recoveryDelay;
|
||||
this.switchMonitor = switchMonitor;
|
||||
this.selection = selection;
|
||||
}
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
@@ -34,21 +42,44 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
public async IAsyncEnumerable<AudioChunk> CaptureAsync(
|
||||
MeetingAssistantOptions options,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
using var session = new CaptureSession(selection, cancellationToken);
|
||||
var monitoring = switchMonitor?.RunAsync(session.SwitchTo, session.Token) ?? Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
await foreach (var chunk in CaptureWithRecoveryAsync(options, session, session.Token))
|
||||
{
|
||||
yield return chunk;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Stop();
|
||||
await monitoring;
|
||||
}
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AudioChunk> CaptureWithRecoveryAsync(
|
||||
MeetingAssistantOptions options,
|
||||
CaptureSession session,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var failedAttempts = 0;
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var captureToken = session.BeginCapture();
|
||||
IMeetingAudioSource? captureSource = null;
|
||||
IAsyncEnumerator<AudioChunk>? capture = null;
|
||||
Exception? failure = null;
|
||||
|
||||
try
|
||||
{
|
||||
capture = captureSources
|
||||
.CreateCapture(options)
|
||||
.CaptureAsync(options, cancellationToken)
|
||||
.GetAsyncEnumerator(cancellationToken);
|
||||
captureSource = captureSources.CreateCapture(options);
|
||||
capture = captureSource
|
||||
.CaptureAsync(options, captureToken)
|
||||
.GetAsyncEnumerator(captureToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -56,6 +87,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
failure = exception;
|
||||
}
|
||||
|
||||
using var captureOwner = captureSource as IDisposable;
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
@@ -65,14 +97,14 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
while (!captureToken.IsCancellationRequested)
|
||||
{
|
||||
var hasNext = false;
|
||||
try
|
||||
{
|
||||
hasNext = await capture.MoveNextAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -80,7 +112,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
failure = exception;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested || failure is not null || !hasNext)
|
||||
if (captureToken.IsCancellationRequested || failure is not null || !hasNext)
|
||||
{
|
||||
break;
|
||||
}
|
||||
@@ -102,7 +134,7 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
{
|
||||
await capture.DisposeAsync();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
catch (OperationCanceledException) when (captureToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -112,11 +144,19 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
}
|
||||
}
|
||||
|
||||
var switchRequested = captureToken.IsCancellationRequested;
|
||||
session.EndCapture();
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (switchRequested)
|
||||
{
|
||||
logger.LogInformation("Replacing microphone capture after an accepted device-switch notification");
|
||||
continue;
|
||||
}
|
||||
|
||||
failedAttempts++;
|
||||
logger.LogWarning(
|
||||
failure,
|
||||
@@ -142,4 +182,53 @@ public sealed class MicrophoneAudioSource : IMeetingAudioSource
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class CaptureSession(MicrophoneDeviceSelection? selection, CancellationToken cancellationToken) : IDisposable
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly CancellationTokenSource lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
private CancellationTokenSource? capture;
|
||||
|
||||
public CancellationToken Token => lifetime.Token;
|
||||
|
||||
public CancellationToken BeginCapture()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
capture = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
return capture.Token;
|
||||
}
|
||||
}
|
||||
|
||||
public void EndCapture()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
capture?.Dispose();
|
||||
capture = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void SwitchTo(MicrophoneDevice device, CancellationToken promptCancellation)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (lifetime.IsCancellationRequested || promptCancellation.IsCancellationRequested || selection is null) return;
|
||||
selection.Select(device.Id);
|
||||
capture?.Cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
lock (gate) lifetime.Cancel();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
EndCapture();
|
||||
lifetime.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class MicrophoneSwitchMonitor
|
||||
{
|
||||
private readonly IMicrophoneDeviceProvider devices;
|
||||
private readonly IMicrophoneSwitchPromptService prompts;
|
||||
private readonly ILogger<MicrophoneSwitchMonitor> logger;
|
||||
private readonly TimeSpan pollInterval;
|
||||
private readonly TimeSpan promptLifetime;
|
||||
|
||||
public MicrophoneSwitchMonitor(
|
||||
IMicrophoneDeviceProvider devices,
|
||||
IMicrophoneSwitchPromptService prompts,
|
||||
ILogger<MicrophoneSwitchMonitor> logger)
|
||||
: this(devices, prompts, logger, TimeSpan.FromSeconds(1), TimeSpan.FromMinutes(1))
|
||||
{
|
||||
}
|
||||
|
||||
internal MicrophoneSwitchMonitor(
|
||||
IMicrophoneDeviceProvider devices,
|
||||
IMicrophoneSwitchPromptService prompts,
|
||||
ILogger<MicrophoneSwitchMonitor> logger,
|
||||
TimeSpan pollInterval,
|
||||
TimeSpan promptLifetime)
|
||||
{
|
||||
this.devices = devices;
|
||||
this.prompts = prompts;
|
||||
this.logger = logger;
|
||||
this.pollInterval = pollInterval;
|
||||
this.promptLifetime = promptLifetime;
|
||||
}
|
||||
|
||||
public async Task RunAsync(Action<MicrophoneDevice, CancellationToken> switchMicrophone, CancellationToken cancellationToken)
|
||||
{
|
||||
var known = (ReadDevices() ?? []).Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var pending = new Dictionary<string, PendingPrompt>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
await Task.Delay(pollInterval, cancellationToken);
|
||||
var available = ReadDevices();
|
||||
if (available is null) continue;
|
||||
var availableIds = available.Select(device => device.Id).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (id, prompt) in pending.ToArray())
|
||||
{
|
||||
if (!availableIds.Contains(id)) await prompt.Cancellation.CancelAsync();
|
||||
if (prompt.Task.IsCompleted)
|
||||
{
|
||||
prompt.Cancellation.Dispose();
|
||||
pending.Remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var device in available)
|
||||
{
|
||||
if (known.Add(device.Id))
|
||||
{
|
||||
var promptCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
promptCancellation.CancelAfter(promptLifetime);
|
||||
pending.Add(device.Id, new PendingPrompt(promptCancellation,
|
||||
OfferAsync(device, switchMicrophone, promptCancellation.Token)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var prompt in pending.Values) await prompt.Cancellation.CancelAsync();
|
||||
await Task.WhenAll(pending.Values.Select(prompt => prompt.Task));
|
||||
foreach (var prompt in pending.Values) prompt.Cancellation.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OfferAsync(MicrophoneDevice device, Action<MicrophoneDevice, CancellationToken> switchMicrophone,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await prompts.ConfirmSwitchAsync(device, cancellationToken)
|
||||
&& !cancellationToken.IsCancellationRequested
|
||||
&& ReadDevices()?.Any(current => current.Id.Equals(device.Id, StringComparison.OrdinalIgnoreCase)) == true
|
||||
&& !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
switchMicrophone(device, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not offer microphone {MicrophoneName}", device.Name);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<MicrophoneDevice>? ReadDevices()
|
||||
{
|
||||
try
|
||||
{
|
||||
return devices.GetAvailableMicrophones();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Could not check for newly available microphones");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PendingPrompt(CancellationTokenSource Cancellation, Task Task);
|
||||
}
|
||||
@@ -4,11 +4,12 @@ using NAudio.Wave;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource, IDisposable
|
||||
{
|
||||
private readonly IWaveIn capture;
|
||||
private readonly string sourceName;
|
||||
private readonly ILogger logger;
|
||||
private int disposed;
|
||||
|
||||
public NaudioCaptureAudioSource(
|
||||
IWaveIn capture,
|
||||
@@ -22,13 +23,15 @@ internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
|
||||
public IAsyncEnumerable<AudioChunk> CaptureAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return CaptureWith(capture, sourceName, logger, cancellationToken);
|
||||
return CaptureWith(cancellationToken);
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
IWaveIn capture,
|
||||
string sourceName,
|
||||
ILogger logger,
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 0) capture.Dispose();
|
||||
}
|
||||
|
||||
private async IAsyncEnumerable<AudioChunk> CaptureWith(
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
var channel = Channel.CreateUnbounded<AudioChunk>();
|
||||
@@ -83,7 +86,7 @@ internal sealed class NaudioCaptureAudioSource : IMeetingAudioSource
|
||||
}
|
||||
finally
|
||||
{
|
||||
capture.Dispose();
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,8 +117,25 @@ public sealed class OfflineTranscriptionBacklogProcessor
|
||||
pipelineOptions,
|
||||
cancellationToken);
|
||||
|
||||
if (!finishedSegments.Any(segment =>
|
||||
segment.Kind == TranscriptionSegmentKind.Speech && !string.IsNullOrWhiteSpace(segment.Text)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Offline transcription replay returned no speech text; preserving the existing transcript and recovery files.");
|
||||
}
|
||||
|
||||
var lines = await TransformTranscriptLinesAsync(artifacts, runOptions, finishedSegments, cancellationToken);
|
||||
await transcriptStore.ReplaceLinesAsync(session, lines, cancellationToken);
|
||||
await transcriptStore.AppendLinesAsync(session,
|
||||
[
|
||||
"",
|
||||
"## Offline transcription recovery",
|
||||
"",
|
||||
"> The following transcript was recovered by replaying the entire recording. "
|
||||
+ "It may duplicate earlier transcript passages. "
|
||||
+ "Treat repeated passages as the same discussion when summarizing.",
|
||||
"",
|
||||
.. lines
|
||||
], cancellationToken);
|
||||
|
||||
meetingNote.Frontmatter.EndTime = item.InferredEndTime ?? DateTimeOffset.Now;
|
||||
var completedMeetingNote = await meetingNoteStore.SaveAsync(meetingNote, runOptions, cancellationToken);
|
||||
|
||||
@@ -49,6 +49,25 @@ public sealed class VaultTranscriptStore : ITranscriptStore
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public Task AppendLinesAsync(
|
||||
TranscriptSession session,
|
||||
IReadOnlyList<string> lines,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return WithFileGateAsync(
|
||||
session.TranscriptPath,
|
||||
() => File.AppendAllTextAsync(
|
||||
session.TranscriptPath,
|
||||
string.Concat(lines.Select(line => line + Environment.NewLine)),
|
||||
cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
public async Task ReplaceLineAsync(
|
||||
TranscriptSession session,
|
||||
TranscriptLineReference lineReference,
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#if WINDOWS
|
||||
using System.Collections.Concurrent;
|
||||
using CommunityToolkit.WinUI.Notifications;
|
||||
|
||||
namespace MeetingAssistant.Recording;
|
||||
|
||||
public sealed class WindowsMicrophoneSwitchPromptService : IMicrophoneSwitchPromptService, IDisposable
|
||||
{
|
||||
private const string Source = "meeting-microphone-switch";
|
||||
private readonly ConcurrentDictionary<string, TaskCompletionSource<bool>> pending = new();
|
||||
private readonly ILogger<WindowsMicrophoneSwitchPromptService> logger;
|
||||
private readonly object gate = new();
|
||||
private bool registered;
|
||||
|
||||
public WindowsMicrophoneSwitchPromptService(ILogger<WindowsMicrophoneSwitchPromptService> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> ConfirmSwitchAsync(MicrophoneDevice microphone, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!OperatingSystem.IsWindowsVersionAtLeast(10, 0, 17763)) return false;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var id = Guid.NewGuid().ToString("N");
|
||||
var response = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (!registered)
|
||||
{
|
||||
ToastNotificationManagerCompat.OnActivated += OnActivated;
|
||||
registered = true;
|
||||
}
|
||||
|
||||
pending[id] = response;
|
||||
new ToastContentBuilder()
|
||||
.AddArgument("source", Source)
|
||||
.AddArgument("promptId", id)
|
||||
.SetToastScenario(ToastScenario.Reminder)
|
||||
.SetToastDuration(ToastDuration.Long)
|
||||
.AddText("New microphone available")
|
||||
.AddText($"Switch this meeting to {microphone.Name}?")
|
||||
.AddButton(new ToastButton().SetContent("Yes")
|
||||
.AddArgument("source", Source).AddArgument("promptId", id)
|
||||
.AddArgument("response", "yes").SetBackgroundActivation())
|
||||
.AddButton(new ToastButton().SetContent("No")
|
||||
.AddArgument("source", Source).AddArgument("promptId", id)
|
||||
.AddArgument("response", "no").SetBackgroundActivation())
|
||||
.Show(toast =>
|
||||
{
|
||||
toast.Tag = id;
|
||||
toast.Group = Source;
|
||||
toast.ExpirationTime = DateTimeOffset.Now.AddMinutes(1);
|
||||
});
|
||||
}
|
||||
|
||||
logger.LogInformation("Offered newly available microphone {MicrophoneName}", microphone.Name);
|
||||
return await response.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
pending.TryRemove(id, out _);
|
||||
try
|
||||
{
|
||||
ToastNotificationManagerCompat.History.Remove(id, Source);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogDebug(exception, "Could not remove microphone notification {PromptId}", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnActivated(ToastNotificationActivatedEventArgsCompat args)
|
||||
{
|
||||
var arguments = NotificationActivationArguments.Parse(args.Argument);
|
||||
if (arguments.GetValueOrDefault("source") != Source ||
|
||||
!arguments.TryGetValue("promptId", out var id) ||
|
||||
!pending.TryRemove(id, out var response)) return;
|
||||
|
||||
response.TrySetResult(arguments.GetValueOrDefault("response") == "yes");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (registered) ToastNotificationManagerCompat.OnActivated -= OnActivated;
|
||||
registered = false;
|
||||
foreach (var response in pending.Values) response.TrySetResult(false);
|
||||
pending.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user