Skip to content

Commit 2192e4e

Browse files
committed
Merge remote-tracking branch 'origin/develop' into offline-login-error-handling
2 parents 643dddb + 7bd2a81 commit 2192e4e

42 files changed

Lines changed: 1814 additions & 213 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ localResourcesCache/
3131
backend/FwLite/FwLiteShared/wwwroot/viewer
3232

3333
*.csproj.user
34-
backend/Directory.Build.props.user
34+
*.props.user
3535
*.log
3636
failedSyncs/
3737

.vscode/tasks.json

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,5 +61,39 @@
6161
],
6262
"problemMatcher": "$msCompile"
6363
},
64+
{
65+
"label": "FW Lite Web",
66+
"dependsOn": [
67+
"Fw Lite aspnet server",
68+
"Fw Lite vite"
69+
],
70+
"group": "build"
71+
},
72+
{
73+
"label": "Fw Lite aspnet server",
74+
"command": "dotnet",
75+
"type": "process",
76+
"args": [
77+
"watch",
78+
"--project",
79+
"${workspaceFolder}/backend/FwLite/FwLiteWeb/FwLiteWeb.csproj",
80+
"--no-hot-reload",
81+
"--",
82+
"--FwLiteWeb:OpenBrowser=false"
83+
],
84+
"problemMatcher": "$msCompile"
85+
},
86+
{
87+
"label": "Fw Lite vite",
88+
"command": "npm",
89+
"type": "process",
90+
"args": [
91+
"run",
92+
"dev"
93+
],
94+
"options": {
95+
"cwd": "${workspaceFolder}/frontend/viewer"
96+
},
97+
}
6498
]
6599
}

backend/.editorconfig

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ csharp_style_deconstructed_variable_declaration = true:suggestion
9696
csharp_prefer_simple_default_expression = true:suggestion
9797
csharp_style_prefer_local_over_anonymous_function = true:suggestion
9898
csharp_style_inlined_variable_declaration = true:suggestion
99-
# Remove unnecessary using directives
99+
# Remove unnecessary using directives
100100
dotnet_diagnostic.IDE0005.severity = warning
101101
###############################
102102
# C# Formatting Rules #
@@ -134,6 +134,11 @@ dotnet_diagnostic.VSTHRD200.severity = none
134134
# "Expression value is never used"
135135
dotnet_diagnostic.IDE0058.severity = suggestion
136136

137+
# IDE0061: Use block body for local function
138+
dotnet_diagnostic.IDE0061.severity = silent
139+
# IDE0022: Use block body for method
140+
dotnet_diagnostic.IDE0022.severity = silent
141+
137142
[{*Kernel}.cs]
138143
dotnet_diagnostic.IDE0058.severity = none
139144

@@ -146,4 +151,4 @@ indent_size = unset
146151
indent_style = unset
147152
insert_final_newline = false
148153
tab_width = unset
149-
trim_trailing_whitespace = false
154+
trim_trailing_whitespace = false

backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1088,12 +1088,15 @@ private ICmObject FindSenseOrEntryComponent(ComplexFormComponent component)
10881088

10891089
public Task DeleteComplexFormComponent(ComplexFormComponent complexFormComponent)
10901090
{
1091+
//complex form entry has been deleted, so this component relationship is gone already
1092+
if (!EntriesRepository.TryGetObject(complexFormComponent.ComplexFormEntryId, out var lexEntry))
1093+
return Task.CompletedTask;
1094+
10911095
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Delete Complex Form Component",
10921096
"Add Complex Form Component",
10931097
Cache.ServiceLocator.ActionHandler,
10941098
() =>
10951099
{
1096-
var lexEntry = EntriesRepository.GetObject(complexFormComponent.ComplexFormEntryId);
10971100
RemoveComplexFormComponent(lexEntry, complexFormComponent);
10981101
});
10991102
return Task.CompletedTask;
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using FwLiteMaui.Services;
2+
3+
namespace FwLiteMaui.Tests;
4+
5+
public class ConnectivitySyncTriggerTests
6+
{
7+
[Theory]
8+
[InlineData(NetworkAccess.None, NetworkAccess.Internet, true)]
9+
[InlineData(NetworkAccess.Unknown, NetworkAccess.Internet, true)]
10+
[InlineData(NetworkAccess.ConstrainedInternet, NetworkAccess.Internet, true)]
11+
[InlineData(NetworkAccess.Internet, NetworkAccess.Internet, false)] // already online — don't re-run
12+
[InlineData(NetworkAccess.Internet, NetworkAccess.None, false)] // going offline
13+
[InlineData(NetworkAccess.None, NetworkAccess.Local, false)] // local network only, no internet
14+
public void ShouldRecover_OnlyOnTransitionIntoInternet(NetworkAccess previous, NetworkAccess current, bool expected)
15+
{
16+
ConnectivitySyncTrigger.ShouldRecover(previous, current).Should().Be(expected);
17+
}
18+
}

