Skip to content

Commit 5cd723b

Browse files
sharpninjaCopilot
andcommitted
Refactor TODO/voice flows and add agent event notifications
Consolidate shared TODO prompt handling, add core agent SSE listener, wire Android/Windows system notifications, add simplified desktop voice tab, and update McpServer submodule. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ffbc2a5 commit 5cd723b

14 files changed

Lines changed: 889 additions & 17 deletions

docs/todo.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,11 @@ Features:
9494
done: true
9595
- task: Build and verify no compilation errors
9696
done: true
97+
DEPLOY-STATUS:
98+
high-priority:
99+
- id: deploy-status-20260302-1512z
100+
title: APK Published and Verified at 20260302-1512Z
101+
done: true
102+
description:
103+
- Pipeline run 22580481045 completed successfully.
104+
- F-Droid index-v1.json resolves to McpServerManager-0.0.1-160.apk (versionCode 101) at https://sharpninja.github.io/McpServerManager/repo.

lib/McpServer

Submodule McpServer updated 156 files

src/McpServerManager.Android/App.axaml.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,14 @@ void OpenMainView(string mcpBaseUrl, string? mcpApiKey, string? bearerToken)
7676
}
7777

7878
var clipboardService = new AndroidClipboardService();
79-
var vm = new MainWindowViewModel(clipboardService, mcpBaseUrl, mcpApiKey, bearerToken);
79+
var systemNotificationService = new AndroidSystemNotificationService();
80+
var vm = new MainWindowViewModel(clipboardService, mcpBaseUrl, mcpApiKey, bearerToken, systemNotificationService);
8081
vm.SaveWorkspaceKey = AndroidConnectionPreferencesService.SaveWorkspaceKey;
8182
vm.LoadWorkspaceKey = AndroidConnectionPreferencesService.LoadWorkspaceKey;
8283
vm.LogoutRequested += (_, _) =>
8384
{
8485
_logger.LogInformation("Logout requested; clearing tokens and returning to connection dialog");
86+
connectionVm.LogoutCommand.Execute(null);
8587
AndroidConnectionPreferencesService.ClearOidcJwt();
8688
connectionVm.IsConnecting = false;
8789
connectionVm.ErrorMessage = "";
@@ -145,6 +147,13 @@ private static void WireGlobalExceptionHandlers()
145147
StatusViewModel.Instance.AddStatus(ex?.ToString() ?? args.ExceptionObject?.ToString() ?? "Unknown unhandled exception");
146148
};
147149

