Skip to content

Commit 36092bd

Browse files
authored
Merge pull request #3 from GeneralLibrary/copilot/implement-generalupdate-maui-android
Implement `GeneralUpdate.Maui.Android`: UI-less MAUI Android updater core with resumable download, SHA256 validation, thread-safe installer orchestration, .NET 10 targeting, Bootstrap API naming, and bilingual README docs
2 parents c314450 + 62c318d commit 36092bd

79 files changed

Lines changed: 1784 additions & 1542 deletions

File tree

Some content is hidden

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

.github/workflows/dotnet-ci.yaml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ jobs:
1111
- name: Setup .NET SDK
1212
uses: actions/setup-dotnet@v2
1313
with:
14-
dotnet-version: '8.0.x'
14+
dotnet-version: '10.0.x'
1515
- name: Install .NET MAUI
1616
run: dotnet workload install maui
1717
- name: Restore dependencies
1818
run: dotnet restore ./src/GeneralUpdate.Maui.sln
1919
- name: build
20-
run: dotnet build ./src/GeneralUpdate.Maui.sln -c Release
20+
run: dotnet build ./src/GeneralUpdate.Maui.sln -c Release
21+
- name: test
22+
run: dotnet test ./src/GeneralUpdate.Maui.Android.Tests/GeneralUpdate.Maui.Android.Tests.csproj -c Release -p:TargetFramework=net10.0

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,16 @@ This project is a subproject of GeneralUpdate, designed to be compatible with .N
44

55
| Platform | Support | Framework version |
66
| -------- | ------- | ----------------- |
7-
| Android | Yes | .NET8 |
7+
| Android | Yes | .NET10 |
88
| Windows | - | - |
99
| iOS | - | - |
1010
| Mac | - | - |
1111