backend/FwLite/FwLiteMaui/App.xaml.cs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
using FwLiteShared.Projects;
2+
using FwLiteShared.Services;
3+
using Microsoft.Extensions.Logging;
14

25
namespace FwLiteMaui;
36

@@ -6,11 +9,20 @@ public partial class App : Application
69
public IServiceProvider ServiceProvider { get; }
710
private readonly MainPage _mainPage;
811
public static string? OverrideStartupUrl { get; set; }
12+
private readonly LexboxProjectChangeListener _lexboxProjectChangeListener;
13+
private readonly ILogger<App> _logger;
914

10-
public App(MainPage mainPage, IServiceProvider serviceProvider)
15+
public App(MainPage mainPage, IPreferencesService preferences, LexboxProjectChangeListener lexboxProjectChangeListener, ILogger<App> logger, IServiceProvider serviceProvider)
1116
{
1217
ServiceProvider = serviceProvider;
1318
_mainPage = mainPage;
19+
_lexboxProjectChangeListener = lexboxProjectChangeListener;
20+
_logger = logger;
21+
var lastUrl = preferences.Get(nameof(PreferenceKey.AppLastUrl));
22+
if (lastUrl?.StartsWith('/') == true)
23+
{
24+
mainPage.StartPath = lastUrl;
25+
}
1426
InitializeComponent();
1527
}
1628

@@ -21,7 +33,25 @@ internal void LoadAppUrl(string url)
2133

2234
protected override Window CreateWindow(IActivationState? activationState)
2335
{
24-
return CreateWindow(_mainPage);
36+
var window = CreateWindow(_mainPage);
37+
// An OS-frozen app (e.g. Android Doze/Standby) can come back with its push listener down and no connectivity
38+
// transition to recover on; resume is when the user is watching, so recover immediately instead of
39+
// waiting for the periodic backstop.
40+
window.Resumed += (_, _) => _ = EnsureListenersAfterResume();
41+
return window;
42+
}
43+
44+
private async Task EnsureListenersAfterResume()
45+
{
46+
try
47+
{
48+
_logger.LogInformation("App resumed; ensuring push listeners");
49+
await _lexboxProjectChangeListener.EnsureListenersForTrackedProjects(kickReconnecting: true);
50+
}
51+
catch (Exception e)
52+
{
53+
_logger.LogWarning(e, "Failed to ensure push listeners after app resume");
54+
}
2555
}
2656

2757
public static Window CreateWindow(MainPage mainPage, int? width = null)

