Skip to content
Draft
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 backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using FwLiteShared.Services;
using LcmCrdt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Hosting.Internal;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -66,6 +67,7 @@ public static void AddFwLiteMauiServices(this IServiceCollection services,
#endif
#if ANDROID
services.Configure<AuthConfig>(config => config.ParentActivityOrWindow = Platform.CurrentActivity);
services.Replace(ServiceDescriptor.Singleton<ILongRunningWorkHost, AndroidForegroundWorkHost>());
#endif
services.AddSingleton<IAppLauncher, AppLauncher>();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
using Android;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.App;
using AndroidX.Core.Content;
using FwLiteShared.Services;
using Microsoft.Extensions.Logging;
using AndroidApplication = Android.App.Application;

namespace FwLiteMaui;

public sealed class AndroidForegroundWorkHost(ILogger<AndroidForegroundWorkHost> logger) : ILongRunningWorkHost
{
private const int NotificationPermissionRequestCode = 2300;
private readonly Lock wakeLockLock = new();
private PowerManager.WakeLock? wakeLock;

public Task WorkStartedAsync(LongRunningWorkRequest request, CancellationToken cancellationToken)
{
var context = Platform.AppContext ?? AndroidApplication.Context;
RequestNotificationPermissionIfNeeded(context);

var intent = new Intent(context, typeof(ForegroundWorkService))
.PutExtra(ForegroundWorkService.TitleExtra, request.Title)
.PutExtra(ForegroundWorkService.TextExtra, request.NotificationText);
ContextCompat.StartForegroundService(context, intent);
AcquireWakeLock(context);
return Task.CompletedTask;
}

public Task WorkQueueDrainedAsync(CancellationToken cancellationToken)
{
ReleaseWakeLock();

var context = Platform.AppContext ?? AndroidApplication.Context;
context.StopService(new Intent(context, typeof(ForegroundWorkService)));
return Task.CompletedTask;
}

private void RequestNotificationPermissionIfNeeded(Context context)
{
if (!OperatingSystem.IsAndroidVersionAtLeast(33)) return;
if (ContextCompat.CheckSelfPermission(context, Manifest.Permission.PostNotifications) == Permission.Granted) return;

var activity = Platform.CurrentActivity;
if (activity is null)
{
logger.LogWarning("Unable to request Android notification permission because no current activity is available");
return;
}

ActivityCompat.RequestPermissions(
activity,
[Manifest.Permission.PostNotifications],
NotificationPermissionRequestCode);
}

private void AcquireWakeLock(Context context)
{
lock (wakeLockLock)
{
if (wakeLock?.IsHeld == true) return;

var powerManager = (PowerManager?)context.GetSystemService(Context.PowerService);
wakeLock = powerManager?.NewWakeLock(WakeLockFlags.Partial, "FwLite:LongRunningWork");
wakeLock?.Acquire();
}
}

private void ReleaseWakeLock()
{
lock (wakeLockLock)
{
try
{
if (wakeLock?.IsHeld == true) wakeLock.Release();
}
finally
{
wakeLock?.Dispose();
wakeLock = null;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- the app icon is kinda magic here, see https://learn.microsoft.com/en-us/dotnet/maui/user-interface/images/app-icons?view=net-maui-9.0&tabs=android-->
<application android:allowBackup="true" android:icon="@mipmap/logo_background" android:roundIcon="@mipmap/logo_background_round" android:supportsRtl="true"></application>
<application android:allowBackup="true" android:icon="@mipmap/logo_background" android:roundIcon="@mipmap/logo_background_round" android:supportsRtl="true">
<service android:name="org.sil.FwLiteMaui.ForegroundWorkService" android:exported="false" android:foregroundServiceType="dataSync" />
</application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<queries>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.OS;
using AndroidX.Core.App;

namespace FwLiteMaui;

[Service(Name = ServiceName, Exported = false, ForegroundServiceType = ForegroundService.TypeDataSync)]
public sealed class ForegroundWorkService : Service
{
public const string ServiceName = "org.sil.FwLiteMaui.ForegroundWorkService";
public const string TitleExtra = "org.sil.FwLiteMaui.ForegroundWorkService.Title";
public const string TextExtra = "org.sil.FwLiteMaui.ForegroundWorkService.Text";
public const string ChannelId = "fw-lite-long-running-work";
public const int NotificationId = 2030;

public override IBinder? OnBind(Intent? intent) => null;

public override StartCommandResult OnStartCommand(Intent? intent, StartCommandFlags flags, int startId)
{
var title = intent?.GetStringExtra(TitleExtra) ?? "FieldWorks Lite is working";
var text = intent?.GetStringExtra(TextExtra) ?? "FieldWorks Lite is completing work";
EnsureNotificationChannel();
var notification = BuildNotification(title, text);

if (OperatingSystem.IsAndroidVersionAtLeast(29))
{
StartForeground(NotificationId, notification, ForegroundService.TypeDataSync);
}
else
{
StartForeground(NotificationId, notification);
}

return StartCommandResult.NotSticky;
}

public override void OnDestroy()
{
StopForeground(StopForegroundFlags.Remove);
base.OnDestroy();
}

private void EnsureNotificationChannel()
{
if (!OperatingSystem.IsAndroidVersionAtLeast(26)) return;

var notificationManager = (NotificationManager?)GetSystemService(NotificationService);
var existingChannel = notificationManager?.GetNotificationChannel(ChannelId);
if (existingChannel is not null) return;

var channel = new NotificationChannel(
ChannelId,
"Long-running FieldWorks Lite work",
NotificationImportance.Low)
{
Description = "Shows progress for downloads and other long-running FieldWorks Lite work."
};
notificationManager?.CreateNotificationChannel(channel);
}

private Notification BuildNotification(string title, string text)
{
var launchIntent = PackageManager?.GetLaunchIntentForPackage(PackageName ?? string.Empty);
var pendingIntent = launchIntent is null
? null
: PendingIntent.GetActivity(
this,
0,
launchIntent,
PendingIntentFlags.Immutable | PendingIntentFlags.UpdateCurrent);

var builder = new NotificationCompat.Builder(this, ChannelId);
builder.SetSmallIcon(Resource.Drawable.ic_notification);
builder.SetContentTitle(title);
builder.SetContentText(text);
builder.SetOngoing(true);
builder.SetOnlyAlertOnce(true);
builder.SetCategory(NotificationCompat.CategoryStatus);
builder.SetPriority((int)NotificationPriority.Low);

if (pendingIntent is not null) builder.SetContentIntent(pendingIntent);

return builder.Build() ?? throw new InvalidOperationException("Unable to create foreground work notification");
}
}
13 changes: 13 additions & 0 deletions backend/FwLite/FwLiteMaui/Platforms/Android/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Android Long-Running Work

FwLite uses `AndroidForegroundWorkHost` and `ForegroundWorkService` as a generic foreground-service host for user-visible long-running work. The service only maintains Android foreground-service state and notification lifecycle; queued work continues to run through the shared .NET `ILongRunningWorkQueue`.

If the foreground service or wake lock fails to start, work still continues (fail open) so downloads are not blocked. A global `UserNotification` event is published so the UI can show a copyable error toast for bug reports.

## Manual Sleep Test

1. Start FwLite on an Android device or emulator.
2. Start downloading a large project from the normal project download UI.
3. Turn the screen off and wait long enough to exceed the normal screen-sleep window.
4. Turn the screen back on.
5. Verify the project completed or surfaced a real error, rather than silently stopping while the screen was off.
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Design Icons: mdi:cloud-sync (white silhouette for Android status-bar notifications) -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M13,17.5c0,0.89 0.18,1.73 0.5,2.5h-7c-1.5,0 -2.81,-0.5 -3.89,-1.57C1.54,17.38 1,16.09 1,14.58q0,-1.95 1.17,-3.48C3.34,9.57 4,9.43 5.25,9.15c0.42,-1.53 1.25,-2.77 2.5,-3.72S10.42,4 12,4c1.95,0 3.6,0.68 4.96,2.04S19,9.05 19,11h0.1c-3.4,0.23 -6.1,3.05 -6.1,6.5m6,-4V12l-2.25,2.25L19,16.5V15a2.5,2.5 0,0 1,2.5 2.5c0,0.4 -0.09,0.78 -0.26,1.12l1.09,1.09c0.42,-0.63 0.67,-1.39 0.67,-2.21c0,-2.21 -1.79,-4 -4,-4m0,6.5a2.5,2.5 0,0 1,-2.5 -2.5c0,-0.4 0.09,-0.78 0.26,-1.12l-1.09,-1.09c-0.42,0.63 -0.67,1.39 -0.67,2.21c0,2.21 1.79,4 4,4V23l2.25,-2.25L19,18.5z" />
</vector>
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
using System.Collections.Concurrent;
using System.Reactive.Linq;
using FwLiteShared.Events;
using FwLiteShared.Services;
using Microsoft.Extensions.Logging.Abstractions;

namespace FwLiteShared.Tests.Services;

public class InProcessLongRunningWorkQueueTests
{
[Fact]
public async Task RunsQueuedWorkSerially()
{
var host = new FakeLongRunningWorkHost();
var queue = CreateQueue(host);
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var secondStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var observed = new ConcurrentQueue<string>();

var first = queue.EnqueueAsync(Request("First"), async _ =>
{
observed.Enqueue("first-start");
firstStarted.SetResult();
await releaseFirst.Task.WaitAsync(TimeSpan.FromSeconds(5));
observed.Enqueue("first-end");
});
await firstStarted.Task;

var second = queue.EnqueueAsync(Request("Second"), _ =>
{
observed.Enqueue("second-start");
secondStarted.SetResult();
return Task.CompletedTask;
});

var earlySecondStart = await Task.WhenAny(secondStarted.Task, Task.Delay(100));
earlySecondStart.Should().NotBe(secondStarted.Task);

releaseFirst.SetResult();
await Task.WhenAll(first, second);

observed.Should().Equal("first-start", "first-end", "second-start");
}

[Fact]
public async Task PropagatesWorkExceptionAndContinuesWithNextItem()
{
var queue = CreateQueue();

var failedWork = async () => await queue.EnqueueAsync(
Request("Fails"),
_ => throw new InvalidOperationException("boom"));

await failedWork.Should().ThrowAsync<InvalidOperationException>().WithMessage("boom");

var result = await queue.EnqueueAsync(Request("Succeeds"), _ => Task.FromResult(42));
result.Should().Be(42);
}

[Fact]
public async Task KeepsHostActiveUntilQueuedWorkDrains()
{
var host = new FakeLongRunningWorkHost();
var queue = CreateQueue(host);
var firstStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);

var first = queue.EnqueueAsync(Request("First"), async _ =>
{
firstStarted.SetResult();
await releaseFirst.Task.WaitAsync(TimeSpan.FromSeconds(5));
});
await firstStarted.Task;

var second = queue.EnqueueAsync(Request("Second"), _ => Task.CompletedTask);
releaseFirst.SetResult();
await Task.WhenAll(first, second);

host.StartedTitles.Should().Equal("First", "Second");
host.DrainedCount.Should().Be(1);
}

[Fact]
public async Task HostStartFailure_ContinuesWorkAndPublishesEvent()
{
using var eventBus = new GlobalEventBus(NullLogger<GlobalEventBus>.Instance);
UserNotificationEvent? published = null;
using var _ = eventBus.OnGlobalEvent.OfType<UserNotificationEvent>()
.Subscribe(e => published = e);

var host = new FakeLongRunningWorkHost
{
StartException = new InvalidOperationException("foreground service failed")
};
var queue = CreateQueue(host, eventBus);
var ran = false;

await queue.EnqueueAsync(Request("Downloading project demo"), _ =>
{
ran = true;
return Task.CompletedTask;
});

ran.Should().BeTrue();
published.Should().NotBeNull();
published!.Message.Should().Be("Background work protection failed");
published.NotificationType.Should().Be(UserNotificationType.Error);
published.Duration.Should().Be(UserNotificationDuration.Infinite);
published.Description.Should().Contain("Downloading project demo");
published.ClipboardText.Should().Contain("foreground service failed");
host.DrainedCount.Should().Be(1);
}

private static InProcessLongRunningWorkQueue CreateQueue(
ILongRunningWorkHost? host = null,
GlobalEventBus? eventBus = null)
{
return new InProcessLongRunningWorkQueue(
host ?? new FakeLongRunningWorkHost(),
eventBus ?? new GlobalEventBus(NullLogger<GlobalEventBus>.Instance),
NullLogger<InProcessLongRunningWorkQueue>.Instance);
}

private static LongRunningWorkRequest Request(string title) => new(title, $"{title} notification");

private sealed class FakeLongRunningWorkHost : ILongRunningWorkHost
{
public List<string> StartedTitles { get; } = [];
public int DrainedCount { get; private set; }
public Exception? StartException { get; init; }

public Task WorkStartedAsync(LongRunningWorkRequest request, CancellationToken cancellationToken)
{
StartedTitles.Add(request.Title);
if (StartException is not null) throw StartException;
return Task.CompletedTask;
}

public Task WorkQueueDrainedAsync(CancellationToken cancellationToken)
{
DrainedCount++;
return Task.CompletedTask;
}
}
}
2 changes: 2 additions & 0 deletions backend/FwLite/FwLiteShared/Events/IFwEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ namespace FwLiteShared.Events;
[JsonDerivedType(typeof(SyncEvent), nameof(SyncEvent))]
[JsonDerivedType(typeof(AppUpdateEvent), nameof(AppUpdateEvent))]
[JsonDerivedType(typeof(AppUpdateProgressEvent), nameof(AppUpdateProgressEvent))]
[JsonDerivedType(typeof(UserNotificationEvent), nameof(UserNotificationEvent))]
public interface IFwEvent
{
FwEventType Type { get; }
Expand All @@ -25,4 +26,5 @@ public enum FwEventType
Sync,
AppUpdate,
AppUpdateProgress,
UserNotification,
}
Loading
Loading