12+
## Projects
13+
14+
- `GeneralUpdate.Maui.Android`: UI-less Android auto-update core for .NET MAUI, including:
15+
- Update discovery from external metadata
16+
- Resumable APK download via `HttpClient` and HTTP range requests
17+
- SHA256 integrity verification
18+
- Android package installation triggering via `FileProvider` and system installer intent
19+
- Workflow state and event notifications for update lifecycle and download statistics
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
using GeneralUpdate.Maui.Android.Abstractions;
2+
using GeneralUpdate.Maui.Android.Enums;
3+
using GeneralUpdate.Maui.Android.Events;
4+
using GeneralUpdate.Maui.Android.Models;
5+
using GeneralUpdate.Maui.Android.Services;
6+
using Xunit;
7+
8+
namespace GeneralUpdate.Maui.Android.Tests;
9+
10+
public class AndroidBootstrapTests
11+
{
12+
[Fact]
13+
public async Task ValidateAsync_Should_ReportUpdateAvailable_WhenVersionIsNewer()
14+
{
15+
var manager = CreateManager();
16+
var package = new UpdatePackageInfo
17+
{
18+
Version = "2.0.0",
19+
DownloadUrl = "https://example.com/app.apk",
20+
Sha256 = "ABCDEF"
21+
};
22+
23+
var raised = false;
24+
manager.AddListenerValidate += (_, args) => raised = args.PackageInfo.Version == "2.0.0";
25+
26+
var result = await manager.ValidateAsync(package, new UpdateOptions { CurrentVersion = "1.0.0" }, CancellationToken.None);
27+
28+
Assert.True(result.IsUpdateAvailable);
29+
Assert.True(raised);
30+
Assert.Equal(UpdateState.UpdateAvailable, manager.CurrentState);
31+
}
32+
33+
[Fact]
34+
public async Task ValidateAsync_Should_ReturnNoUpdate_WhenVersionIsNotNewer()
35+
{
36+
var manager = CreateManager();
37+
var package = new UpdatePackageInfo
38+
{
39+
Version = "1.0.0",
40+
DownloadUrl = "https://example.com/app.apk",
41+
Sha256 = "ABCDEF"
42+
};
43+
44+
var result = await manager.ValidateAsync(package, new UpdateOptions { CurrentVersion = "1.0.0" }, CancellationToken.None);
45+
46+
Assert.False(result.IsUpdateAvailable);
47+
Assert.Equal(UpdateState.None, manager.CurrentState);
48+
}
49+
50+
[Fact]
51+
public async Task ExecuteUpdateAsync_Should_FailWithIntegrityReason_WhenHashValidationFails()
52+
{
53+
var fakeDownloader = new FakeDownloader();
54+
var fakeValidator = new FakeValidator(new HashValidationResult
55+
{
56+
IsSuccess = false,
57+
ExpectedHash = "A",
58+
ActualHash = "B",
59+
FailureReason = "SHA mismatch"
60+
});
61+
62+
var manager = new AndroidBootstrap(
63+
fakeDownloader,
64+
fakeValidator,
65+
new FakeInstaller(),
66+
new UpdateFileStore());
67+
68+
var package = new UpdatePackageInfo
69+
{
70+
Version = "2.0.0",
71+
DownloadUrl = "https://example.com/app.apk",
72+
Sha256 = "A"
73+
};
74+
75+
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
76+
Directory.CreateDirectory(tempDir);
77+
78+
var failedRaised = false;
79+
manager.AddListenerUpdateFailed += (_, e) => failedRaised = e.Reason == UpdateFailureReason.IntegrityCheckFailed;
80+
81+
var result = await manager.ExecuteUpdateAsync(package, new UpdateOptions
82+
{
83+
CurrentVersion = "1.0.0",
84+
DownloadDirectory = tempDir,
85+
InstallOptions = new AndroidInstallOptions { FileProviderAuthority = "com.test.fileprovider" }
86+
}, CancellationToken.None);
87+
88+
Assert.False(result.IsSuccess);
89+
Assert.Equal(UpdateFailureReason.IntegrityCheckFailed, result.FailureReason);
90+
Assert.True(failedRaised);
91+
}
92+
93+
[Fact]
94+
public async Task ExecuteUpdateAsync_Should_ReturnFailure_WhenConcurrentExecutionIsRequested()
95+
{
96+
var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
97+
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
98+
var slowDownloader = new SlowDownloader(started, gate.Task);
99+
var manager = new AndroidBootstrap(
100+
slowDownloader,
101+
new FakeValidator(new HashValidationResult { IsSuccess = true, ExpectedHash = "A", ActualHash = "A" }),
102+
new FakeInstaller(),
103+
new UpdateFileStore());
104+
105+
var package = new UpdatePackageInfo
106+
{
107+
Version = "2.0.0",
108+
DownloadUrl = "https://example.com/app.apk",
109+
Sha256 = "A"
110+
};
111+
112+
var options = new UpdateOptions
113+
{
114+
CurrentVersion = "1.0.0",
115+
DownloadDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")),
116+
InstallOptions = new AndroidInstallOptions { FileProviderAuthority = "com.test.fileprovider" }
117+
};
118+
119+
var firstExecution = manager.ExecuteUpdateAsync(package, options, CancellationToken.None);
120+
await started.Task;
121+
122+
var secondExecution = await manager.ExecuteUpdateAsync(package, options, CancellationToken.None);
123+
124+
gate.SetResult();
125+
await firstExecution;
126+
127+
Assert.False(secondExecution.IsSuccess);
128+
Assert.Equal(UpdateFailureReason.AlreadyInProgress, secondExecution.FailureReason);
129+
Assert.Equal("An update execution is already in progress.", secondExecution.Message);
130+
}
131+
132+
[Fact]
133+
public async Task ValidateAsync_Should_NotBreak_WhenListenerThrows()
134+
{
135+
var logger = new RecordingLogger();
136+
var manager = new AndroidBootstrap(
137+
new FakeDownloader(),
138+
new FakeValidator(new HashValidationResult
139+
{
140+
IsSuccess = true,
141+
ExpectedHash = "A",
142+
ActualHash = "A"
143+
}),
144+
new FakeInstaller(),
145+
new UpdateFileStore(),
146+
logger);
147+
var package = new UpdatePackageInfo
148+
{
149+
Version = "2.0.0",
150+
DownloadUrl = "https://example.com/app.apk",
151+
Sha256 = "ABCDEF"
152+
};
153+
154+
var secondListenerCalled = false;
155+
manager.AddListenerValidate += (_, _) => throw new InvalidOperationException("test listener fault");
156+
manager.AddListenerValidate += (_, _) => secondListenerCalled = true;
157+
158+
var result = await manager.ValidateAsync(package, new UpdateOptions { CurrentVersion = "1.0.0" }, CancellationToken.None);
159+
160+
Assert.True(result.IsUpdateAvailable);
161+
Assert.True(secondListenerCalled);
162+
Assert.Contains(logger.Errors, message => message.Contains("AddListenerValidate listener", StringComparison.Ordinal));
163+
}
164+
165+
private static AndroidBootstrap CreateManager()
166+
{
167+
return new AndroidBootstrap(
168+
new FakeDownloader(),
169+
new FakeValidator(new HashValidationResult
170+
{
171+
IsSuccess = true,
172+
ExpectedHash = "A",
173+
ActualHash = "A"
174+
}),
175+
new FakeInstaller(),
176+
new UpdateFileStore());
177+
}
178+
179+
private sealed class FakeDownloader : IUpdateDownloader
180+
{
181+
public Task<DownloadResult> DownloadAsync(UpdatePackageInfo packageInfo, string targetFilePath, string temporaryFilePath, TimeSpan progressReportInterval, IProgress<DownloadStatistics>? progress, CancellationToken cancellationToken)
182+
{
183+
Directory.CreateDirectory(Path.GetDirectoryName(temporaryFilePath)!);
184+
File.WriteAllBytes(temporaryFilePath, [1, 2, 3]);
185+
186+
progress?.Report(new DownloadStatistics
187+
{
188+
DownloadedBytes = 3,
189+
TotalBytes = 3,
190+
RemainingBytes = 0,
191+
ProgressPercentage = 100,
192+
BytesPerSecond = 100
193+
});
194+
195+
return Task.FromResult(new DownloadResult
196+
{
197+
FilePath = targetFilePath,
198+
TemporaryFilePath = temporaryFilePath,
199+
TotalBytes = 3,
200+
UsedResumableDownload = false
201+
});
202+
}
203+
}
204+
205+
private sealed class FakeValidator(HashValidationResult result) : IHashValidator
206+
{
207+
public Task<HashValidationResult> ValidateSha256Async(string filePath, string expectedSha256, IProgress<double>? progress, CancellationToken cancellationToken)
208+
=> Task.FromResult(result);
209+
}
210+
211+
private sealed class FakeInstaller : IApkInstaller
212+
{
213+
public bool CanRequestPackageInstalls() => true;
214+
215+
public Task TriggerInstallAsync(string apkFilePath, AndroidInstallOptions options, CancellationToken cancellationToken)
216+
=> Task.CompletedTask;
217+
}
218+
219+
private sealed class SlowDownloader(TaskCompletionSource started, Task waitTask) : IUpdateDownloader
220+
{
221+
public async Task<DownloadResult> DownloadAsync(UpdatePackageInfo packageInfo, string targetFilePath, string temporaryFilePath, TimeSpan progressReportInterval, IProgress<DownloadStatistics>? progress, CancellationToken cancellationToken)
222+
{
223+
Directory.CreateDirectory(Path.GetDirectoryName(temporaryFilePath)!);
224+
started.TrySetResult();
225+
await waitTask;
226+
await File.WriteAllBytesAsync(temporaryFilePath, [1, 2, 3], cancellationToken);
227+
return new DownloadResult
228+
{
229+
FilePath = targetFilePath,
230+
TemporaryFilePath = temporaryFilePath,
231+
TotalBytes = 3,
232+
UsedResumableDownload = false
233+
};
234+
}
235+
}
236+
237+
private sealed class RecordingLogger : IUpdateLogger
238+
{
239+
public List<string> Errors { get; } = [];
240+
241+
public void LogError(string message, Exception? exception = null) => Errors.Add(message);
242+
243+
public void LogInfo(string message) { }
244+
245+
public void LogWarning(string message) { }
246+
}
247+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<ImplicitUsings>enable</ImplicitUsings>
5+
<Nullable>enable</Nullable>
6+
<LangVersion>latest</LangVersion>
7+
<IsPackable>false</IsPackable>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
12+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.0" />
13+
<PackageReference Include="xunit" Version="2.9.2" />
14+
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
15+
<PackageReference Include="coverlet.collector" Version="6.0.2" />
16+
</ItemGroup>
17+
18+
<ItemGroup>
19+
<ProjectReference Include="..\GeneralUpdate.Maui.Android\GeneralUpdate.Maui.Android.csproj" AdditionalProperties="TargetFramework=net10.0" />
20+
</ItemGroup>
21+
</Project>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
using GeneralUpdate.Maui.Android.Abstractions;
2+
using GeneralUpdate.Maui.Android.Services;
3+
using Microsoft.Extensions.DependencyInjection;
4+
using Xunit;
5+
6+
namespace GeneralUpdate.Maui.Android.Tests;
7+
8+
public sealed class GeneralUpdateBootstrapDiTests
9+
{
10+
[Fact]
11+
public void AddGeneralUpdateMauiAndroid_RegistersBootstrapAndCoreServices()
12+
{
13+
var services = new ServiceCollection();
14+
15+
services.AddGeneralUpdateMauiAndroid();
16+
17+
using var provider = services.BuildServiceProvider();
18+
var bootstrap = provider.GetRequiredService<IAndroidBootstrap>();
19+
20+
Assert.NotNull(bootstrap);
21+
Assert.IsType<AndroidBootstrap>(bootstrap);
22+
}
23+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Security.Cryptography;
2+
using GeneralUpdate.Maui.Android.Services;
3+
using Xunit;
4+
5+
namespace GeneralUpdate.Maui.Android.Tests;
6+
7+
public class Sha256ValidatorTests
8+
{
9+
[Fact]
10+
public async Task ValidateSha256Async_Should_ReturnSuccess_WhenHashMatches_IgnoringCase()
11+
{
12+
var file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".bin");
13+
await File.WriteAllBytesAsync(file, [10, 20, 30, 40]);
14+
15+
using var sha = SHA256.Create();
16+
var expected = Convert.ToHexString(sha.ComputeHash(await File.ReadAllBytesAsync(file))).ToLowerInvariant();
17+
18+
var sut = new Sha256Validator();
19+
var result = await sut.ValidateSha256Async(file, expected, progress: null, CancellationToken.None);
20+
21+
Assert.True(result.IsSuccess);
22+
Assert.Equal(expected, result.ExpectedHash);
23+
Assert.NotEmpty(result.ActualHash);
24+
}
25+
26+
[Fact]
27+
public async Task ValidateSha256Async_Should_ReturnFailure_WhenHashDoesNotMatch()
28+
{
29+
var file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".bin");
30+
await File.WriteAllBytesAsync(file, [1, 2, 3, 4]);
31+
32+
var sut = new Sha256Validator();
33+
var result = await sut.ValidateSha256Async(file, "FFFFFFFF", progress: null, CancellationToken.None);
34+
35+
Assert.False(result.IsSuccess);
36+
Assert.Equal("SHA256 does not match expected value.", result.FailureReason);
37+
}
38+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using GeneralUpdate.Maui.Android.Enums;
2+
using GeneralUpdate.Maui.Android.Events;
3+
using GeneralUpdate.Maui.Android.Models;
4+
5+
namespace GeneralUpdate.Maui.Android.Abstractions;
6+
7+
/// <summary>
8+
/// Provides orchestration APIs for Android update workflows.
9+
/// </summary>
10+
public interface IAndroidBootstrap
11+
{
12+
event EventHandler<ValidateEventArgs>? AddListenerValidate;
13+
14+
event EventHandler<DownloadProgressChangedEventArgs>? AddListenerDownloadProgressChanged;
15+
16+
event EventHandler<UpdateCompletedEventArgs>? AddListenerUpdateCompleted;
17+
18+
event EventHandler<UpdateFailedEventArgs>? AddListenerUpdateFailed;
19+
20+
UpdateState CurrentState { get; }
21+
22+
Task<UpdateCheckResult> ValidateAsync(UpdatePackageInfo packageInfo, UpdateOptions options, CancellationToken cancellationToken);
23+
24+
Task<UpdateExecutionResult> ExecuteUpdateAsync(UpdatePackageInfo packageInfo, UpdateOptions options, CancellationToken cancellationToken);
25+
}

0 commit comments

Comments
 (0)