backend/FwLite/FwLiteMaui/FwLiteMauiKernel.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ public static void AddFwLiteMauiServices(this IServiceCollection services,
132132
services.AddSingleton(_ => Preferences.Default);
133133
services.AddSingleton(_ => VersionTracking.Default);
134134
services.AddSingleton(_ => Connectivity.Current);
135+
services.AddSingleton<IHostedService, ConnectivitySyncTrigger>();
136+
services.AddSingleton<INetworkStatus, ConnectivityNetworkStatus>();
135137
services.AddSingleton(_ => Launcher.Default);
136138
services.AddSingleton(_ => Browser.Default);
137139
services.AddSingleton(_ => Share.Default);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using FwLiteShared.Services;
2+
3+
namespace FwLiteMaui.Services;
4+
5+
public class ConnectivityNetworkStatus(IConnectivity connectivity) : INetworkStatus
6+
{
7+
// ConstrainedInternet (e.g. captive portal) counts as offline, matching ConnectivitySyncTrigger.
8+
public bool IsOnline => connectivity.NetworkAccess == NetworkAccess.Internet;
9+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
using FwLiteShared.Projects;
2+
using Microsoft.Extensions.Hosting;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace FwLiteMaui.Services;
6+
7+
// Primary use case: app started offline should start syncing if the device comes online
8+
public sealed class ConnectivitySyncTrigger(
9+
IConnectivity connectivity,
10+
LexboxProjectChangeListener lexboxProjectChangeListener,
11+
ILogger<ConnectivitySyncTrigger> logger) : IHostedService
12+
{
13+
private NetworkAccess _lastAccess;
14+
15+
public Task StartAsync(CancellationToken cancellationToken)
16+
{
17+
_lastAccess = connectivity.NetworkAccess;
18+
connectivity.ConnectivityChanged += OnConnectivityChanged;
19+
logger.LogInformation("Watching device connectivity to re-establish push listeners (current access: {NetworkAccess})", _lastAccess);
20+
return Task.CompletedTask;
21+
}
22+
23+
public Task StopAsync(CancellationToken cancellationToken)
24+
{
25+
connectivity.ConnectivityChanged -= OnConnectivityChanged;
26+
return Task.CompletedTask;
27+
}
28+
29+
private void OnConnectivityChanged(object? sender, ConnectivityChangedEventArgs e)
30+
{
31+
var previous = _lastAccess;
32+
var current = _lastAccess = e.NetworkAccess;
33+
34+
logger.LogInformation("Device connectivity changed: {Previous} -> {Current} (profiles: {Profiles})",
35+
previous, current, string.Join(", ", e.ConnectionProfiles));
36+
37+
if (!ShouldRecover(previous, current)) return;
38+
39+
logger.LogInformation("Connectivity regained (internet access); ensuring push listeners");
40+
_ = EnsureListeners();
41+
}
42+
43+
private async Task EnsureListeners(CancellationToken cancellationToken = default)
44+
{
45+
try
46+
{
47+
await lexboxProjectChangeListener.EnsureListenersForTrackedProjects(kickReconnecting: true, cancellationToken);
48+
}
49+
catch (Exception e)
50+
{
51+
logger.LogWarning(e, "Failed to ensure push listeners after connectivity change");
52+
}
53+
}
54+
55+
// Only react to a transition INTO internet access, so recovery doesn't re-run on every connectivity
56+
// change (e.g. wifi<->cellular) while already online. Idempotent recovery makes a missed edge harmless;
57+
// this just keeps the common flapping case quiet.
58+
public static bool ShouldRecover(NetworkAccess previous, NetworkAccess current)
59+
{
60+
return current == NetworkAccess.Internet && previous != NetworkAccess.Internet;
61+
}
62+
}

backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,27 @@ public async Task FwDataApiDoesNotUpdateMorphType()
168168
actual.Should().NotBeNull();
169169
actual.MorphType.Should().Be(MorphTypeKind.BoundStem);
170170
}
171+
172+
[Fact]
173+
public async Task SyncFull_RemovingComplexFormWhoseParentWasDeleted_DoesNotThrow()
174+
{
175+
var component = await Api.CreateEntry(new() { Id = Guid.NewGuid(), LexemeForm = { { "en", "component" } } });
176+
var complexForm = new Entry { Id = Guid.NewGuid(), LexemeForm = { { "en", "complexForm" } } };
177+
complexForm.Components = [ComplexFormComponent.FromEntries(complexForm, component)];
178+
await Api.CreateEntry(complexForm);
179+
180+
var before = await Api.GetEntry(component.Id);
181+
before!.ComplexForms.Should().ContainSingle();
182+
183+
// Deleting the parent already drops the relationship; the sync then redundantly removes it from the surviving component, which used to throw because the parent was gone.
184+
await Api.DeleteEntry(complexForm.Id);
185+
var after = before.Copy();
186+
after.ComplexForms.Clear();
187+
188+
await EntrySync.SyncFull(before, after, Api);
189+
190+
(await Api.GetEntry(component.Id)).Should().NotBeNull();
191+
}
171192
}
172193

173194
public abstract class EntrySyncTestsBase(ExtraWritingSystemsSyncFixture fixture) : IClassFixture<ExtraWritingSystemsSyncFixture>, IAsyncLifetime

0 commit comments

Comments
 (0)