150+
global::Android.Runtime.AndroidEnvironment.UnhandledExceptionRaiser += (_, args) =>
151+
{
152+
_logger.LogError(args.Exception, "Android Java unhandled exception");
153+
StatusViewModel.Instance.AddStatus(args.Exception?.ToString() ?? "Unknown Android Java unhandled exception");
154+
args.Handled = false;
155+
};
156+
148157
TaskScheduler.UnobservedTaskException += (_, args) =>
149158
{
150159
_logger.LogError(args.Exception, "Unobserved task exception");
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using Android.App;
5+
using Android.Content;
6+
using Android.Content.PM;
7+
using Android.OS;
8+
using Microsoft.Extensions.Logging;
9+
using McpServerManager.Core.Models;
10+
using McpServerManager.Core.Services;
11+
12+
namespace McpServerManager.Android.Services;
13+
14+
/// <summary>
15+
/// Android implementation for posting actionable agent event notifications.
16+
/// </summary>
17+
public sealed class AndroidSystemNotificationService : ISystemNotificationService
18+
{
19+
private const string ChannelId = "mcp_agent_events";
20+
private const int LaunchRequestCode = 43001;
21+
private static int _nextNotificationId = 43000;
22+
23+
private static readonly ILogger _logger = AppLogService.Instance.CreateLogger("AndroidSystemNotificationService");
24+
25+
public async Task NotifyAgentEventAsync(
26+
McpIncomingChangeEvent changeEvent,
27+
string message,
28+
CancellationToken cancellationToken = default)
29+
{
30+
ArgumentNullException.ThrowIfNull(changeEvent);
31+
32+
var notificationMessage = string.IsNullOrWhiteSpace(message)
33+
? "Agent event received."
34+
: message.Trim();
35+
36+
try
37+
{
38+
var context = Application.Context;
39+
if (context == null)
40+
{
41+
_logger.LogWarning("Skipping agent event notification: Android application context is null.");
42+
return;
43+
}
44+
45+
if (!await EnsureNotificationPermissionAsync(context, cancellationToken).ConfigureAwait(false))
46+
return;
47+
48+
var manager = context.GetSystemService(Context.NotificationService) as NotificationManager;
49+
if (manager == null)
50+
{
51+
_logger.LogWarning("Skipping agent event notification: NotificationManager unavailable.");
52+
return;
53+
}
54+
55+
EnsureChannel(manager);
56+
57+
var builder = new Notification.Builder(context)
58+
.SetSmallIcon(global::Android.Resource.Drawable.IcDialogInfo)
59+
.SetContentTitle("Request Tracker")
60+
.SetContentText(notificationMessage)
61+
.SetStyle(new Notification.BigTextStyle().BigText(notificationMessage))
62+
.SetAutoCancel(true)
63+
.SetWhen(Java.Lang.JavaSystem.CurrentTimeMillis())
64+
.SetPriority((int)NotificationPriority.Default);
65+
66+
var launchPendingIntent = CreateLaunchPendingIntent(context);
67+
if (launchPendingIntent != null)
68+
builder.SetContentIntent(launchPendingIntent);
69+
70+
if (Build.VERSION.SdkInt >= BuildVersionCodes.O)
71+
builder.SetChannelId(ChannelId);
72+
73+
manager.Notify(Interlocked.Increment(ref _nextNotificationId), builder.Build());
74+
}
75+
catch (System.OperationCanceledException) when (cancellationToken.IsCancellationRequested)
76+
{
77+
}
78+
catch (Exception ex)
79+
{
80+
_logger.LogWarning(ex, "Failed to post agent event notification.");
81+
}
82+
}
83+
84+
private static async Task<bool> EnsureNotificationPermissionAsync(Context context, CancellationToken cancellationToken)
85+
{
86+
if ((int)Build.VERSION.SdkInt < (int)BuildVersionCodes.Tiramisu)
87+
return true;
88+
89+
if (context.CheckSelfPermission(global::Android.Manifest.Permission.PostNotifications) == Permission.Granted)
90+
return true;
91+
92+
var activity = AndroidActivityHost.TryGetCurrentActivity();
93+
if (activity == null)
94+
{
95+
_logger.LogDebug("Skipping agent event notification: no activity available to request POST_NOTIFICATIONS.");
96+
return false;
97+
}
98+
99+
try
100+
{
101+
var granted = await AndroidActivityHost
102+
.RequestPostNotificationsPermissionAsync(activity, cancellationToken)
103+
.ConfigureAwait(false);
104+
105+
if (!granted)
106+
_logger.LogDebug("Skipping agent event notification: POST_NOTIFICATIONS permission denied.");
107+
108+
return granted;
109+
}
110+
catch (System.OperationCanceledException)
111+
{
112+
return false;
113+
}
114+
}
115+
116+
private static PendingIntent? CreateLaunchPendingIntent(Context context)
117+
{
118+
var packageName = context.PackageName;
119+
if (string.IsNullOrWhiteSpace(packageName))
120+
return null;
121+
122+
var launchIntent = context.PackageManager?.GetLaunchIntentForPackage(packageName);
123+
if (launchIntent == null)
124+
return null;
125+
126+
launchIntent.AddFlags(
127+
ActivityFlags.NewTask |
128+
ActivityFlags.SingleTop |
129+
ActivityFlags.ClearTop |
130+
ActivityFlags.ReorderToFront);
131+
132+
var flags = PendingIntentFlags.UpdateCurrent;
133+
if (Build.VERSION.SdkInt >= BuildVersionCodes.M)
134+
flags |= PendingIntentFlags.Immutable;
135+
136+
return PendingIntent.GetActivity(context, LaunchRequestCode, launchIntent, flags);
137+
}
138+
139+
private static void EnsureChannel(NotificationManager manager)
140+
{
141+
if (Build.VERSION.SdkInt < BuildVersionCodes.O)
142+
return;
143+
144+
if (manager.GetNotificationChannel(ChannelId) != null)
145+
return;
146+
147+
var channel = new NotificationChannel(
148+
ChannelId,
149+
"Agent Events",
150+
NotificationImportance.Default)
151+
{
152+
Description = "Notifications for actionable agent events."
153+
};
154+
155+
manager.CreateNotificationChannel(channel);
156+
}
157+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Text.Json;
4+
using System.Text.Json.Serialization;
5+
6+
namespace McpServerManager.Core.Models;
7+
8+
/// <summary>
9+
/// Incoming change event payload emitted by <c>/mcpserver/events</c>.
10+
/// Includes extension data for forward-compatible fields.
11+
/// </summary>
12+
public sealed record McpIncomingChangeEvent
13+
{
14+
[JsonPropertyName("category")]
15+
public string? Category { get; init; }
16+
17+
[JsonPropertyName("action")]
18+
public string? Action { get; init; }
19+
20+
[JsonPropertyName("entityId")]
21+
public string? EntityId { get; init; }
22+
23+
[JsonPropertyName("resourceUri")]
24+
public string? ResourceUri { get; init; }
25+
26+
[JsonPropertyName("timestamp")]
27+
public DateTimeOffset? Timestamp { get; init; }
28+
29+
[JsonPropertyName("agentId")]
30+
public string? AgentId { get; init; }
31+
32+
[JsonPropertyName("eventType")]
33+
public string? EventType { get; init; }
34+
35+
[JsonPropertyName("status")]
36+
public string? Status { get; init; }
37+
38+
[JsonPropertyName("workspacePath")]
39+
public string? WorkspacePath { get; init; }
40+
41+
[JsonPropertyName("message")]
42+
public string? Message { get; init; }
43+
44+
/// <summary>SSE event name from the <c>event:</c> line when provided.</summary>
45+
[JsonIgnore]
46+
public string? SseEvent { get; init; }
47+
48+
[JsonExtensionData]
49+
public IDictionary<string, JsonElement>? ExtensionData { get; init; }
50+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
using System;
2+
using System.Threading;
3+
using System.Threading.Tasks;
4+
using McpServerManager.Core.Models;
5+
6+
namespace McpServerManager.Core.Services;
7+
8+
/// <summary>
9+
/// Abstraction for delivering system-level notifications from actionable agent events.
10+
/// </summary>
11+
public interface ISystemNotificationService
12+
{
13+
/// <summary>
14+
/// Notifies the user about an actionable agent event.
15+
/// </summary>
16+
Task NotifyAgentEventAsync(
17+
McpIncomingChangeEvent changeEvent,
18+
string message,
19+
CancellationToken cancellationToken = default);
20+
}
21+
22+
/// <summary>
23+
/// Default no-op notification service when a platform implementation is not provided.
24+
/// </summary>
25+
public sealed class NoOpSystemNotificationService : ISystemNotificationService
26+
{
27+
public static NoOpSystemNotificationService Instance { get; } = new();
28+
29+
private NoOpSystemNotificationService()
30+
{
31+
}
32+
33+
public Task NotifyAgentEventAsync(
34+
McpIncomingChangeEvent changeEvent,
35+
string message,
36+
CancellationToken cancellationToken = default)
37+
{
38+
ArgumentNullException.ThrowIfNull(changeEvent);
39+
return Task.CompletedTask;
40+
}
41+
}

0 commit comments

Comments
 (0)