Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,8 @@ auto-assigned by the broker (range 10223–10899), or configurable via `.mauidev
| `/api/platform/permissions` | GET | Check status of all known permissions |
| `/api/platform/permissions/{name}` | GET | Check a specific permission status |
| `/api/platform/geolocation` | GET | GPS coordinates. `?accuracy=Medium` `?timeout=10` |

Platform endpoints return structured JSON errors on failure. In addition to `success` and `error`, failures may include a machine-readable `reason` and `details` object so clients can distinguish cases like `missing_permission`, `not_supported`, `main_thread_required`, `timeout`, and `unknown` without parsing the error text.
| `/api/sensors` | GET | List all sensors with support/active/subscriber status |
| `/api/sensors/{sensor}/start` | POST | Start sensor. `?speed=UI\|Game\|Fastest\|Default` |
| `/api/sensors/{sensor}/stop` | POST | Stop sensor |
Expand Down
36 changes: 31 additions & 5 deletions src/MauiDevFlow.Agent.Core/AgentHttpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;

namespace MauiDevFlow.Agent.Core;

Expand Down Expand Up @@ -467,12 +468,37 @@ public class HttpResponse
Body = JsonSerializer.Serialize(new { success = true, message })
};

public static HttpResponse Error(string message, int statusCode = 400) => new()
public static HttpResponse Error(string message, int statusCode = 400, string? reason = null, object? details = null)
{
StatusCode = statusCode,
StatusText = statusCode == 404 ? "Not Found" : "Bad Request",
Body = JsonSerializer.Serialize(new { success = false, error = message })
};
var body = new Dictionary<string, object?>
{
["success"] = false,
["error"] = message
};

if (!string.IsNullOrWhiteSpace(reason))
body["reason"] = reason;

if (details != null)
body["details"] = details;

return new HttpResponse
{
StatusCode = statusCode,
StatusText = statusCode switch
{
403 => "Forbidden",
404 => "Not Found",
408 => "Request Timeout",
500 => "Internal Server Error",
_ => "Bad Request"
},
Body = JsonSerializer.Serialize(body, new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
})
};
}

public static HttpResponse NotFound(string message = "Not found") => Error(message, 404);
}
145 changes: 131 additions & 14 deletions src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using Microsoft.Maui;
Expand Down Expand Up @@ -903,7 +904,7 @@
var scale = (float)targetWidth.Value / original.Width;
var newHeight = (int)(original.Height * scale);

using var resized = original.Resize(new SkiaSharp.SKImageInfo(targetWidth.Value, newHeight), SkiaSharp.SKFilterQuality.Medium);

Check warning on line 907 in src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

'SKBitmap.Resize(SKImageInfo, SKFilterQuality)' is obsolete: 'Use Resize(SKImageInfo info, SKSamplingOptions sampling) instead.'

Check warning on line 907 in src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

'SKFilterQuality' is obsolete: 'Use SKSamplingOptions instead.'

Check warning on line 907 in src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

'SKBitmap.Resize(SKImageInfo, SKFilterQuality)' is obsolete: 'Use Resize(SKImageInfo info, SKSamplingOptions sampling) instead.'

Check warning on line 907 in src/MauiDevFlow.Agent.Core/DevFlowAgentService.cs

View workflow job for this annotation

GitHub Actions / build-and-test (macos-latest)

'SKFilterQuality' is obsolete: 'Use SKSamplingOptions instead.'
if (resized == null) return pngData;

using var image = SkiaSharp.SKImage.FromBitmap(resized);
Expand Down Expand Up @@ -3830,6 +3831,109 @@

// ── Platform info endpoints ──

private const string PlatformErrorReasonMissingPermission = "missing_permission";
private const string PlatformErrorReasonNotSupported = "not_supported";
private const string PlatformErrorReasonMainThreadRequired = "main_thread_required";
private const string PlatformErrorReasonTimeout = "timeout";
private const string PlatformErrorReasonUnknown = "unknown";
private const string PlatformErrorReasonInvalidRequest = "invalid_request";
private static readonly Regex AndroidPermissionRegex = new(@"android\.permission\.[A-Z0-9_\.]+", RegexOptions.IgnoreCase | RegexOptions.Compiled);

private static HttpResponse CreatePlatformError(string message, Exception ex, int statusCode = 400, Dictionary<string, object?>? details = null)
{
var payload = BuildPlatformErrorPayload(ex, details);
return HttpResponse.Error(message, payload.StatusCode ?? statusCode, payload.Reason, payload.Details);
}

private static HttpResponse CreatePlatformError(string message, string reason, int statusCode = 400, Dictionary<string, object?>? details = null)
{
var payloadDetails = CreatePlatformErrorDetails();
if (details != null)
{
foreach (var (key, value) in details)
{
if (value != null)
payloadDetails[key] = value;
}
}

return HttpResponse.Error(message, statusCode, reason, payloadDetails.Count > 0 ? payloadDetails : null);
}

private static (string Reason, Dictionary<string, object?>? Details, int? StatusCode) BuildPlatformErrorPayload(
Exception ex,
Dictionary<string, object?>? details = null)
{
var payloadDetails = CreatePlatformErrorDetails();
if (details != null)
{
foreach (var (key, value) in details)
{
if (value != null)
payloadDetails[key] = value;
}
}

if (IsMissingPermissionException(ex))
{
if (TryExtractPermission(ex.Message) is { Length: > 0 } permission)
payloadDetails["permission"] = permission;

return (PlatformErrorReasonMissingPermission, payloadDetails.Count > 0 ? payloadDetails : null, 403);
}

if (IsMainThreadAccessException(ex))
return (PlatformErrorReasonMainThreadRequired, payloadDetails.Count > 0 ? payloadDetails : null, null);

if (ex is TimeoutException or TaskCanceledException or OperationCanceledException)
return (PlatformErrorReasonTimeout, payloadDetails.Count > 0 ? payloadDetails : null, 408);

if (ex is FeatureNotSupportedException or NotSupportedException or PlatformNotSupportedException or FeatureNotEnabledException)
{
if (ex is FeatureNotEnabledException)
payloadDetails["enabled"] = false;

return (PlatformErrorReasonNotSupported, payloadDetails.Count > 0 ? payloadDetails : null, null);
}

payloadDetails["exceptionType"] = ex.GetType().Name;
return (PlatformErrorReasonUnknown, payloadDetails, null);
}

private static Dictionary<string, object?> CreatePlatformErrorDetails()
{
var details = new Dictionary<string, object?>(StringComparer.Ordinal);
try
{
details["platform"] = DeviceInfo.Current.Platform.ToString();
}
catch
{
}

return details;
}

private static bool IsMissingPermissionException(Exception ex)
{
return ex is PermissionException
|| AndroidPermissionRegex.IsMatch(ex.Message)
|| ex.Message.Contains("AndroidManifest", StringComparison.OrdinalIgnoreCase);
}

private static string? TryExtractPermission(string message)
{
var match = AndroidPermissionRegex.Match(message);
return match.Success ? match.Value : null;
}

private static bool IsMainThreadAccessException(Exception ex)
{
return ex.GetType().Name.Equals("UIKitThreadAccessException", StringComparison.Ordinal)
|| ex.Message.Contains("main thread", StringComparison.OrdinalIgnoreCase)
|| ex.Message.Contains("UI thread", StringComparison.OrdinalIgnoreCase);
}

private async Task<HttpResponse> HandlePlatformAppInfo(HttpRequest request)
{
try
Expand All @@ -3850,7 +3954,7 @@
}
catch (Exception ex)
{
return HttpResponse.Error($"Failed to get app info: {ex.Message}");
return CreatePlatformError($"Failed to get app info: {ex.Message}", ex);
}
}

Expand All @@ -3872,7 +3976,7 @@
}
catch (Exception ex)
{
return Task.FromResult(HttpResponse.Error($"Failed to get device info: {ex.Message}"));
return Task.FromResult(CreatePlatformError($"Failed to get device info: {ex.Message}", ex));
}
}

Expand All @@ -3896,7 +4000,7 @@
}
catch (Exception ex)
{
return HttpResponse.Error($"Failed to get display info: {ex.Message}");
return CreatePlatformError($"Failed to get display info: {ex.Message}", ex);
}
}

Expand All @@ -3915,7 +4019,7 @@
}
catch (Exception ex)
{
return Task.FromResult(HttpResponse.Error($"Failed to get battery info: {ex.Message}"));
return Task.FromResult(CreatePlatformError($"Failed to get battery info: {ex.Message}", ex));
}
}

Expand All @@ -3932,7 +4036,7 @@
}
catch (Exception ex)
{
return Task.FromResult(HttpResponse.Error($"Failed to get connectivity info: {ex.Message}"));
return Task.FromResult(CreatePlatformError($"Failed to get connectivity info: {ex.Message}", ex));
}
}

Expand All @@ -3958,7 +4062,7 @@
}
catch (Exception ex)
{
return Task.FromResult(HttpResponse.Error($"Failed to get version tracking info: {ex.Message}"));
return Task.FromResult(CreatePlatformError($"Failed to get version tracking info: {ex.Message}", ex));
}
}

Expand Down Expand Up @@ -4007,7 +4111,7 @@
}
catch (Exception ex)
{
return HttpResponse.Error($"Failed to check permissions: {ex.Message}");
return CreatePlatformError($"Failed to check permissions: {ex.Message}", ex);
}
}

Expand All @@ -4016,18 +4120,24 @@
try
{
if (!request.RouteParams.TryGetValue("permission", out var permName))
return HttpResponse.Error("permission name is required");
return HttpResponse.Error(
"permission name is required",
reason: PlatformErrorReasonInvalidRequest,
details: new Dictionary<string, object?> { ["parameter"] = "permission" });

if (!KnownPermissions.TryGetValue(permName, out var factory))
return HttpResponse.Error($"Unknown permission: {permName}. Valid: {string.Join(", ", KnownPermissions.Keys)}");
return HttpResponse.Error(
$"Unknown permission: {permName}. Valid: {string.Join(", ", KnownPermissions.Keys)}",
reason: PlatformErrorReasonInvalidRequest,
details: new Dictionary<string, object?> { ["parameter"] = "permission" });

var perm = factory();
var status = await perm.CheckStatusAsync();
return HttpResponse.Json(new { permission = permName, status = status.ToString() });
}
catch (Exception ex)
{
return HttpResponse.Error($"Failed to check permission: {ex.Message}");
return CreatePlatformError($"Failed to check permission: {ex.Message}", ex);
}
}

Expand All @@ -4051,7 +4161,7 @@
var location = await Geolocation.GetLocationAsync(new GeolocationRequest(accuracy, TimeSpan.FromSeconds(timeoutSec)));

if (location == null)
return HttpResponse.Error("Could not determine location");
return CreatePlatformError("Could not determine location", PlatformErrorReasonUnknown);

return HttpResponse.Json(new
{
Expand All @@ -4067,15 +4177,22 @@
}
catch (PermissionException)
{
return HttpResponse.Error("Location permission not granted", 403);
return CreatePlatformError("Location permission not granted", PlatformErrorReasonMissingPermission, 403);
}
catch (FeatureNotEnabledException)
{
return HttpResponse.Error("Location services not enabled on device");
return CreatePlatformError(
"Location services not enabled on device",
PlatformErrorReasonNotSupported,
details: new Dictionary<string, object?>
{
["feature"] = "geolocation",
["enabled"] = false
});
}
catch (Exception ex)
{
return HttpResponse.Error($"Failed to get location: {ex.Message}");
return CreatePlatformError($"Failed to get location: {ex.Message}", ex);
}
}

Expand Down
8 changes: 6 additions & 2 deletions src/MauiDevFlow.Driver/AgentClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -315,8 +315,12 @@ private async Task<JsonElement> GetJsonAsync(string path)
{
try
{
var response = await _http.GetStringAsync($"{_baseUrl}{path}");
return JsonSerializer.Deserialize<JsonElement>(response);
using var response = await _http.GetAsync($"{_baseUrl}{path}");
var body = await response.Content.ReadAsStringAsync();
if (string.IsNullOrWhiteSpace(body))
return default;

return JsonSerializer.Deserialize<JsonElement>(body);
}
catch { return default; }
}
Expand Down
70 changes: 70 additions & 0 deletions tests/MauiDevFlow.Tests/AgentIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using MauiDevFlow.Agent.Core;

namespace MauiDevFlow.Tests;

Expand Down Expand Up @@ -190,7 +191,7 @@
Assert.Single(tree);
Assert.Equal("ContentPage", tree[0].Type);
Assert.NotNull(tree[0].Children);
Assert.Single(tree[0].Children);

Check warning on line 194 in tests/MauiDevFlow.Tests/AgentIntegrationTests.cs

View workflow job for this annotation

GitHub Actions / build-and-test (windows-latest)

Possible null reference argument for parameter 'collection' in 'ElementInfo Assert.Single<ElementInfo>(IEnumerable<ElementInfo> collection)'.
Assert.Equal("VerticalStackLayout", tree[0].Children[0].Type);
Assert.NotNull(tree[0].Children[0].Children);
Assert.Single(tree[0].Children[0].Children);
Expand All @@ -199,5 +200,74 @@
listener.Stop();
}

[Fact]
public void HttpResponseError_IncludesReasonAndDetails_WhenProvided()
{
var response = HttpResponse.Error(
"Failed to get battery info",
403,
"missing_permission",
new Dictionary<string, object?>
{
["permission"] = "android.permission.BATTERY_STATS",
["platform"] = "Android"
});

Assert.Equal(403, response.StatusCode);
Assert.Equal("Forbidden", response.StatusText);
Assert.NotNull(response.Body);

var json = JsonSerializer.Deserialize<JsonElement>(response.Body!);
Assert.False(json.GetProperty("success").GetBoolean());
Assert.Equal("Failed to get battery info", json.GetProperty("error").GetString());
Assert.Equal("missing_permission", json.GetProperty("reason").GetString());
Assert.Equal("android.permission.BATTERY_STATS", json.GetProperty("details").GetProperty("permission").GetString());
Assert.Equal("Android", json.GetProperty("details").GetProperty("platform").GetString());
}

[Fact]
public async Task GetPlatformInfoAsync_ReturnsStructuredErrorBody_OnNonSuccessResponse()
{
using var listener = new TcpListener(IPAddress.Loopback, _port);
listener.Start();

var acceptTask = Task.Run(async () =>
{
using var client = await listener.AcceptTcpClientAsync();
using var stream = client.GetStream();
var buffer = new byte[4096];
var read = await stream.ReadAsync(buffer);
var request = Encoding.UTF8.GetString(buffer, 0, read);

Assert.Contains("GET /api/platform/battery", request);

var body = """
{
"success": false,
"error": "Failed to get battery info: You need to declare using the permission: `android.permission.BATTERY_STATS` in your AndroidManifest.xml",
"reason": "missing_permission",
"details": {
"permission": "android.permission.BATTERY_STATS",
"platform": "Android"
}
}
""";
var response = $"HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {Encoding.UTF8.GetByteCount(body)}\r\nConnection: close\r\n\r\n{body}";
await stream.WriteAsync(Encoding.UTF8.GetBytes(response));
});

using var agentClient = new MauiDevFlow.Driver.AgentClient("localhost", _port);
var result = await agentClient.GetPlatformInfoAsync("battery");

Assert.Equal(JsonValueKind.Object, result.ValueKind);
Assert.False(result.GetProperty("success").GetBoolean());
Assert.Equal("missing_permission", result.GetProperty("reason").GetString());
Assert.Equal("android.permission.BATTERY_STATS", result.GetProperty("details").GetProperty("permission").GetString());
Assert.Equal("Android", result.GetProperty("details").GetProperty("platform").GetString());

await acceptTask;
listener.Stop();
}

public void Dispose() { }
}
Loading